Compare commits

...

95 Commits

Author SHA1 Message Date
sakuradairong
783294ce55 chore: snapshot backup before rainycy push (20260624-032434)
Auto-committed by MiMo for migration to git.rainycy.top
2026-06-24 03:26:33 +08:00
sakuradairong
3cb8244d77 i18n: add survey and aws marketplace translation keys 2026-06-24 01:27:28 +08:00
sakuradairong
1e0b124a03 i18n: localize webhooks module (general, headers, auth, config) 2026-06-24 01:23:37 +08:00
sakuradairong
9d1cd3e189 i18n: localize invoices module (tab, table, action cells, type cells) 2026-06-24 01:07:26 +08:00
sakuradairong
3b870a0e74 i18n: localize reverse-proxy terminated service badge 2026-06-24 00:53:04 +08:00
sakuradairong
9bbbeead1a i18n: localize notifications module (channels, modals, events) 2026-06-24 00:42:47 +08:00
sakuradairong
fdc7af186e fix(i18n): localize peer SSH, edit IP, routes, access tokens and setup keys
- Localize PeerEditIPModal config and buttons
- Localize PeerSSHToggle dialogs, tooltips, help text and callouts
- Localize AddRouteDropdownButton and RemoteJobDropdownButton
- Localize PeerRoutesTable and RouteMetricCell tooltips
- Localize AccessTokensTable empty state
- Localize SetupKeysTable filters, options and empty states
- Add missing translation keys to en.ts and zh.ts
- Fix t.rich tag usage for peerOfflineRemoteJob
- Fix singularize usage with ICU plural activePoliciesCount
- Normalize formatting with prettier
- Remove empty progress.md
2026-06-23 23:12:27 +08:00
sakuradairong
f9326f65c5 fix(i18n): localize PeerSSHToggle and PeerEditIPModal
- PeerSSHToggle: sshAccess, enableSSH
- PeerEditIPModal: changesTakeEffect, cancel
- Add sshAccess, enableSSH, changesTakeEffect keys
2026-06-23 22:28:52 +08:00
sakuradairong
722c9fb8bf fix(i18n): localize AccessTokensTable and SetupKeysTable columns
- Refactor both table column defs from const to function with t() param
- Add lastUsed, lastUsedOn, nameAndKey, expires keys to common/setupKeys namespaces
2026-06-23 22:24:58 +08:00
sakuradairong
249da7788a fix(i18n): convert UsersTable columns to useTranslations
- Refactor UsersTableColumns from const to function with t() param
- Add useTranslations('users') to UsersTable component
- Replace hardcoded Name, Role, Status, Groups, Last Login headers
2026-06-23 22:16:32 +08:00
sakuradairong
9cd0d6ccbe fix(i18n): convert PeersTable columns to useTranslations
- Refactor PeersTableColumns from const to function with t() param
- Add useTranslations('peers') to PeersTable component
- Replace hardcoded Name, Address, Groups, OS, Serial number, Version headers
- Add selectAll, selectRow, unknown keys
2026-06-23 22:14:13 +08:00
sakuradairong
e89c56b8ec fix(i18n): commit remaining locale changes for team/user, PeerMultiSelect, AuthenticationTab, UserInviteModal 2026-06-23 22:03:49 +08:00
sakuradairong
11ca0c64a4 Merge remote-tracking branch 'origin/main' into localize-zh-v2.39.0
# Conflicts:
#	src/app/(dashboard)/team/users/page.tsx
2026-06-23 22:03:34 +08:00
sakuradairong
c8eec5a818 fix(i18n): add accessControlGroups and autoApply keys 2026-06-23 21:59:38 +08:00
Maycon Santos
8c22e64d02 Restrict cloud/licensed-only API calls in open-source mode (#675)
Gate premium endpoints so the open-source dashboard stops calling
endpoints the open-source management server does not serve:

- MSP/Distributor: only fetch /integrations/msp, /integrations/msp/switcher
  and /integrations/msp/reseller on NetBird Cloud
- EDR bypass: only fetch /peers/edr/bypassed when licensed
- Event Streaming: gate /integrations/event-streaming GETs on the license
  in addition to the existing permission check
- useIsLicensed: cache the licensed-only probe result (per API origin, 1h
  TTL) so it no longer re-fires on every page load
- Team > Users: show the Identity Provider Sync tile in open-source mode so
  IdP sync remains discoverable
2026-06-23 15:59:15 +02:00
sakuradairong
c0fe4d07e4 fix(i18n): localize PeerMultiSelect and add route keys
- Add useTranslations to PeerMultiSelect (assigningGroups, assignGroups)
- Add accessControlGroups, autoApply keys for RouteTable
2026-06-23 21:59:01 +08:00
sakuradairong
0ccc0f93f3 fix(i18n): localize AuthenticationTab
- Add useTranslations('settings')
- Replace hardcoded Session Expiration, Days, Hours, Require login with existing t() keys
2026-06-23 21:56:25 +08:00
sakuradairong
fcb1e8c4d4 fix(i18n): localize UserInviteModal and team/user page
- Add userRole, userRoleHelp to users namespace
- Add expiresIn, expiresInHelp to users namespace
- Replace hardcoded User Role, Expires in with t() calls
- Add useTranslations to UserInviteModal
2026-06-23 21:55:16 +08:00
sakuradairong
acf427db4d fix(i18n): localize groups sections, DNS records, route modals, reverse proxy
- GroupPeersSection: lastSeen, removePeersFromGroup
- GroupUsersSection: blockUser, lastLogin
- AssignUserToGroupModal: lastLogin
- DNSRecordModal: recordTypeAAAA, recordTypeCNAME
- RouteAddRoutingPeerModal: addNewRoutingPeer, networkIdentifier, routingPeer, distributionGroups
- RouteUpdateModal: routingPeer, peerGroup, distributionGroups, metric
- ReverseProxyAccessControlRules: accessControlRules
- ReverseProxyTargetCustomHeaders: customHeaders
- Add corresponding keys to en.ts and zh.ts
2026-06-23 21:46:57 +08:00
sakuradairong
8e40fd8d84 fix(i18n): localize IdentityProviderModal, ChangePasswordModal, CreateDebugJobModal
- Add IDP keys (idpProviderType, idpName, idpClientId, etc.)
- Add common keys (currentPassword, newPassword, logFileCount, duration)
- Add settings/allowSSH keys for PeerSSHInstructions
2026-06-23 21:40:56 +08:00
sakuradairong
dc9c9d9735 fix(i18n): localize RouteModal, SetupKeyModal, CreateAccessTokenModal
- RouteModal: routeType, networkRange, domains, distributionGroups, metric
- SetupKeyModal: createTitle, nameHelp, usageLimitHelp, expiresIn
- CreateAccessTokenModal: tokenName, tokenNameHelp, tokenExpiresIn
- Add corresponding keys to en.ts and zh.ts
2026-06-23 21:38:18 +08:00
sakuradairong
312c32f6ea fix(i18n): localize Peer sections and Posture Checks components
- AccessiblePeersSection, PeerNetworkRoutesSection, PeerRemoteJobsSection
- PostureCheckGeoLocation, PostureCheckNetBirdVersion, PostureCheckOperatingSystem
- PostureCheckPeerNetworkRange, PostureCheckProcess, PostureCheckNoChecksInfo
- Update en.ts and zh.ts messages with new translation keys
2026-06-23 21:25:55 +08:00
sakuradairong
055252d68b Merge remote-tracking branch 'origin/main' into localize-zh-v2.39.0
# Conflicts:
#	package-lock.json
#	src/app/(dashboard)/dns/settings/page.tsx
#	src/app/(dashboard)/events/audit/page.tsx
#	src/app/(dashboard)/peer/page.tsx
#	src/app/(dashboard)/peers/servers/page.tsx
#	src/app/(dashboard)/peers/users/page.tsx
#	src/app/(dashboard)/reverse-proxy/services/page.tsx
#	src/app/(dashboard)/settings/page.tsx
#	src/app/(dashboard)/team/user/page.tsx
#	src/app/(dashboard)/team/users/page.tsx
#	src/components/ui/AddGroupButton.tsx
#	src/components/ui/AddPeerButton.tsx
#	src/layouts/Navigation.tsx
#	src/modules/access-control/AccessControlModal.tsx
#	src/modules/access-control/table/AccessControlActionCell.tsx
#	src/modules/access-control/table/AccessControlTable.tsx
#	src/modules/common-table-rows/GroupsRow.tsx
#	src/modules/groups/table/GroupsActionCell.tsx
#	src/modules/networks/NetworkModal.tsx
#	src/modules/networks/resources/NetworkResourceAccessControl.tsx
#	src/modules/networks/resources/NetworkResourceModal.tsx
#	src/modules/peers/PeerActionCell.tsx
#	src/modules/peers/PeerMultiSelect.tsx
#	src/modules/peers/PeersTable.tsx
#	src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
#	src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx
#	src/modules/reverse-proxy/ReverseProxyModal.tsx
#	src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
#	src/modules/reverse-proxy/clusters/ClustersModal.tsx
#	src/modules/reverse-proxy/domain/CustomDomainModal.tsx
#	src/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders.tsx
#	src/modules/settings/AuthenticationTab.tsx
#	src/modules/settings/ClientSettingsTab.tsx
#	src/modules/settings/DangerZoneTab.tsx
#	src/modules/settings/GroupsSettings.tsx
#	src/modules/settings/IdentityProviderModal.tsx
#	src/modules/settings/NetworkSettingsTab.tsx
#	src/modules/settings/PermissionsTab.tsx
#	src/modules/users/ServiceUserModal.tsx
#	src/modules/users/ServiceUsersTable.tsx
#	src/modules/users/UsersTable.tsx
2026-06-23 21:20:35 +08:00
Maycon Santos
7653e3411c 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.
2026-06-21 16:01:08 +02:00
Maycon Santos
86b6e23981 Update announcements.json (#673) 2026-06-20 16:43:04 +02:00
sakuradairong
bddc357b0e fix(i18n): localize Resource Access Control, Peer Expiration, Peer Settings
NetworkResourceAccessControl: "Access Control Policies" label + help text
PeerExpirationToggle: session expiration description, tooltip text (setup
key disabled, no permission, global setting disabled, go to settings)
PeerExpirationSettings: notification texts, inactivity expiration text

New keys in peers + networks namespaces (en + zh).
2026-06-20 22:21:45 +08:00
sakuradairong
d894c7440f fix(i18n): localize Settings tab sub-categories (forms, labels, help text)
Replaces hardcoded English text inside all 7 settings tabs with
useTranslations("settings") calls. Covers:

- AuthenticationTab: Session Expiration, User Approval, Local MFA,
  Peer Session Expiration, Days/Hours, Require login after disconnect
- ClientSettingsTab: Automatic Updates, Force Updates, Expose Services
  from CLI, Enable Peer Expose, Allowed peer groups, Lazy Connections
- GroupsSettings: Group propagation, JWT group sync, JWT claim,
  JWT allow groups, group access warning
- NetworkSettingsTab: DNS Domain, Network Range, IPv6 ranges,
  IPv6 enabled groups, DNS Wildcard Routing, validation errors
- IdentityProvidersTab: table headers (Name/Type), description
- SetupKeysTab: description text
- PermissionsTab: Restrict dashboard toggle, Save Changes

40+ new translation keys added to settings namespace (en + zh).
2026-06-20 22:00:07 +08:00
Eduard Gert
389a0c0785 update banner (#672) 2026-06-20 15:59:23 +02:00
sakuradairong
5c4df7aeb1 merge: resolve conflict with origin/main (IdentityProviderModal logout URL)
Resolve merge conflict in IdentityProviderModal.tsx by adopting upstream
main's new 'Endpoint URLs' section (Redirect/Callback + Logout URLs from
PR #657), then re-applying i18n: useTranslations import, Cancel button
localization, and new translation keys for the added endpoint/logout text
(endpointUrls, redirectCallback, logoutLabel, notAllProvidersLogout,
learnMore) in en + zh.

Also brings in upstream DNS Zones & Setup modal improvements (#669),
banner IPv6 link (#662), remote jobs docs link (#664).
2026-06-20 21:40:32 +08:00
sakuradairong
50c2ffac0b fix(i18n): localize Danger Zone account deletion text
Replaces all hardcoded English strings in DangerZoneTab with
useTranslations("settings") calls: card title, warning paragraph,
confirm/cancel dialog, notify messages, and button label.
i18n keys added to settings namespace (en + zh).
2026-06-20 21:18:30 +08:00
sakuradairong
77b413d960 fix(i18n): localize aria-label attributes across 12 components
Replaces 19 hardcoded aria-label strings with useTranslations() calls:

- "Select all" / "Select row" — group assignments, posture checks
- "Configure policies" / "Configure access control" — network, reverse proxy
- "Configure authentication" / "Remove rule" / "Remove header" — reverse proxy
- "Public listen port" / "Destination port" — reverse proxy L4 config
- "Select language" — locale switcher

Also refactors 5 module-level column arrays into factory functions
to pass the translation function through column definitions.
i18n keys added to common namespace (en + zh translations).
2026-06-20 20:25:32 +08:00
sakuradairong
2dc523ca9e fix(i18n): localize Cancel button text across 25 modal components
Replaces all hardcoded "Cancel" button text with {t("cancel")} using the
common namespace in every modal component, plus a few adjacent button
texts. Adds import { useTranslations } + useTranslations("common") to
each component that needed it.

Files covered: access-tokens, jobs, networks, peer, posture-checks,
remote-access/ssh, reverse-proxy/auth, reverse-proxy/clusters,
reverse-proxy/domain, routes, settings, setup-keys, users — all modal
dialogs with Cancel buttons now respect the active locale.
2026-06-20 19:52:21 +08:00
sakuradairong
96f15d7b48 fix(i18n): localize remaining hardcoded titles in user detail, peers section and onboarding
Covers four high-visibility hardcoded headings and surrounding text:

- User detail page: "Access Tokens" tab label + heading + description
- UserPeersSection: "Peers" heading + "View all peers..." description
- OnboardingIntent: "Get started with NetBird" title
- OnboardingAddResource: "Add your first resource" title

i18n keys added: users.accessTokens, users.accessTokensDescription,
peers.userPeersDescription, onboarding.title, onboarding.addResource
(en + zh translations).
2026-06-20 19:31:28 +08:00
sakuradairong
c05a3ee051 fix(i18n): localize remaining hardcoded UI strings across settings and auth
Replaces all hardcoded English text in 8 settings tab components and the
SessionLost page with useTranslations() calls, so they render in the user's
active locale (English or Chinese):

- Settings tabs: Authentication, Clients, Groups, Networks, Danger Zone,
  Setup Keys, Identity Providers, Permissions — breadcrumbs and h1 headers
  now use the existing settings namespace keys.
- SessionLost: "Session Expired" description + "Login" button localized
  via the auth namespace.
- i18n keys added: auth.sessionExpiredDescription, dns.nameserversDescription,
  dns.zonesDescription (en + zh translations).
2026-06-20 19:17:40 +08:00
sakuradairong
98a7923400 feat(i18n): cookie-based locale detection with English default and switcher
Previously the locale was hardcoded to "zh" in AppLayout's
NextIntlClientProvider, so every user saw Chinese with no way to switch.

This adds proper client-side locale resolution suitable for the static
export build (`output: "export"` — no server runtime / middleware):

- New single source of truth `src/i18n/config.ts` (locales, defaultLocale,
  message catalog, cookie name, timezones) consumed by request.ts,
  routing.ts, navigation.ts and the new provider.
- New pure detection helpers `src/i18n/detection.ts`:
  cookie (NEXT_LOCALE) -> browser language (navigator.languages) ->
  default. All branches are SSR/build-safe to avoid hydration mismatches.
- New `LocaleProvider` context wraps NextIntlClientProvider, resolves the
  locale on mount, syncs <html lang>, and exposes { locale, setLocale }.
- AppLayout now uses LocaleProvider instead of the hardcoded "zh" wiring.
- New `LanguageSwitcher` in the header and a new `Language` tab in
  Settings, both persisting the choice via the cookie for reloads.

Default locale is now English ("en"); Chinese remains available via the
browser-language fallback or the explicit switcher. Includes a manual
assertion script (detection.test.ts) covering cookie precedence, browser
fallback, persistence and SSR safety.
2026-06-20 18:57:43 +08:00
sakuradairong
6c363b681d fix(i18n): localize RestrictedAccess component and add restrictedAccess keys to common namespace 2026-06-19 02:36:35 +08:00
sakuradairong
4bd5eda04b fix(i18n): address CodeRabbit review — localize confirmation dialogs, tooltips, table headers, timestamps, and fix plural/aria-label issues 2026-06-19 02:31:42 +08:00
sakuradairong
21e4ceb11f fix(server): add comment explaining single-locale /zh fallback logic 2026-06-19 02:23:10 +08:00
sakuradairong
bb6c3043f2 feat(i18n): localize remaining reverse proxy, table headers and modal strings 2026-06-19 02:17:22 +08:00
sakuradairong
da01b5bd93 feat(i18n): expand Chinese localization and fix env substitution 2026-06-19 01:52:22 +08:00
sakuradairong
27fe31099a feat(docker): containerize localized dashboard
- Update Dockerfile to use node:22-alpine with custom static server
- Add server.js for serving Next.js static export with path resolution
- Fix path.join issue with absolute paths in Node.js
- Remove middleware.ts (incompatible with output: 'export')
- Add NextIntlClientProvider to AppLayout for static export compatibility
- Update routing to use zh as default locale
2026-06-18 22:55:40 +08:00
sakuradairong
c4469af733 feat(i18n): localize DNS, Reverse Proxy, Settings, Posture Checks, Network Routes pages
- Localize DNS pages (nameservers, zones, settings)
- Localize Reverse Proxy pages (services, custom-domains, clusters, logs)
- Localize Settings page with all vertical tab labels
- Localize Posture Checks page
- Localize Network Routes page
- Add comprehensive translation keys for all above modules
- Fix import paths for reverse-proxy and posture-checks tables
2026-06-18 22:13:45 +08:00
sakuradairong
bd2c5ce473 feat(i18n): localize Users, Service Users, Groups main pages
- UsersTable with status, role, group filters localized
- ServiceUsersTable localized
- Team main pages (users, service-users) localized
- Add new i18n keys for additional status, role, and action labels
2026-06-18 21:30:44 +08:00
sakuradairong
f2fc11b89e feat(i18n): localize Peers, Access Control, Groups modules to Chinese
- Expand en.ts/zh.ts with extensive translation keys for all modules
- Localize Peers table, peer detail page, peer action cells
- Localize Access Control table, modal, action cells
- Localize Groups table, action cells, main page
- Add common helpers (GroupsRow, NoPeersGettingStarted) translations

Continuation of the localization effort.
2026-06-18 21:22:45 +08:00
Misha Bragin
bf6601782d DNS Zones & Setup modal improvements (#669) 2026-06-15 16:02:33 +02:00
Max
5eac5162e0 Insert link to remote jobs documentation (#664)
Replaced general link to docs homepage with exact Documentation link for Remote Jobs
2026-06-12 09:39:36 +02:00
Brandon Hopkins
1d8d32dd12 Edit banner to IPv6 kh link (#662) 2026-06-05 12:54:59 -07:00
Brandon Hopkins
578d890bb5 Add Logout URL to the Identity Provider dialog (#657)
* Add Logout URL section

* Condensed the URL section, added logout note
2026-06-05 08:28:32 -07:00
Maycon Santos
e8f0f20455 Update reverse proxy modals (#661)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* add multi-environment deployment options for reverse proxy setup

* refactor reverse proxy modal to handle state updates more robustly
2026-06-04 20:43:45 +02:00
Misha Bragin
8c94090e3d Group Networks and Routes under Network Routing (#660) 2026-06-04 19:44:13 +02:00
Misha Bragin
1917df6f60 Improve Table Filters Layout (#654) 2026-06-04 18:24:53 +02:00
Misha Bragin
358b477ded Move setup keys to Settings (#653) 2026-05-27 17:17:04 +02:00
Maycon Santos
f535fe2667 Feature/private service expose (#646)
* feat: private service expose

Dashboard surface for the netbird private-service feature: the
reverse-proxy modal gains a Private toggle and the target modal gains
a Direct-upstream option with custom upstream host, both feeding the
backend's Service.private + target.direct_upstream / target.host
fields. The Proxy Events page wraps its table in PeersProvider so
peer-name fallback resolution works for tunnel-peer callers.

Reverse-proxy modal changes (ReverseProxyModal.tsx):
- Private toggle that pivots the modal between standalone and cluster
  target types and auto-injects the cluster target.

Reverse-proxy target modal changes:
- Direct upstream toggle (target dials via the host stack instead of
  the embedded NetBird client).
- Custom upstream host input revealed when Direct upstream is on.
- New ReverseProxyClusterTargetSelector + ReverseProxyAddressInput.
- useReverseProxyTargetOptions updated for the new option shape.

Proxy Events table:
- Reuses across the reverse-proxy events surface; UserCell falls back
  to the peer name when no user is attached to the call.

* feat(reverse-proxy): move NetBird-only access to auth tab + access groups

Restructures the Private (NetBird-only) flow so the auth model is
clearer:

- Removes the 'Private (NetBird-only)' toggle from the Service main
  page. Service mode is the only primary choice now.
- Adds a 'NetBird-only access' toggle on the Authentication tab, gated
  on serviceMode=HTTP and selectedDomain.supports_private===true. When
  the cluster doesn't support it, the toggle is disabled with an
  inline note explaining why.
- Adds an Access Groups picker (PeerGroupSelector) inline on the auth
  tab when NetBird-only is on. Replaces the previous bearer-auth
  distribution_groups overload — these groups go on the new
  service.access_groups field on the wire.
- When NetBird-only is enabled, all other auth modes (SSO, password,
  PIN, headers, link) are hidden — the inbound peer's WireGuard
  identity is the only auth path.
- Adds a 'Direct upstream' toggle to the Advanced settings tab,
  gated on isPrivate + cluster supports private. The toggle is
  service-level in the UI; on save it patches the (single) cluster
  target's options.direct_upstream.
- togglePrivate now also clears bearer/password/pin/header/link
  state when entering private mode (strict mutual exclusivity).

* fix(reverse-proxy): tighten NetBird-only flow per review

Five fixes from the first cut:

1. Auth-tab design mismatch. The NetBird-only block was an inline
   FancyToggleSwitch + inline PeerGroupSelector. It now follows the
   same SettingCard.Item pattern as SSO/Password/PIN/Headers: a
   clickable row showing enabled state that opens a dedicated modal
   (AuthNetBirdOnlyModal) where the access groups are picked.

2. Trimmed wordy NetBird-only description down to one line.

3. Trimmed wordy Direct upstream help text.

4. Removed the per-target Direct upstream toggle from the target
   modal. Direct upstream now lives only at the service level (under
   Advanced settings) for private services. Cluster targets still
   imply direct_upstream via the existing sanitizeTargets path, so
   the wire stays correct.

5. togglePrivate no longer drops the user's existing targets when
   entering private mode. The previous behavior silently dropped any
   non-cluster targets the operator had configured, leaving the
   targets list empty and the Save button disabled. Now: targets
   stay put, canSaveService gates on 'all targets are cluster type'
   so the Save button accurately reflects what the backend will
   accept, and an inline warning explains what to fix when the
   constraint isn't met.

The AuthNetBirdOnlyModal requires at least one access group before
Enable becomes clickable, mirroring the SSO modal's pattern.

* fix(reverse-proxy): allow any target type on private services

The cluster-target restriction was a holdover from the previous
auth-by-bearer-groups model where only cluster targets exposed a
proxy peer that could host the ACL. With the new access_groups path
the ACL is server-side and works regardless of how the proxy reaches
the upstream.

- Drop the inline 'Private services only support cluster targets'
  warning.
- canSaveService no longer requires all targets to be cluster type.
- Service-level Direct upstream now applies to every target (cluster,
  peer, host, domain) when private, so the operator can mix target
  types and still control the dial path globally.
- Tightened NetBird-only description to '...connected peers in the
  selected NetBird groups.' (per review).
- Tightened Direct upstream help to '...reachable without a Wireguard
  connection.' (per review).

A future iteration may add a dedicated cluster-only mode with its own
guided flow; for now the operator picks whatever target types suit
their topology.

* fix(reverse-proxy): count NetBird-only access as protection

The 'No Protection Configured' popup fired even on private services
because isUnprotected only checked the password/PIN/bearer/header/link
auth modes. NetBird-only access is also a form of protection (tunnel
identity + access groups), so include isPrivate in the gate.

* fix(reverse-proxy): show NetBird-only auth and resolve access group names

Two display bugs for private services:

- ReverseProxyModal seeded accessGroups with {id}-only stubs, which
  made useGroupHelper skip its ID→Group resolution and render empty
  pills in the NetBird-only modal when editing an existing service.
  Pass the raw string[] so useGroupHelper resolves full Group objects
  with names against the GroupsProvider.

- ReverseProxyAuthCell only inspected password/pin/bearer/headers
  auth flags, so a NetBird-only service displayed "No Auth" in the
  services table. Add a NetBird-only entry that counts toward the
  auth badge, renders as "NetBird Only" with the CircleUser icon
  when it's the single auth, and lists the access groups (name +
  user count) in the hover.

* feat(reverse-proxy): unify proxy-cluster target into the peer/resource selector

Operators picking a target for a service now see Proxy Clusters as a
third tab alongside Peers and Resources, gated on at least one cluster
advertising supports_private. When the service already has a proxy
cluster the tab lists only that cluster; when it doesn't, picking a
cluster commits it as the service's domain. The dedicated private-mode
cluster picker is removed in favour of this unified flow.

Selecting a cluster target now also auto-flips the service to
NetBird-only, since cluster targets are only reachable over the
WireGuard overlay and SSO/password/PIN advertise an auth path no
public client could exercise. The Access Control tab carries a note
explaining that an allow rule for the account's NetBird network range
is applied automatically alongside any operator-configured rules.

PeerGroupSelector gains opt-in showClusters/clusters/selectedCluster/
onClusterChange props plus a Proxy Clusters tab in its TabsList; the
tabOrder union widens to include "clusters". All call sites that don't
pass showClusters render unchanged.

* fix(reverse-proxy): polish private-service copy and lock Direct Upstream for cluster targets

Title-case NetBird-Only Access and Direct Upstream so the labels read
consistently across the auth tab, the dedicated modal, and the
services-table auth cell.

Cluster targets are reached via the embedded proxy's host network
stack and have no WireGuard endpoint to fall back to, so toggling
Direct Upstream off would silently break them. When any target is a
cluster the toggle is forced on, the FancyToggleSwitch is disabled,
and the helper text explains why. The save path writes the locked-on
value to every target's options.direct_upstream so the persisted
state matches what the UI shows.

* fix(reverse-proxy): support dynamic placeholder and restrict input to IPv4 or IPv6 when required

* fix(reverse-proxy): rename "Agent" to "Peer" in events user cell tooltip

* feat(reverse-proxy): gate NetBird-Only + Direct Upstream on cluster capability

When the selected cluster doesn't have at least one connected embedded
proxy (`netbird proxy`), the NetBird-Only Access and Direct Upstream
controls are visually disabled with a tooltip explaining why, instead of
silently no-op (NetBird-Only) or hidden (Direct Upstream).

- ReverseProxyCluster interface gains the optional `private` flag that
  the management API now exposes on /api/reverse-proxies/clusters.
- ClustersFeaturesCell renders a new "Private" badge for clusters whose
  `private === true`, matching the other capability badges.
- SettingCard.Item gains a `disabled` prop: opacity-50 + cursor-not-allowed,
  click/keyboard guarded, Add/Edit button greyed out. No visual change
  for existing usages (default false).
- ReverseProxyModal:
  - NetBird-Only Access card is now wrapped in a FullTooltip that
    activates only when the cluster lacks support; the SettingCard.Item
    is rendered with `disabled` in that case. Description text stops
    switching based on cluster state — the tooltip explains the gate.
  - Direct Upstream toggle is no longer hidden when supports_private is
    false; it stays visible and disabled (alongside the existing
    hasClusterTarget locked-on case), wrapped in a tooltip explaining
    the cluster requirement. Existing private services whose cluster
    lost the capability now show the control disabled rather than
    vanishing.

* disable button when no group were selected

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Feature/private service expose update (#651)

* feat(reverse-proxy): refine UI state and copy for NetBird-Only and Direct Upstream controls

- Default `Direct Upstream` to `false` for peer/resource targets; enhance tooltip descriptions.
- Improve conditional rendering of `NetBird-Only Access` setting with appropriate tooltips for unsupported clusters.
- Add cluster badge interaction in `PeerGroupSelector` and dynamic placeholder handling.
- Simplify `ReverseProxyTargetSelector` messaging for proxy forwarding options.
- Enhance `ClustersFeaturesCell` with richer description for the "Private" badge.

* refactor(reverse-proxy): improve tooltip and modal text for clarity

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-26 21:37:11 +02:00
Misha Bragin
52f7020a0f Fix broken SSH and RDP (#650) 2026-05-26 20:14:36 +02:00
Misha Bragin
a604643f9a Feature/simplified layout (#649)
Splits the Peers sidebar entry into User Devices (/peers/users) and Servers (/peers/servers), with /peers redirecting to User Devices and a shared kind filter splitting peers
by whether the owner is a real user vs a service/no-user. The Servers page description and an inline link replace what was the old "Setup Keys" sidebar item under Peers.
2026-05-25 13:09:30 +02:00
Maycon Santos
42cd088c5d rebuild self-hosted page as Clusters with type features (#641)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* feat(reverse-proxy): rebuild self-hosted page as Clusters with type + features

The Self-Hosted Proxies page was account-only by design but the
underlying API already returned every cluster the account could see.
Lifting that filter and renaming the page surfaces shared clusters
too — operators can see what NetBird-deployed clusters are reachable
alongside their own self-hosted ones, with online status and feature
support visible per row.

ReverseProxyCluster matches the new backend shape: `type`
(account/shared), `online`, and the three capability flags. The
`isSelfHostedCluster` provider hook now compares against `type ===
account` instead of a deprecated boolean.

Page folder renamed self-hosted-proxies → clusters (history-preserving
git mv). Table columns: Cluster (with an EphemeralPeerIndicator-style
icon next to the name marking account vs shared and a colored dot for
online status), Connected Proxies (plain numeric), Features (one
tooltip-backed badge per supported capability), Actions (Delete only
on account-owned rows; shared clusters render an empty action cell).

Empty state shows when the list is fully empty with a doc link in the
page header. Sidebar entry restored under Reverse Proxy.

* Update record in modal, update doc link, update modal title

* update reverse proxy documentation links to latest anchors

* update cluster modal description to "proxy cluster" instead of "self-hosted cluster"

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-05-20 11:48:38 +02:00
Maycon Santos
7400ac806e remove self-hosted proxies menu item (#640)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-05-14 17:50:07 +02:00
Viktor Liu
240ff5af9a Fix IPv6 input across reverse proxy, routes and resources (#638) 2026-05-14 16:43:02 +02:00
Eduard Gert
dc86c30463 Add self-hosted proxies (#636)
* Add self-hosted proxies

* fix selfhosted badge for domain
2026-05-12 15:22:12 +02:00
Nicolas Frati
e58f75ae3c Enable MFA for local users toggle (#615)
* implement enable mfa for local users toggle

* fix visibility check

* Added beta badge to MFA auth toggle
2026-05-08 16:51:17 +02:00
Viktor Liu
dc1adebd27 Add IPv6 overlay settings and peer display (#594) 2026-05-07 15:20:12 +02:00
Bethuel Mmbaga
d76cbd1122 Add Microsoft AD FS support for embedded Dex identity providers (#625) 2026-04-28 12:42:48 +03:00
Eduard Gert
01330e0f58 Fix missing peer context in group network routes tab (#620)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-23 17:05:05 +02:00
Viktor Liu
e9ac1a1a23 Add CrowdSec IP reputation (#600)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-21 12:29:37 +02:00
raghvendra
b53802a5c5 fix: prevent storage clear and logout on failed account deletion (#611) 2026-04-13 09:07:14 +02:00
Eduard Gert
9addc18956 Fix reverse proxy mode selection (#606)
* Fix reverse proxy mode selection

* Fix isNetBirdHosted

* Fix activity description
2026-04-09 09:52:35 +02:00
shuuri-labs
9701e6503b Add new pull request template + enforce documentation acknowledgement… (#602)
* Add new pull request template + enforce documentation acknowledgement in new workflow

* fix docs-ack workflow: pass PR number via env and simplify checkbox validation
2026-04-02 21:39:38 +02:00
Eduard Gert
0841caecbb Fix dns zone domain validation and peers last seen sort (#595)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-25 17:50:22 +01:00
Eduard Gert
c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu
8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert
34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
Viktor Liu
aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add layer 4 proto support

* Fix initialResource fallback and UDP session_idle_timeout

* Fix tlsResourceId init for resource-driven create flows, UDP timeout label

* Address PR review: ServiceMode enum, resource init fix, modal title, a11y

* Add L4 protocol values to ReverseProxyTargetProtocol, remove unsafe double cast

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

* Fix custom port reset on edit and show port in L4 service link

* Remove redundant listen port from L4 target cell

* Show link only for HTTP/TLS services, copy-on-click for TCP/UDP

* Move mode badge before domain and use fixed width for alignment

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

* Remove advanced settings tab in http targetmodal and use accordion instead

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-18 17:43:00 +01:00
Zoltan Papp
bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

Add "Always Update" toggle to the Clients settings tab that controls
whether updates are installed automatically in the background or require
user interaction from the UI. Includes a warning icon and caution callout
when enabled to highlight the risk of disrupting active connections.

* [dashboard] fix: improve auto-update UI clarity and toggle label

Clarify that automatic updates require user interaction by updating the
description. Rename "Always Update" to "Force Automatic Updates" for
clarity. Move warning callout below the toggle switch instead of inside it.

* Update src/modules/settings/ClientSettingsTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
Misha Bragin
4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
Eduard Gert
15fb6e0b05 Refactor resource modal (#582)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-12 16:30:51 +01:00
Eduard Gert
55c5525626 Fix resource group policy when adding single resource as destination (#581)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-11 19:23:59 +01:00
Eduard Gert
c0c1f4688e Add proxy events sort (#560)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add proxy events sort

* Fix coderabbit comment

* Disable local sort when server pagination is used
2026-03-10 10:10:53 +01:00
Eduard Gert
b5a8f751ba Create policies inside resources (#568)
* Add acl tooltips

* Adjust resource modal and add tooltips

* Prevent nextjs navigation trigger on tab change

* Update wording

* add acl into resource

* Refactor resource policies

* Add prop to hide group edit and disable redirect

* Add skeleton loader to network page

* Create policy for new resources

* Show existing policies if groups are matching

* Add confirm dialog after creating resource without policy

* Add dialog if user edits policy that is used in multiple resources

* Add callout when selecting resource groups containing policies

* Add dialog if deleting policies containing resources

* Fix stale policies and new group creation in resource modal

* Remove whitespace

* Fix sort

* Cleanup

* Address coderabbit comments

* Fix policy alignment

* Fix initial resource

* disable selector if user did not select  resource groups

* Consider current resource when editing / deleting policy

* Remove unused mutate

* Fix dot position

* Remove ask for policy

* Fix policy index

* Fix multiple resource confirm dialog on policy cell
2026-03-10 10:10:38 +01:00
Eduard Gert
10a8e7b745 Fix stale certificate issued state (#575)
* Fix stale certificate issued state

* fix coderabbit
2026-03-09 10:08:35 +01:00
Viktor Liu
60e8394010 Add per-target options to reverse proxy (#576) 2026-03-06 18:55:28 +01:00
Eduard Gert
9420214059 Bump minimatch and ajv dependencies (#572) 2026-03-02 11:32:52 +01:00
Maycon Santos
b949f60afe Feature/client service expose (#567)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* add draft

* add reverse proxy activities

* move peer expose settings into client settings tab and fix activity descriptions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* prevent false positive group report

* add docs link

* allow save when groups are added to the setting

* Add loading skeleton to client settings, update icon, use grouphelper to allow creating new groups, remove .patch

* mv expose settings from extra settings

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-02-24 14:54:58 +01:00
Eduard Gert
d498e4cc25 Fix dns records pagination (#566)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-20 21:42:26 +01:00
Eduard Gert
130dc0c32c Fix group unused filter (#565)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-19 10:32:14 +01:00
Eduard Gert
f5824d6ddb Allow empty groups for reverse proxy sso auth (#563)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-18 16:27:13 +01:00
Eduard Gert
829395f908 Add hover to reverse proxy auth methods (#564) 2026-02-18 13:39:19 +01:00
Eduard Gert
8eebec78b4 Preserve query params for ssh and rdp (#559)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 17:34:08 +01:00
raghvendra
3e01a6dafd refactor: simplify FullScreenLoading to use boolean prop instead of string union (#555) 2026-02-16 11:10:26 +01:00
Maycon Santos
1555b94043 Fix service cluster status (#556)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-16 09:23:22 +01:00
Eduard Gert
6c62127d42 Update announcement (#553) 2026-02-13 20:56:40 +01:00
Eduard Gert
b71d0fde89 Add reverse proxy (#552)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* **New Features**
  * Full Reverse Proxy UI: Services, Targets, Clusters, Custom Domains (with verification) and a Proxy Events page.
  * In-app modals for service auth (SSO, password, PIN) and a new PIN input component.

* **Improvements**
  * Network & Peer pages: tabbed views (Resources, Routing Peers, Services) and improved tables, search and filters.
  * Toast stacking/visibility and global toast styling refined.
2026-02-13 18:59:16 +01:00
Misha Bragin
84c239ce30 Indicate that local user auth is disabled (#551) 2026-02-12 15:16:34 +01:00
Aaron Dewes
ba66201c64 Remove architecture info tooltip for MacOS (#550)
* Remove architecture info tooltip for MacOS

Previously, this tooltip helped users determine which binary to download. Since #501, there is only one universal binary download link, so keeping the tooltip explaining how to determine the CPU architecture is unnecessary.

* fix: Remove unused imports
2026-02-12 11:21:08 +01:00
raghvendra
c6341e000f docs: fix broken Auth0 quickstart link in README (#548)
* docs: fix broken Auth0 quickstart link

* docs: spell error fixes in readme

* docs: fix typo in NETBIRD_MGMT_API_ENDPOINT placeholder in readme
2026-02-09 11:41:09 +08:00
Eduard Gert
750f660bcc Update NextJS to 16.1.6 (#547)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update NextJS to 16.1.6

* Update Node in workflow

* Fix rabbit comments

* Fix types

* Add engines field
2026-02-02 15:34:23 +01:00
790 changed files with 87551 additions and 19858 deletions

View File

@@ -0,0 +1,85 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# GitNexus CLI Commands
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
## Commands
### analyze — Build or refresh the index
```bash
node .gitnexus/run.cjs analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
### status — Check index freshness
```bash
node .gitnexus/run.cjs status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
### clean — Delete the index
```bash
node .gitnexus/run.cjs clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
| Flag | Effect |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
### wiki — Generate documentation from the graph
```bash
node .gitnexus/run.cjs wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
```bash
node .gitnexus/run.cjs list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
## After Indexing
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding

View File

@@ -0,0 +1,89 @@
---
name: gitnexus-debugging
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
---
# Debugging with GitNexus
## When to Use
- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
- "This endpoint returns 500"
- Investigating bugs, errors, or unexpected behavior
## Workflow
```
1. query({query: "<error or symptom>"}) → Find related execution flows
2. context({name: "<suspect>"}) → See callers/callees/processes
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
4. cypher({query: "MATCH path..."}) → Custom traces if needed
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] Understand the symptom (error message, unexpected behavior)
- [ ] query for error text or related code
- [ ] Identify the suspect function from returned processes
- [ ] context to see callers and callees
- [ ] Trace execution flow via process resource if applicable
- [ ] cypher for custom call chain traces if needed
- [ ] Read source files to confirm root cause
```
## Debugging Patterns
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
| Recent regression | `detect_changes` to see what your changes affect |
## Tools
**query** — find code related to error:
```
query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError, PaymentException
```
**context** — full context for a suspect:
```
context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates (external API!)
→ Processes: CheckoutFlow (step 3/7)
```
**cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
```
## Example: "Payment endpoint returns 500 intermittently"
```
1. query({query: "payment error handling"})
→ Processes: CheckoutFlow, ErrorHandling
→ Symbols: validatePayment, handlePaymentError
2. context({name: "validatePayment"})
→ Outgoing calls: verifyCard, fetchRates (external API!)
3. READ gitnexus://repo/my-app/process/CheckoutFlow
→ Step 3: validatePayment → calls fetchRates (external)
4. Root cause: fetchRates calls external API without proper timeout
```

View File

@@ -0,0 +1,78 @@
---
name: gitnexus-exploring
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
---
# Exploring Codebases with GitNexus
## When to Use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- "Where is the database logic?"
- Understanding code you haven't seen before
## Workflow
```
1. READ gitnexus://repos → Discover indexed repos
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
3. query({query: "<what you want to understand>"}) → Find related execution flows
4. context({name: "<symbol>"}) → Deep dive on specific symbol
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
```
> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] READ gitnexus://repo/{name}/context
- [ ] query for the concept you want to understand
- [ ] Review returned processes (execution flows)
- [ ] context on key symbols for callers/callees
- [ ] READ process resource for full execution traces
- [ ] Read source files for implementation details
```
## Resources
| Resource | What you get |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
## Tools
**query** — find execution flows related to a concept:
```
query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Symbols grouped by flow with file locations
```
**context** — 360-degree view of a symbol:
```
context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
→ Outgoing calls: checkToken, getUserById
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
```
## Example: "How does payment processing work?"
```
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
2. query({query: "payment processing"})
→ CheckoutFlow: processPayment → validateCard → chargeStripe
→ RefundFlow: initiateRefund → calculateRefund → processRefund
3. context({name: "processPayment"})
→ Incoming: checkoutHandler, webhookHandler
→ Outgoing: validateCard, chargeStripe, saveTransaction
4. Read src/payments/processor.ts for implementation details
```

View File

@@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# GitNexus Guide
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
## Always Start Here
For any task involving code understanding, debugging, impact analysis, or refactoring:
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first.
## Skills
| Task | Skill to read |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
| Rename / extract / split / refactor | `gitnexus-refactoring` |
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
## Tools Reference
| Tool | What it gives you |
| ---------------- | ------------------------------------------------------------------------ |
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
| Resource | Content |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```

View File

@@ -0,0 +1,97 @@
---
name: gitnexus-impact-analysis
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
---
# Impact Analysis with GitNexus
## When to Use
- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
- "Who uses this code?"
- Before making non-trivial code changes
- Before committing — to understand what your changes affect
## Workflow
```
1. impact({target: "X", direction: "upstream"}) → What depends on this
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
3. detect_changes() → Map current git changes to affected flows
4. Assess risk and report to user
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklist
```
- [ ] impact({target, direction: "upstream"}) to find dependents
- [ ] Review d=1 items first (these WILL BREAK)
- [ ] Check high-confidence (>0.8) dependencies
- [ ] READ processes to check affected execution flows
- [ ] detect_changes() for pre-commit check
- [ ] Assess risk level and report to user
```
## Understanding Output
| Depth | Risk Level | Meaning |
| ----- | ---------------- | ------------------------ |
| d=1 | **WILL BREAK** | Direct callers/importers |
| d=2 | LIKELY AFFECTED | Indirect dependencies |
| d=3 | MAY NEED TESTING | Transitive effects |
## Risk Assessment
| Affected | Risk |
| ------------------------------ | -------- |
| <5 symbols, few processes | LOW |
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
## Tools
**impact** — the primary tool for symbol blast radius:
```
impact({
target: "validateUser",
direction: "upstream",
minConfidence: 0.8,
maxDepth: 3
})
→ d=1 (WILL BREAK):
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
```
**detect_changes** — git-diff based impact analysis:
```
detect_changes({scope: "staged"})
→ Changed: 5 symbols in 3 files
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
→ Risk: MEDIUM
```
## Example: "What breaks if I change validateUser?"
```
1. impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
2. READ gitnexus://repo/my-app/processes
→ LoginFlow and TokenRefresh touch validateUser
3. Risk: 2 direct callers, 2 processes = MEDIUM
```

View File

@@ -0,0 +1,121 @@
---
name: gitnexus-refactoring
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
---
# Refactoring with GitNexus
## When to Use
- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
- "Move this to a new file"
- Any task involving renaming, extracting, splitting, or restructuring code
## Workflow
```
1. impact({target: "X", direction: "upstream"}) → Map all dependents
2. query({query: "X"}) → Find execution flows involving X
3. context({name: "X"}) → See all incoming/outgoing refs
4. Plan update order: interfaces → implementations → callers → tests
```
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
## Checklists
### Rename Symbol
```
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
- [ ] detect_changes() — verify only expected files changed
- [ ] Run tests for affected processes
```
### Extract Module
```
- [ ] context({name: target}) — see all incoming/outgoing refs
- [ ] impact({target, direction: "upstream"}) — find all external callers
- [ ] Define new module interface
- [ ] Extract code, update imports
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
### Split Function/Service
```
- [ ] context({name: target}) — understand all callees
- [ ] Group callees by responsibility
- [ ] impact({target, direction: "upstream"}) — map callers to update
- [ ] Create new functions/services
- [ ] Update callers
- [ ] detect_changes() — verify affected scope
- [ ] Run tests for affected processes
```
## Tools
**rename** — automated multi-file rename:
```
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
→ 10 graph edits (high confidence), 2 ast_search edits (review)
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
```
**impact** — map all dependents first:
```
impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
→ Affected Processes: LoginFlow, TokenRefresh
```
**detect_changes** — verify your changes after refactoring:
```
detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
→ Affected processes: LoginFlow, TokenRefresh
→ Risk: MEDIUM
```
**cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
```
## Risk Rules
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
```
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits: 10 graph (safe), 2 ast_search (review)
→ Files: validator.ts, login.ts, middleware.ts, config.json...
2. Review ast_search edits (config.json: dynamic reference!)
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
→ Applied 12 edits across 8 files
4. detect_changes({scope: "all"})
→ Affected: LoginFlow, TokenRefresh
→ Risk: MEDIUM — run tests for these flows
```

19
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,19 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
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

View File

@@ -7,19 +7,26 @@ 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: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
@@ -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

105
.github/workflows/docs-ack.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied ✅"

162
.github/workflows/e2e-test.yml vendored Normal file
View File

@@ -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: <tag>` or `reverse-proxy-tag: <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

9
.gitignore vendored
View File

@@ -36,7 +36,14 @@ yarn-error.log*
next-env.d.ts
# config
.local-config.json
.local*config*.json
.test-config.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

View File

@@ -0,0 +1,10 @@
{
"sessionID": "ses_10af25468ffezWZn49GbDsWmkw",
"updatedAt": "2026-06-23T15:17:44.982Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-23T15:17:44.982Z"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"sessionID": "ses_10af30226ffeXZKbIMUr35P91Y",
"updatedAt": "2026-06-23T17:30:43.458Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-23T17:30:43.458Z"
}
}
}

377
AGENTS.md Normal file
View File

@@ -0,0 +1,377 @@
# 仓库指南
## 项目概述
NetBird Dashboard 是 NetBird 管理服务的 Web 界面。这是一个 Next.js 应用程序,为 NetBird 网络提供网络管理、对等节点监控、访问控制和配置功能。
**在线版本:** https://app.netbird.io/
**源代码:** https://github.com/netbirdio/dashboard
## 架构与数据流
### 技术栈
- **框架:** Next.js 13+ 使用 App Router
- **语言:** TypeScript
- **样式:** Tailwind CSS + shadcn/ui 组件
- **状态管理:** React Context + SWR 用于服务器状态
- **认证:** OIDC 通过 @axa-fr/react-oidc
- **国际化:** next-intl
- **测试:** Cypress (E2E)
- **部署:** Docker + Nginx
### 高级结构
```
src/
├── app/ # Next.js App Router 页面
│ ├── (dashboard)/ # 主仪表板路由(分组布局)
│ ├── (remote-access)/ # 远程访问路由
│ ├── install/ # 安装向导
│ ├── invite/ # 用户邀请流程
│ └── setup/ # 初始设置流程
├── assets/ # 静态资源(图标、图片、字体)
├── auth/ # OIDC 认证组件
├── components/ # 共享 UI 组件(基于 shadcn/ui
├── contexts/ # React Context 提供者
├── hooks/ # 自定义 React 钩子
├── i18n/ # 国际化配置和消息
├── interfaces/ # TypeScript 类型定义
├── layouts/ # 布局组件
├── modules/ # 功能模块(领域特定)
└── utils/ # 工具函数
```
### 数据流
1. **认证:** OIDC 提供者处理认证 → 令牌存储在内存中
2. **API 调用:** `useFetchApi` 钩子 → SWR → OIDC 请求 → 管理 API
3. **状态:** 服务器状态通过 SWR 缓存UI 状态通过 React Context
4. **渲染:** 默认使用服务器组件,需要时使用客户端组件
## 关键目录
### `src/app/` - 页面和路由
- 使用 Next.js App Router 和路由分组
- `(dashboard)/` 包含主要应用页面和共享布局
- 每个路由有 `page.tsx` 和可选的 `layout.tsx`
- 通过 `error/page.tsx` 实现错误边界
### `src/modules/` - 功能模块
按功能组织的领域特定组件:
- `peers/` - 对等节点管理组件
- `networks/` - 网络配置
- `access-control/` - ACL 策略
- `dns/` - DNS 管理
- `routes/` - 网络路由
- `users/` - 用户管理
- `groups/` - 分组管理
- `setup-keys/` - 设置密钥管理
- `activity/` - 活动日志
- `settings/` - 账户设置
### `src/components/` - 共享 UI 组件
基于 shadcn/ui 构建,具有自定义变体:
- `Input.tsx` - 带验证的表单输入
- `Select.tsx` - 下拉选择
- `Dialog.tsx` - 模态对话框
- `Table.tsx` - 数据表格
- `Button.tsx` - 操作按钮
- `Badge.tsx` - 状态徽章
- `Tooltip.tsx` - 信息提示
### `src/contexts/` - 状态提供者
全局状态的 React Context 提供者:
- `ApplicationProvider.tsx` - 应用级配置
- `PeersProvider.tsx` - 对等节点数据
- `GroupsProvider.tsx` - 分组数据
- `RoutesProvider.tsx` - 路由数据
- `PoliciesProvider.tsx` - ACL 策略
- `PermissionsProvider.tsx` - 用户权限
- `GlobalThemeProvider.tsx` - 主题管理
- `LocaleProvider.tsx` - 语言/区域设置
### `src/hooks/` - 自定义钩子
可复用的 React 钩子:
- `useLocalStorage.tsx` - 持久化本地存储
- `useDebounce.tsx` - 防抖值
- `useSearch.ts` - 搜索功能
- `useCopyToClipboard.ts` - 剪贴板操作
- `useElementSize.ts` - DOM 元素尺寸
- `useIntersectionObserver.ts` - 可见性检测
### `src/interfaces/` - 类型定义
领域模型的 TypeScript 接口:
- `Peer.ts` - 网络对等节点
- `Group.ts` - 对等节点分组
- `Route.ts` - 网络路由
- `Nameserver.ts` - DNS 名称服务器
- `Account.ts` - 用户账户
- `SetupKey.ts` - 设置密钥
- `AccessToken.ts` - API 访问令牌
### `src/utils/` - 工具函数
辅助函数:
- `api.tsx` - 集成 SWR 的 API 客户端
- `helpers.ts` - 通用工具cn, randomString 等)
- `config.ts` - 配置加载器
- `ip.ts` - IP 地址工具
- `wireguard.ts` - WireGuard 辅助函数
- `version.ts` - 版本比较
## 开发命令
```bash
# 安装依赖
npm install
# 启动开发服务器(端口 3000
npm run dev
# 使用 Turbopack 启动(更快)
npm run turbo
# 构建生产版本
npm run build
# 启动生产服务器
npm start
# 运行代码检查
npm run lint
# 打开 Cypress 测试运行器
npm run cypress:open
# 复制 OIDC 服务工作者(认证必需)
npm run copy
npm run copytrusted
```
## 代码规范和常见模式
### 组件模式
```tsx
// 使用 shadcn/ui 和 class-variance-authority 实现变体
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@utils/helpers";
const buttonVariants = cva("base-classes", {
variants: {
variant: {
default: "default-classes",
destructive: "destructive-classes",
},
},
});
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
);
}
```
### Context 提供者模式
```tsx
import React, { useMemo } from "react";
import useFetchApi from "@utils/api";
const DataContext = React.createContext({} as DataType);
export default function DataProvider({ children }: { children: React.ReactNode }) {
const { data, isLoading } = useFetchApi<Data[]>("/endpoint");
const value = useMemo(() => ({ data, isLoading }), [data, isLoading]);
return (
<DataContext.Provider value={value}>
{children}
</DataContext.Provider>
);
}
export const useData = () => React.useContext(DataContext);
```
### API 钩子模式
```tsx
import useFetchApi from "@utils/api";
// GET 请求使用 SWR
const { data, isLoading, error } = useFetchApi<Data[]>("/endpoint");
// POST/PUT/DELETE 请求
const { mutate } = useFetchApi("/endpoint", { method: "POST" });
```
### 样式模式
```tsx
import { cn } from "@utils/helpers";
// 合并 Tailwind 类
<div className={cn(
"base-classes",
condition && "conditional-classes",
className
)} />
```
### 导入顺序
`simple-import-sort` ESLint 插件强制执行:
1. 副作用导入 (`import "polyfill"`)
2. 外部包 (`import React from "react"`)
3. 内部别名 (`import { Button } from "@/components"`)
4. 相对导入 (`import { useData } from "./context"`)
### 文件命名
- **组件:** PascalCase (`PeerTable.tsx`, `GroupSelector.tsx`)
- **钩子:** camelCase 带 `use` 前缀 (`useLocalStorage.tsx`)
- **工具函数:** camelCase (`helpers.ts`, `api.tsx`)
- **接口:** PascalCase (`Peer.ts`, `Group.ts`)
- **页面:** `page.tsx`Next.js App Router 要求)
- **布局:** `layout.tsx`Next.js App Router 要求)
## 重要文件
### 入口点
- `src/app/layout.tsx` - 根布局(提供者、字体、元数据)
- `src/app/(dashboard)/layout.tsx` - 仪表板布局(导航、认证)
- `src/app/page.tsx` - 首页重定向
### 配置文件
- `next.config.js` - Next.js 配置
- `tailwind.config.ts` - Tailwind CSS 配置
- `components.json` - shadcn/ui 配置
- `config.json` - 应用配置API 端点、认证)
- `.eslintrc.json` - ESLint 规则
- `tsconfig.json` - TypeScript 配置
### 关键工具
- `src/utils/api.tsx` - API 客户端SWR + OIDC
- `src/utils/config.ts` - 配置加载器
- `src/utils/helpers.ts` - 共享工具
- `src/auth/OIDCProvider.tsx` - 认证提供者
## 运行时/工具偏好
### 必需环境
- Node.js 18+(推荐 LTS
- npm包管理器
### 本地开发设置
1. 克隆仓库
2. 创建 `.local-config.json` 覆盖 `config.json` 中的值
3. 运行 `npm install`
4. 运行 `npm run copy`(复制 OIDC 服务工作者)
5. 运行 `npm run dev`
### Docker 部署
```bash
docker run -d --name netbird-dashboard \
-p 80:80 \
-e AUTH0_DOMAIN=<domain> \
-e AUTH0_CLIENT_ID=<client-id> \
-e AUTH0_AUDIENCE=<audience> \
-e NETBIRD_MGMT_API_ENDPOINT=<api-url> \
netbirdio/dashboard:main
```
### 配置
- `config.json` - 默认配置
- `.local-config.json` - 本地覆盖(已忽略)
- Docker 部署的环境变量
## 测试与质量保证
### E2E 测试Cypress
```bash
# 打开 Cypress UI
npm run cypress:open
# 无头运行测试
npx cypress run
```
**测试位置:** `cypress/e2e/`
**支持文件:** `cypress/support/`
**测试数据:** `cypress/fixtures/`
### 代码检查
```bash
npm run lint
```
ESLint 配置:
- `next/core-web-vitals` - Next.js 最佳实践
- `prettier` - 代码格式化
- `simple-import-sort` - 导入排序
### 类型检查
TypeScript 严格模式已启用。运行 `npx tsc --noEmit` 检查类型。
## 模块上下文
参见 `docs/contexts/` 获取特定模块的详细文档:
- `peers.md` - 对等节点管理模块
- `networks.md` - 网络配置模块
- `access-control.md` - ACL 策略模块
- `dns.md` - DNS 管理模块
- `api-client.md` - API 客户端模式
- `authentication.md` - OIDC 认证流程
## 常见问题与注意事项
### OIDC 服务工作者
安装后必须运行 `npm run copy` 将 OIDC 服务工作者复制到 `public/`
### 本地配置
创建 `.local-config.json` 覆盖 `config.json` 中的本地开发值。
### 静态导出
应用在 Next.js 配置中使用 `output: "export"` - 运行时无服务器端渲染。
### 暗黑模式
主题通过 `GlobalThemeProvider` 管理。使用 Tailwind 暗黑模式类。
### API 端点
所有 API 调用通过 `src/utils/api.tsx`,它处理:
- OIDC 令牌注入
- 令牌刷新
- 错误处理
- SWR 缓存
## 快速参考
### 添加新页面
1. 创建 `src/app/(dashboard)/new-page/page.tsx`
2.`src/layouts/Navigation.tsx` 中添加导航
3. 如需要,在 `src/contexts/` 中创建上下文提供者
4.`src/interfaces/` 中添加类型
### 添加新组件
1.`src/components/`(共享)或 `src/modules/<feature>/`(功能特定)中创建
2. 遵循 shadcn/ui 模式并实现变体
3. 从组件文件导出
### 添加新 API 端点
1. 在组件或上下文中使用 `useFetchApi` 钩子
2.`src/interfaces/` 中添加 TypeScript 接口
3. 如果数据在组件间共享,创建上下文提供者
### 添加新模块
1.`src/modules/<feature>/` 中创建目录
2. 为该功能添加组件
3.`src/contexts/` 中创建上下文提供者
4.`src/app/(dashboard)/<feature>/` 中添加页面
5. 更新导航
---
*本文档由 AI 自动生成。随着代码库的发展而更新。*

View File

@@ -1,3 +1,3 @@
Mikhail Bragin (https://github.com/braginini)
Maycon Santos (https://github.com/mlsmaycon)
Wiretrustee UG (haftungsbeschränkt)
NetBird GmbH

43
CLAUDE.md Normal file
View File

@@ -0,0 +1,43 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **dashboard** (4993 symbols, 15422 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
## Never Do
- NEVER edit a function, class, or method without first running `impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit changes without running `detect_changes()` to check affected scope.
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/dashboard/context` | Codebase overview, check index freshness |
| `gitnexus://repo/dashboard/clusters` | All functional areas |
| `gitnexus://repo/dashboard/processes` | All execution flows |
| `gitnexus://repo/dashboard/process/{name}` | Step-by-step execution trace |
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

View File

@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
The dashboard makes it possible to:
- track the status of your peers
- remove peers
- manage Setup Keys (to authenticate new peers)
@@ -17,10 +18,10 @@ The dashboard makes it possible to:
- define access controls
## Some Screenshots
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
## Technologies Used
- NextJS
@@ -33,8 +34,9 @@ The dashboard makes it possible to:
- Let's Encrypt
## How to run
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
1. Install [Docker](https://docs.docker.com/get-docker/)
@@ -43,9 +45,9 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins"
4. NetBird UI Dashboard uses NetBirds Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
5. Run docker container without SSL (Let's Encrypt):
```shell
@@ -54,9 +56,10 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
6. Run docker container with SSL (Let's Encrypt):
```shell
@@ -68,7 +71,7 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
@@ -84,11 +87,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
You can start editing by modifying the code inside `src/..`
The page auto-updates as you edit the file.
## How to migrate from old dashboard (v1)
## How to migrate from old dashboard (v1)
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
1. Stop the dashboard container `docker compose down dashboard`
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`

View File

@@ -1,9 +1,9 @@
[
{
"tag": "New",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
"closeable": true,

103
batch-i18n.py Normal file
View File

@@ -0,0 +1,103 @@
"""Batch i18n localization for remaining simple files"""
import re, os
os.chdir('/root/github_projects/dashboard')
# Helper: replace first occurrence after a marker
def replace_in_file(filepath, replacements):
with open(filepath) as f:
content = f.read()
changed = False
for old, new in replacements:
if old in content:
content = content.replace(old, new, 1)
changed = True
print(f" {os.path.basename(filepath)}: replaced '{old[:40]}'")
else:
print(f" WARN: '{old[:40]}' not in {os.path.basename(filepath)}")
if changed:
with open(filepath, 'w') as f:
f.write(content)
# === 1. AddRouteDropdownButton.tsx ===
replace_in_file('src/modules/peer/AddRouteDropdownButton.tsx', [
('import Button from "@components/Button";',
'import { useTranslations } from "next-intl";\nimport Button from "@components/Button";'),
('export default function AddRouteDropdownButton() {',
'export default function AddRouteDropdownButton() {\n const t = useTranslations("common");'),
('New Network Route', '{t("newNetworkRoute")}'),
('Existing Network', '{t("existingNetwork")}'),
])
# === 2. RemoteJobDropdownButton.tsx ===
replace_in_file('src/modules/peer/RemoteJobDropdownButton.tsx', [
('import Button from "@components/Button";',
'import { useTranslations } from "next-intl";\nimport Button from "@components/Button";'),
('export const RemoteJobDropdownButton = () => {',
'export const RemoteJobDropdownButton = () => {\n const t = useTranslations("common");'),
('Debug Bundle', '{t("debugBundle")}'),
])
# === 3. RouteMetricCell.tsx ===
replace_in_file('src/modules/routes/RouteMetricCell.tsx', [
('import FullTooltip from "@components/FullTooltip";',
'import { useTranslations } from "next-intl";\nimport FullTooltip from "@components/FullTooltip";'),
('export default function RouteMetricCell({',
'export default function RouteMetricCell({\n const t = useTranslations("common");'),
('Lower metrics have higher priority.', '{t("metricPriority")}'),
])
# === 4. PeerRoutesTable.tsx ===
replace_in_file('src/modules/peer/PeerRoutesTable.tsx', [
('import Card from "@components/Card";',
'import { useTranslations } from "next-intl";\nimport Card from "@components/Card";'),
('export const RouteTableColumns: ColumnDef<Route>[] = [',
'function RouteTableColumns(t: ReturnType<typeof useTranslations>): ColumnDef<Route>[] {\n return ['),
('];\n\nexport default function PeerRoutesTable({',
'];\n}\n\nexport default function PeerRoutesTable({'),
('export default function PeerRoutesTable({',
'export default function PeerRoutesTable({\n const t = useTranslations("common");'),
('];\n\nfunction RouteTableColumns', '];\n}\n\nfunction RouteTableColumns'), # fix double close
])
# Replace column headers in PeerRoutesTable
with open('src/modules/peer/PeerRoutesTable.tsx') as f:
c = f.read()
c = c.replace('DataTableHeader column={column}>Name<', 'DataTableHeader column={column}>{t("name")}<')
c = c.replace('DataTableHeader column={column}>Network<', 'DataTableHeader column={column}>{t("network")}<')
c = c.replace('DataTableHeader column={column}>Distribution Groups<', 'DataTableHeader column={column}>{t("distributionGroups")}<')
c = c.replace('DataTableHeader column={column}>Active<', 'DataTableHeader column={column}>{t("active")}<')
with open('src/modules/peer/PeerRoutesTable.tsx', 'w') as f:
f.write(c)
print(" PeerRoutesTable: column headers replaced")
# === 5. Add keys to en.ts ===
with open('src/i18n/messages/en.ts') as f:
en = f.read()
# Add to common namespace (after debugBundle or similar)
if 'debugBundle' not in en:
en = en.replace(
'routingPeer: "Routing Peer",',
'routingPeer: "Routing Peer",\n newNetworkRoute: "New Network Route",\n existingNetwork: "Existing Network",\n debugBundle: "Debug Bundle",\n metricPriority: "Lower metrics have higher priority.",'
)
with open('src/i18n/messages/en.ts', 'w') as f:
f.write(en)
print(" en.ts: keys added")
# === 6. Add keys to zh.ts ===
with open('src/i18n/messages/zh.ts') as f:
zh = f.read()
if 'debugBundle' not in zh:
zh = zh.replace(
'routingPeer: "路由节点",',
'routingPeer: "路由节点",\n newNetworkRoute: "新网络路由",\n existingNetwork: "现有网络",\n debugBundle: "调试包",\n metricPriority: "较低的度量值具有更高的优先级。"'
)
with open('src/i18n/messages/zh.ts', 'w') as f:
f.write(zh)
print(" zh.ts: Chinese translations added")
print("\nDone!")

View File

@@ -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"
}

View File

@@ -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,
});

View File

@@ -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();
});
});

View File

@@ -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"
}

View File

@@ -1,37 +0,0 @@
/// <reference types="cypress" />
// ***********************************************
// 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<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

View File

@@ -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')

View File

@@ -1,9 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es5", "dom"],
"baseUrl": "http://localhost:3000",
"types": ["cypress", "node"],
},
"include": ["**/*.ts"]
}

View File

@@ -1,24 +1,13 @@
FROM alpine:3.14
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
rm -rf /var/cache/apk/* && mkdir -p /run/nginx
STOPSIGNAL SIGINT
EXPOSE 80
EXPOSE 443
ENTRYPOINT ["/usr/bin/supervisord","-c","/etc/supervisord.conf"]
FROM node:22-alpine
WORKDIR /usr/share/nginx/html
# copy configuration files
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/init_cert.sh /usr/local/init_cert.sh
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
RUN chmod +x /usr/local/init_cert.sh && rm /etc/crontabs/root
RUN chmod +x /usr/local/init_react_envs.sh
# configure supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
# copy build files
COPY out/ /usr/share/nginx/html/
# Copy build files
COPY out/ /usr/share/nginx/html/
# Copy server script
COPY docker/server.js /server.js
EXPOSE 80
CMD ["node", "/server.js"]

View File

@@ -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;
}
}

View File

@@ -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"

138
docker/server.js Normal file
View File

@@ -0,0 +1,138 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const root = path.resolve('/usr/share/nginx/html');
const MIME = {
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
'.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2',
'.txt': 'text/plain', '.xml': 'text/xml'
};
// Replace both placeholder styles used by generated assets and templates.
const ENV_KEYS = [
'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',
'AUTH0_DOMAIN',
'AUTH0_CLIENT_ID',
'AUTH0_AUDIENCE',
];
function substituteEnv(content) {
let changed = false;
for (const key of ENV_KEYS) {
const val = process.env[key] || '';
for (const pattern of ['$$' + key, '$' + key]) {
if (content.includes(pattern)) {
content = content.split(pattern).join(val);
changed = true;
}
}
}
return { content, changed };
}
function walkDir(dir) {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkDir(full);
else if (entry.isFile() && /\.(js|html|txt|json)$/.test(entry.name)) {
const result = substituteEnv(fs.readFileSync(full, 'utf8'));
if (result.changed) fs.writeFileSync(full, result.content, 'utf8');
}
}
} catch (e) {
console.warn('Failed to substitute environment variables:', e);
}
}
console.log('Substituting environment variables...');
try {
const tmpl = path.join(root, 'OidcTrustedDomains.js.tmpl');
if (fs.existsSync(tmpl)) {
const result = substituteEnv(fs.readFileSync(tmpl, 'utf8'));
fs.writeFileSync(path.join(root, 'OidcTrustedDomains.js'), result.content, 'utf8');
}
} catch (e) {
console.warn('Failed to create OidcTrustedDomains.js:', e);
}
walkDir(root);
console.log('Environment substitution complete.');
function isFile(p) {
try { return fs.statSync(p).isFile(); } catch(e) { return false; }
}
function safePath(p) {
const abs = path.resolve(root, '.' + p);
return abs === root || abs.startsWith(root + path.sep) ? abs : null;
}
function resolvePath(url) {
let p = url.split('?')[0];
if (!p.startsWith('/')) p = '/' + p;
if (p === '/' || p.endsWith('/')) p += 'index.html';
const abs = safePath(p);
if (abs && isFile(abs)) return abs;
// Try .html suffix (Next.js static export uses path.html)
const asHtml = safePath(p + '.html');
if (asHtml && isFile(asHtml)) return asHtml;
// Try path/index.html
const asDirIndex = safePath(p + '/index.html');
if (asDirIndex && isFile(asDirIndex)) return asDirIndex;
// Single-locale build: the static export places pages under /zh/.
// Fall back to /zh so that paths like /networks still resolve when the
// user navigates directly to a non-prefixed URL.
if (!p.startsWith('/zh')) {
const zh = safePath('/zh' + p);
if (zh && isFile(zh)) return zh;
const zhHtml = safePath('/zh' + p + '.html');
if (zhHtml && isFile(zhHtml)) return zhHtml;
const zhDirIndex = safePath('/zh' + p + '/index.html');
if (zhDirIndex && isFile(zhDirIndex)) return zhDirIndex;
}
return null;
}
http.createServer((req, res) => {
const filePath = resolvePath(req.url);
if (filePath) {
const ext = path.extname(filePath);
fs.readFile(filePath, (err, data) => {
if (err) { send404(res); return; }
res.writeHead(200, {
'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': ['.html', '.js'].includes(ext) ? 'no-store, no-cache, must-revalidate, max-age=0' : 'public, max-age=3600'
});
res.end(data);
});
} else {
send404(res);
}
}).listen(80, () => console.log('NetBird Dashboard running on port 80'));
function send404(res) {
fs.readFile(path.join(root, '404.html'), (err, data) => {
res.writeHead(404, {'Content-Type': 'text/html', 'Cache-Control': 'no-store'});
res.end(err ? '404 Not Found' : data);
});
}

View File

@@ -0,0 +1,59 @@
# 访问控制模块
## 功能
管理访问控制策略ACL- 定义哪些对等节点可以通信。
## API 接口
- `GET /api/policies` - 列出策略
- `GET /api/policies/:id` - 获取策略详情
- `POST /api/policies` - 创建策略
- `PUT /api/policies/:id` - 更新策略
- `DELETE /api/policies/:id` - 删除策略
## 关键类型
```typescript
interface Policy {
id: string;
name: string;
description: string;
enabled: boolean;
rules: PolicyRule[];
// ... 更多字段
}
interface PolicyRule {
name: string;
sources: Group[];
destinations: Group[];
// ... 更多字段
}
```
## 文件路径
- `src/modules/access-control/` - UI 组件
- `src/contexts/PoliciesProvider.tsx` - 数据提供者
- `src/app/(dashboard)/access-control/page.tsx` - 页面组件
## 组件
- 策略列表表格
- 策略编辑器
- 规则配置
## 使用方法
```tsx
import { usePolicies } from "@/contexts/PoliciesProvider";
function MyComponent() {
const { policies, isLoading } = usePolicies();
// ...
}
```
## 命令
- 页面:`/access-control`
## 注意事项
- 策略按顺序评估
- 禁用的策略会被跳过
- 规则引用分组,而不是单个对等节点
- 可以向策略添加姿态检查

View File

@@ -0,0 +1,97 @@
# API Client
## Purpose
Centralized API client with SWR integration and OIDC authentication.
## File Path
- `src/utils/api.tsx`
## Key Exports
```typescript
// Main hook for API calls
export default function useFetchApi<T>(
url: string,
options?: RequestOptions
): SWRResponse<T, ErrorResponse>;
// Request options
type RequestOptions = {
key?: string;
signal?: AbortSignal;
origin?: string;
globalParams?: Params;
ignoreGlobalParams?: boolean;
refreshInterval?: number;
blob?: boolean;
shouldRetryOnError?: boolean;
};
// Error response type
export type ErrorResponse = {
code: number;
message: string;
};
// Query params type
export type Params = Record<string, string | number | boolean>;
```
## Usage Patterns
### GET Request
```tsx
const { data, isLoading, error } = useFetchApi<Peer[]>("/peers");
```
### GET with Refresh
```tsx
const { data } = useFetchApi<Peer[]>("/peers", {
refreshInterval: 5000, // Poll every 5 seconds
});
```
### POST/PUT/DELETE
```tsx
const { mutate } = useFetchApi("/peers", { method: "POST" });
// Or use direct apiRequest function
```
### Custom Key
```tsx
const { data } = useFetchApi("/peers", {
key: "my-custom-key",
});
```
## Implementation Details
### Authentication
- Uses OIDC tokens from `@axa-fr/react-oidc`
- Automatically injects Authorization header
- Handles token refresh
### Caching
- Uses SWR for caching and revalidation
- Global config in `ApplicationProvider`
- Supports refresh intervals
### Error Handling
- Returns structured `ErrorResponse`
- Integrates with `ErrorBoundary`
- Configurable retry behavior
### Configuration
- Base URL from `config.json` (`apiOrigin`)
- Can override with `origin` option
- Global params merged from `ApplicationContext`
## Dependencies
- `swr` - Data fetching and caching
- `@axa-fr/react-oidc` - OIDC authentication
- `react-jwt` - JWT token handling
## Gotchas
- Requires OIDC provider to be initialized
- Tokens stored in memory (not localStorage)
- Global params applied to all requests unless `ignoreGlobalParams: true`
- Use `shouldRetryOnError: false` to disable automatic retries

View File

@@ -0,0 +1,93 @@
# Authentication
## Purpose
OIDC-based authentication using Auth0 or compatible providers.
## File Paths
- `src/auth/OIDCProvider.tsx` - Main OIDC provider
- `src/auth/SecureProvider.tsx` - Protected route wrapper
- `src/auth/OIDCError.tsx` - Error display
- `src/auth/SessionLost.tsx` - Session lost handling
## Key Dependencies
- `@axa-fr/react-oidc` - OIDC client library
## Configuration
Configuration in `config.json`:
```json
{
"auth0Domain": "your-tenant.auth0.com",
"auth0ClientId": "your-client-id",
"auth0Audience": "your-audience",
"auth0Authority": "https://your-tenant.auth0.com",
"auth0RedirectUri": "http://localhost:3000",
"authScope": "openid profile email"
}
```
## Environment Variables (Docker)
- `AUTH0_DOMAIN` - Auth0 tenant domain
- `AUTH0_CLIENT_ID` - Auth0 application client ID
- `AUTH0_AUDIENCE` - API audience identifier
## Usage
### Protected Routes
```tsx
import SecureProvider from "@/auth/SecureProvider";
export default function Layout({ children }) {
return <SecureProvider>{children}</SecureProvider>;
}
```
### Access User Info
```tsx
import { useOidc, useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
function MyComponent() {
const { isAuthenticated, login, logout } = useOidc();
const { oidcAccessToken } = useOidcAccessToken();
const { oidcIdToken } = useOidcIdToken();
// ...
}
```
## Flow
1. User visits protected route
2. `SecureProvider` checks authentication
3. If not authenticated, redirects to Auth0 login
4. After login, Auth0 redirects back with tokens
5. Tokens stored in memory (not localStorage)
6. API calls use access token for authorization
## Service Worker
OIDC service worker must be copied to `public/`:
```bash
npm run copy
npm run copytrusted
```
This enables:
- Token refresh without page reload
- Secure token storage
- Silent authentication
## Local Development
Create `.local-config.json`:
```json
{
"auth0Domain": "localhost",
"auth0ClientId": "test-client-id",
"auth0Audience": "test-audience",
"auth0Authority": "http://localhost:9999",
"auth0RedirectUri": "http://localhost:3000"
}
```
## Gotchas
- Service worker file must be in `public/` directory
- Tokens are in-memory only (cleared on page refresh)
- CORS must be configured on Auth0 for local development
- Redirect URI must match Auth0 configuration exactly
- Silent authentication requires HTTPS in production

46
docs/contexts/dns.md Normal file
View File

@@ -0,0 +1,46 @@
# DNS 模块
## 功能
管理网络的 DNS 名称服务器和 DNS 设置。
## API 接口
- `GET /api/nameservers` - 列出名称服务器
- `GET /api/nameservers/:id` - 获取名称服务器详情
- `POST /api/nameservers` - 创建名称服务器
- `PUT /api/nameservers/:id` - 更新名称服务器
- `DELETE /api/nameservers/:id` - 删除名称服务器
## 关键类型
```typescript
interface Nameserver {
id: string;
name: string;
ip: string;
port: number;
groups: Group[];
// ... 更多字段
}
```
## 文件路径
- `src/modules/dns/` - UI 组件
- `src/interfaces/Nameserver.ts` - 类型定义
- `src/app/(dashboard)/dns/page.tsx` - 页面组件
## 组件
- 名称服务器列表表格
- 名称服务器编辑器
- 分组分配
## 使用方法
```tsx
// 通过自定义钩子或上下文访问
```
## 命令
- 页面:`/dns`
## 注意事项
- 名称服务器分配给分组
- 分组决定哪些对等节点使用哪些名称服务器
- 可以配置默认名称服务器

53
docs/contexts/networks.md Normal file
View File

@@ -0,0 +1,53 @@
# 网络模块
## 功能
配置和管理网络设置、路由和网络级策略。
## API 接口
- `GET /api/networks` - 列出网络
- `GET /api/networks/:id` - 获取网络详情
- `POST /api/networks` - 创建网络
- `PUT /api/networks/:id` - 更新网络
- `DELETE /api/networks/:id` - 删除网络
## 关键类型
```typescript
interface Network {
id: string;
name: string;
description: string;
// ... 更多字段
}
```
## 文件路径
- `src/modules/networks/` - UI 组件
- `src/contexts/RoutesProvider.tsx` - 路由数据
- `src/contexts/GroupRouteProvider.tsx` - 分组路由
- `src/interfaces/Network.ts` - 类型定义
- `src/interfaces/Route.ts` - 路由类型
- `src/app/(dashboard)/networks/page.tsx` - 网络页面
- `src/app/(dashboard)/network-routes/page.tsx` - 路由页面
## 组件
- 网络列表组件在 `src/modules/networks/`
- 路由管理在 `src/modules/routes/`
## 使用方法
```tsx
import { useRoutes } from "@/contexts/RoutesProvider";
function MyComponent() {
const { routes, isLoading } = useRoutes();
// ...
}
```
## 命令
- 网络:`/networks`
- 路由:`/network-routes`
## 注意事项
- 网络按账户范围划分
- 路由定义对等节点之间的流量流向
- 分组路由允许将路由分配给对等节点分组

61
docs/contexts/peers.md Normal file
View File

@@ -0,0 +1,61 @@
# 对等节点模块
## 功能
管理网络对等节点 - 查看、配置和监控已连接的设备。
## API 接口
- `GET /api/peers` - 列出所有对等节点
- `GET /api/peers/:id` - 获取对等节点详情
- `PUT /api/peers/:id` - 更新对等节点
- `DELETE /api/peers/:id` - 删除对等节点
## 关键类型
```typescript
interface Peer {
id: string;
name: string;
ip: string;
connected: boolean;
last_seen: string;
os: OperatingSystem;
version: string;
groups: Group[];
// ... 更多字段
}
```
## 文件路径
- `src/modules/peers/` - UI 组件
- `src/contexts/PeersProvider.tsx` - 数据提供者
- `src/interfaces/Peer.ts` - 类型定义
- `src/app/(dashboard)/peers/page.tsx` - 页面组件
- `src/app/(dashboard)/peer/[id]/page.tsx` - 详情页面
## 组件
- `PeersTable.tsx` - 主要对等节点列表表格
- `PeerNameCell.tsx` - 对等节点名称显示
- `PeerAddressCell.tsx` - IP 地址显示
- `PeerStatusCell.tsx` - 连接状态
- `PeerOSCell.tsx` - 操作系统图标
- `PeerGroupCell.tsx` - 分组成员资格
- `PeerActionCell.tsx` - 操作按钮
- `PeerMultiSelect.tsx` - 多对等节点选择器
## 使用方法
```tsx
import { usePeers } from "@/contexts/PeersProvider";
function MyComponent() {
const { peers, isLoading } = usePeers();
// ...
}
```
## 命令
- 页面:`/peers`
- 详情:`/peer/:id`
## 注意事项
- 对等节点列表可能很大 - 使用虚拟滚动
- 状态更新通过轮询实现SWR refreshInterval
- 操作系统图标位于 `src/assets/os-icons/`

233
e2e/CLAUDE.md Normal file
View File

@@ -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/<file>` — 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.

12
e2e/environment/.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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 <<PROXYEOF
NB_PROXY_TOKEN=$NB_PROXY_TOKEN
NB_PROXY_ALLOW_INSECURE=true
NB_PROXY_LOG_LEVEL=trace
PROXYEOF
# Secondary proxy (custom ports disabled)
NB_PROXY_TOKEN_NO_PORTS=$(create_proxy_token "test-proxy-no-ports")
cat > proxy-no-ports.env <<PROXYEOF
NB_PROXY_TOKEN=$NB_PROXY_TOKEN_NO_PORTS
NB_PROXY_ALLOW_INSECURE=true
PROXYEOF
}
init_zitadel() {
echo -e "\nInitializing Zitadel with NetBird's applications\n"
INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
TOKEN_PATH=./machinekey/zitadel-admin-sa.token
echo -n "Waiting for Zitadel's PAT to be created "
wait_pat "$TOKEN_PATH"
echo "Reading Zitadel PAT"
PAT=$(cat $TOKEN_PATH)
if [ "$PAT" = "null" ]; then
echo "Failed requesting getting Zitadel PAT"
exit 1
fi
echo -n "Waiting for Zitadel to become ready "
wait_api "$INSTANCE_URL" "$PAT"
# create the zitadel project
echo "Creating new zitadel project"
PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT")
ZITADEL_DEV_MODE=false
BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then
ZITADEL_DEV_MODE=true
fi
# create zitadel spa applications
echo "Creating new Zitadel SPA Dashboard application"
DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "http://localhost:1337/nb-auth" "http://localhost:1337/nb-silent-auth" "http://localhost:1337/" "true")
echo "Creating new Zitadel SPA Cli application"
CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true")
MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT")
SERVICE_USER_CLIENT_ID="null"
SERVICE_USER_CLIENT_SECRET="null"
create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID"
DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID")
ZITADEL_ADMIN_USERNAME="owner@localhost.test"
ZITADEL_ADMIN_PASSWORD="testMe123@"
HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD")
DATE="null"
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID")
# Create a second user for role-based testing (e.g., Billing Admin)
ZITADEL_SECOND_USERNAME="user@localhost.test"
ZITADEL_SECOND_PASSWORD="testMe123@"
SECOND_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_SECOND_USERNAME" "$ZITADEL_SECOND_PASSWORD" "Zitadel" "User")
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$SECOND_USER_ID")
DATE="null"
DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT")
if [ "$DATE" = "null" ]; then
echo "Failed deleting auto service user"
echo "Please remove it manually"
fi
export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID
export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID
export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID
export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET
export ZITADEL_ADMIN_USERNAME
export ZITADEL_ADMIN_PASSWORD
}
check_nb_domain() {
DOMAIN=$1
if [ "$DOMAIN-x" == "-x" ]; then
echo "The NETBIRD_DOMAIN variable cannot be empty." > /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 <<EOF
{
debug
servers :80,:443 {
protocols h1 h2c
}
}
:80${CADDY_SECURE_DOMAIN} {
# Signal
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
# Management
reverse_proxy /api/* management:80
reverse_proxy /management.ManagementService/* h2c://management:80
# Zitadel
reverse_proxy /zitadel.admin.v1.AdminService/* h2c://zitadel:8080
reverse_proxy /admin/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.auth.v1.AuthService/* h2c://zitadel:8080
reverse_proxy /auth/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.management.v1.ManagementService/* h2c://zitadel:8080
reverse_proxy /management/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.system.v1.SystemService/* h2c://zitadel:8080
reverse_proxy /system/v1/* h2c://zitadel:8080
reverse_proxy /assets/v1/* h2c://zitadel:8080
reverse_proxy /ui/* h2c://zitadel:8080
reverse_proxy /oidc/v1/* h2c://zitadel:8080
reverse_proxy /saml/v2/* h2c://zitadel:8080
reverse_proxy /oauth/v2/* h2c://zitadel:8080
reverse_proxy /.well-known/openid-configuration h2c://zitadel:8080
reverse_proxy /openapi/* h2c://zitadel:8080
reverse_proxy /debug/* h2c://zitadel:8080
# Dashboard
reverse_proxy /* dashboard:80
}
EOF
}
renderTurnServerConf() {
cat <<EOF
listening-port=3478
tls-listening-port=5349
min-port=$TURN_MIN_PORT
max-port=$TURN_MAX_PORT
fingerprint
lt-cred-mech
user=$TURN_USER:$TURN_PASSWORD
realm=netbird.io
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/private/privkey.pem
log-file=stdout
no-software-attribute
pidfile="/var/tmp/turnserver.pid"
no-cli
EOF
}
renderManagementJson() {
cat <<EOF
{
"StoreConfig": {
"Engine": "postgres"
},
"Stuns": [
{
"Proto": "udp",
"URI": "stun:$NETBIRD_DOMAIN:3478"
}
],
"TURNConfig": {
"Turns": [
{
"Proto": "udp",
"URI": "turn:$NETBIRD_DOMAIN:3478",
"Username": "$TURN_USER",
"Password": "$TURN_PASSWORD"
}
],
"TimeBasedCredentials": false
},
"Signal": {
"Proto": "$NETBIRD_HTTP_PROTOCOL",
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
},
"HttpConfig": {
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"AuthAudience": "$NETBIRD_AUTH_CLIENT_ID",
"OIDCConfigEndpoint":"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/.well-known/openid-configuration"
},
"IdpManagerConfig": {
"ManagerType": "zitadel",
"ClientConfig": {
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/oauth/v2/token",
"ClientID": "$NETBIRD_IDP_MGMT_CLIENT_ID",
"ClientSecret": "$NETBIRD_IDP_MGMT_CLIENT_SECRET",
"GrantType": "client_credentials"
},
"ExtraConfig": {
"ManagementEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/management/v1"
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "$NETBIRD_AUTH_CLIENT_ID_CLI",
"ClientID": "$NETBIRD_AUTH_CLIENT_ID_CLI",
"Scope": "openid profile email offline_access",
"RedirectURLs": ["http://localhost:53000/","http://localhost:54000/"]
}
}
}
EOF
}
renderDashboardEnv() {
cat <<EOF
{
"auth0Auth": "false",
"authAuthority": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"authClientId": "$NETBIRD_AUTH_CLIENT_ID",
"authScopesSupported": "openid profile email offline_access",
"authAudience": "$NETBIRD_AUTH_CLIENT_ID",
"apiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"grpcApiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"redirectURI": "/nb-auth",
"silentRedirectURI": "/nb-silent-auth"
}
EOF
}
renderZitadelEnv() {
cat <<EOF
ZITADEL_LOG_LEVEL=debug
ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY
ZITADEL_DATABASE_COCKROACH_HOST=crdb
ZITADEL_DATABASE_COCKROACH_USER_USERNAME=zitadel_user
ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE=verify-full
ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT="/crdb-certs/ca.crt"
ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT="/crdb-certs/client.zitadel_user.crt"
ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY="/crdb-certs/client.zitadel_user.key"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_MODE=verify-full
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_ROOTCERT="/crdb-certs/ca.crt"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_CERT="/crdb-certs/client.root.crt"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_KEY="/crdb-certs/client.root.key"
ZITADEL_EXTERNALSECURE=$ZITADEL_EXTERNALSECURE
ZITADEL_TLS_ENABLED="false"
ZITADEL_EXTERNALPORT=$NETBIRD_PORT
ZITADEL_EXTERNALDOMAIN=$NETBIRD_DOMAIN
ZITADEL_FIRSTINSTANCE_PATPATH=/machinekey/zitadel-admin-sa.token
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_SCOPES=openid
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DATE
EOF
}
renderDockerCompose() {
cat <<EOF
version: "3.4"
services:
# Caddy reverse proxy
caddy:
image: caddy
restart: unless-stopped
networks: [ netbird ]
ports:
- '33443:443'
- '33080:80'
- '33880:8080'
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
environment:
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
- NB_TRAFFIC_EVENT_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
- NETBIRD_STORE_CONFIG_ENGINE=postgres
- NB_TRAFFIC_EVENT_STORE_ENGINE=postgres
- NB_LICENSE_KEY=${NB_LICENSE_KEY}
- NB_TRAFFIC_FLOW_ADDRESS=http://127.0.0.1:8084
- NETBIRD_DATADIR=/var/lib/netbird/
- NETBIRD_ENCRYPTION_KEY=saFhCwIBtO+4QfRqMA19kKYqNPSrtXq7+TVWfHax+3I=
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
"--log-file", "console",
"--log-level", "trace",
"--disable-anonymous-metrics=false",
"--single-account-mode-domain=netbird.selfhosted",
"--dns-domain=netbird.selfhosted",
"--idp-sign-key-refresh-enabled",
]
depends_on:
postgres:
condition: 'service_healthy'
# PostgreSQL for management
postgres:
image: postgres:17
restart: unless-stopped
networks: [netbird]
environment:
- POSTGRES_USER=netbird
- POSTGRES_PASSWORD=netbird
- POSTGRES_DB=netbird
volumes:
- netbird_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbird"]
interval: 10s
timeout: 5s
retries: 5
# Zitadel - identity provider
zitadel:
restart: 'always'
networks: [netbird]
image: 'ghcr.io/zitadel/zitadel:v2.31.3'
command: 'start-from-init --masterkeyFromEnv --tlsMode $ZITADEL_TLS_MODE'
env_file:
- ./zitadel.env
depends_on:
crdb:
condition: 'service_healthy'
volumes:
- ./machinekey:/machinekey
- netbird_zitadel_certs:/crdb-certs:ro
# Reverse proxy (supports custom listen ports for UDP/TCP)
reverse-proxy:
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
env_file:
- ./proxy.env
volumes:
- ./proxy-certs:/certs:ro
command: [
"--mgmt", "http://management:80",
"--addr", "0.0.0.0:8443",
"--domain", "example.com",
"--cert-dir", "/certs",
"--debug-endpoint",
"--debug-endpoint-addr", "0.0.0.0:8444",
"--health-addr", "0.0.0.0:8080",
"--log-level", "debug",
]
depends_on:
- management
# Reverse proxy with custom ports disabled (auto-assigned listen ports only)
reverse-proxy-no-ports:
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
env_file:
- ./proxy-no-ports.env
volumes:
# Distinct cert dir so this proxy has a distinct identity from the
# primary proxy; a shared cert makes both register under the same
# proxy ID and management superseding kicks one off in a loop, which
# makes cluster registration (and the reverse-proxy suite) flaky.
- ./proxy-certs-no-ports:/certs:ro
command: [
"--mgmt", "http://management:80",
"--addr", "0.0.0.0:9443",
"--domain", "noports.example.com",
"--cert-dir", "/certs",
"--debug-endpoint",
"--debug-endpoint-addr", "0.0.0.0:9444",
"--health-addr", "0.0.0.0:9080",
"--log-level", "debug",
"--supports-custom-ports=false",
]
depends_on:
- management
# CockroachDB for zitadel
crdb:
restart: 'always'
networks: [netbird]
image: 'cockroachdb/cockroach:v22.2.2'
command: 'start-single-node --advertise-addr crdb'
volumes:
- netbird_crdb_data:/cockroach/cockroach-data
- netbird_crdb_certs:/cockroach/certs
- netbird_zitadel_certs:/zitadel-certs
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
volumes:
netbird_management:
netbird_caddy_data:
netbird_crdb_data:
netbird_crdb_certs:
netbird_zitadel_certs:
netbird_postgres_data:
networks:
netbird:
EOF
}
renderPlaywrightEnv() {
cat <<EOF
{
"ZITADEL_URL": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"BASE_URL": "http://localhost:1337"
}
EOF
}
initEnvironment

View File

435
e2e/helpers/api.ts Normal file
View File

@@ -0,0 +1,435 @@
/**
* Direct API helpers for fast CRUD operations in tests.
*
* The app uses OIDC service-worker auth, so page.request doesn't carry
* the Bearer token. We extract it from the browser context and pass it
* explicitly via page.evaluate + fetch.
*/
import type { Page } from "@playwright/test";
type Group = {
id: string;
name: string;
peers_count: number;
resources_count: number;
};
/**
* Capture the auth token and API origin by intercepting a real network
* response from the management API. We listen for any /api/ response
* and extract the request's Authorization header (injected by the OIDC
* service worker at the network level).
*/
const apiContextCache = new WeakMap<Page, { token: string; origin: string }>();
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<T>(page: Page, path: string): Promise<T> {
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<void> {
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<Group[]> {
return apiGet<Group[]>(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<Network[]> {
return apiGet<Network[]>(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<Policy[]> {
return apiGet<Policy[]>(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<Route[]> {
return apiGet<Route[]>(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<SetupKey[]> {
return apiGet<SetupKey[]>(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<DnsZone[]> {
return apiGet<DnsZone[]>(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<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(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<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(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<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(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<ReverseProxyCluster[]> {
return apiGet<ReverseProxyCluster[]>(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<void> {
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<User[]> {
return apiGet<User[]>(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);
}
}

117
e2e/helpers/auth.ts Normal file
View File

@@ -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<TestUser, { username: string; password: string }> = {
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);
}

49
e2e/helpers/fixtures.ts Normal file
View File

@@ -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";

View File

@@ -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();
}

View File

@@ -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<string> {
// 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<string> {
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 ".<domain>" 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();
}

103
e2e/helpers/utils.ts Normal file
View File

@@ -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<T>(
page: Page,
action: () => Promise<T>,
{
pattern = "/api/reverse-prox",
quietMs = 500,
timeoutMs = 15_000,
}: { pattern?: string; quietMs?: number; timeoutMs?: number } = {},
): Promise<T> {
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());
});
}

54
e2e/playwright.config.ts Normal file
View File

@@ -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"],
},
],
});

View File

@@ -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);
});
});

View File

@@ -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 });
}

View File

@@ -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);
}
});
});

View File

@@ -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);
}
});
});

197
e2e/tests/dns-zones.spec.ts Normal file
View File

@@ -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);
}
});
});

View File

@@ -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<void> }> {
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();
}
});
});

109
e2e/tests/login.spec.ts Normal file
View File

@@ -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<TestUser, { username: string; password: string }> = {
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();
}
});
});

View File

@@ -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();
}

164
e2e/tests/networks.spec.ts Normal file
View File

@@ -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();
}

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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();
});
});

View File

@@ -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();
}
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -1,3 +1,7 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
@@ -12,4 +16,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = withNextIntl(nextConfig);

5197
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,45 +2,54 @@
"name": "netbird-dashboard",
"version": "2.0.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
"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.22.18",
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tabler/icons-react": "^2.39.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.1",
"@tanstack/match-sorter-utils": "^8.8.4",
"@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": "^18",
"@types/react-dom": "^18",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
@@ -49,8 +58,10 @@
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"cmdk": "^1.1.1",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
@@ -58,40 +69,46 @@
"elkjs": "^0.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"flowbite": "^1.8.1",
"flowbite-react": "^0.6.4",
"framer-motion": "^10.16.4",
"ip-address": "^10.1.0",
"framer-motion": "^12.29.2",
"ip-address": "^10.2.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^18.3.1",
"react-day-picker": "^8.9.1",
"react-dom": "^18.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",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.2.0",
"react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@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.0.5",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3.4.17"

96
postbuild.js Normal file
View File

@@ -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(
/<script(?![^>]*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,
`<script src="/assets/${chunkFileName}" crossorigin=""></script>`,
);
// Write updated HTML file
writeFileSync(file, updatedFile, "utf8");
});
console.log("Post-build script completed successfully!");

View File

@@ -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;

View File

@@ -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 <FullScreenLoading fullScreen={false} />;
return <CustomersPageContent />;
}
const CustomersPageContent = () => {
const { permission } = usePermissions();
const { data: customers, isLoading } = useFetchApi<DistributorCustomer[]>(
"/integrations/msp/reseller/msps",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/customers"}
label={"Customers"}
icon={<MSPIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Customers</h1>
<Paragraph>
Use this view to manage customer accounts and their plans.
</Paragraph>
<Paragraph>
<DistributorDocsLink />
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Customers"}
hasAccess={permission.tenants.create}
>
<Suspense fallback={<SkeletonTable />}>
<CustomersProvider>
<DistributorCustomersTable
isLoading={isLoading}
headingTarget={portalTarget}
customers={customers}
/>
</CustomersProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
};

View File

@@ -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;

View File

@@ -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 (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
<VerticalTabs.Trigger value="identity-provider">
<FingerprintIcon size={14} />
Identity Provider Sync
</VerticalTabs.Trigger>
{isNetBirdCloud() && (
<VerticalTabs.Trigger value="sso">
<KeyRoundIcon size={14} />
Single Sign-On
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger value="event-streaming">
<FileText size={14} />
Event Streaming
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="edr">
<ShieldCheckIcon size={15} />
MDM & EDR
</VerticalTabs.Trigger>
</VerticalTabs.List>
<RestrictedAccess
page={"Integrations"}
hasAccess={
permission?.edr?.read ||
permission?.idp?.read ||
permission?.event_streaming?.read ||
(!isNetBirdCloud() && (permission?.settings?.read ?? false))
}
>
<div className={"border-l border-nb-gray-930 w-full"}>
<IdentityProviderTab />
<SSOTab />
<EventStreamingTab />
{account && <EDRTab account={account} />}
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}

View File

@@ -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<string>("/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 <Redirect />;
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 (
<Modal open={open} onOpenChange={setOpen}>
<ModalContent
maxWidthClass={"max-w-sm relative"}
showClose={false}
onEscapeKeyDown={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
>
<GradientFadedBackground />
<div className={"flex items-center justify-center flex-col gap-2 px-8"}>
<div
className={
"bg-nb-gray-900 rounded-full h-11 w-11 flex items-center justify-center mb-2"
}
>
<NetBirdIcon size={24} className={"shrink-0"} />
</div>
<div className={"text-xl font-medium text-center max-w-xs mb-1"}>
NetBird invites you to join as an Managed Service Provider (MSP)
</div>
<div className={"text-sm text-nb-gray-300 text-center"}>
You will get access to the NetBird MSP portal where you can manage
multiple customers and their networks from a single place.
</div>
{!isOwner && !isMSPAccount && (
<Callout
icon={
<LockIcon size={14} className={"shrink-0 relative top-[3px]"} />
}
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.
</Callout>
)}
{isMSPAccount && !calledOnce && (
<Callout className={"text-xs mt-3 w-full"}>
The invitation has already been accepted
</Callout>
)}
</div>
<ModalFooter separator={false} className={"gap-x-2 mt-1"}>
<Button
className={"w-full"}
variant={"secondary"}
onClick={redirectTo}
>
{declineButtonText}
</Button>
<Button
autoFocus={true}
className={"w-full"}
variant={"primary"}
disabled={isDisabled}
onClick={acceptInvitation}
>
Accept
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
const Redirect = () => {
useRedirect("/peers");
return <FullScreenLoading fullScreen={false} />;
};

View File

@@ -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;

View File

@@ -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 <FullScreenLoading fullScreen={false} />;
}

View File

@@ -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;

View File

@@ -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 <FullScreenLoading fullScreen={false} />;
}

View File

@@ -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;

View File

@@ -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 <FullScreenLoading fullScreen={false} />;
}

View File

@@ -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;

View File

@@ -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 <FullScreenLoading fullScreen={false} />;
if (!show) return <Redirect />;
return <TenantsPageContent />;
}
const Redirect = () => {
useRedirect("/peers");
return <FullScreenLoading fullScreen={false} />;
};
const TenantsPageContent = () => {
const { permission } = usePermissions();
const { data: tenants, isLoading } = useFetchApi<Tenant[]>(
"/integrations/msp/tenants",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
useFetchApi<User[]>("/users", true);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/tenants"}
label={"Tenants"}
icon={<MSPIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Tenants</h1>
<Paragraph>
A list of all tenants and their subscription details. Use this view to
manage accounts, plans and permissions.
</Paragraph>
<Paragraph>
<MSPTenantDocsLink />
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"Tenants"} hasAccess={permission.tenants.read}>
<Suspense fallback={<SkeletonTable />}>
<TenantsProvider>
<MSPTenantsTable
isLoading={isLoading}
headingTarget={portalTarget}
tenants={tenants}
/>
</TenantsProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
};

View File

@@ -6,5 +6,5 @@ import React from "react";
export default function Redirect() {
useRedirect("/events/audit");
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
@@ -17,60 +18,56 @@ import { Policy } from "@/interfaces/Policy";
import PageContainer from "@/layouts/PageContainer";
const AccessControlTable = lazy(
() => import("@/modules/access-control/table/AccessControlTable"),
() => import("@/modules/access-control/table/AccessControlTable"),
);
export default function AccessControlPage() {
const { permission } = usePermissions();
const t = useTranslations("policies");
const { permission } = usePermissions();
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<GroupsProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/access-control"}
label={"Access Control"}
icon={<AccessControlIcon size={14} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Access Control Policies</h1>
<Paragraph>
Create rules to manage access in your network and define what peers
can connect.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Access Controls
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
return (
<PageContainer>
<GroupsProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/access-control"}
label={t("title")}
icon={<AccessControlIcon size={14} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
{t("accessControlDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
{t("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess
page={"Access Control"}
hasAccess={permission.policies.read}
>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<AccessControlTable
isLoading={isLoading}
policies={policies}
headingTarget={portalTarget}
/>
</Suspense>
</PoliciesProvider>
</RestrictedAccess>
</GroupsProvider>
</PageContainer>
);
<RestrictedAccess
page={t("title")}
hasAccess={permission.policies.read}
>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<AccessControlTable
isLoading={isLoading}
policies={policies}
headingTarget={portalTarget}
/>
</Suspense>
</PoliciesProvider>
</RestrictedAccess>
</GroupsProvider>
</PageContainer>
);
}

View File

@@ -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";

View File

@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import DNSIcon from "@/assets/icons/DNSIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -15,63 +16,61 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
import PageContainer from "@/layouts/PageContainer";
const NameserverGroupTable = lazy(
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
);
export default function NameServers() {
const { permission } = usePermissions();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: nameserverGroups, isLoading } =
useFetchApi<NameserverGroup[]>("/dns/nameservers");
const { data: nameserverGroups, isLoading } =
useFetchApi<NameserverGroup[]>("/dns/nameservers");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns/nameservers"}
label={"DNS"}
icon={<DNSIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/dns/nameservers"}
label={"Nameservers"}
active
icon={<DNSIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Nameservers</h1>
<Paragraph>
Add nameservers for domain name resolution in your NetBird network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
target={"_blank"}
>
DNS
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns/nameservers"}
label={t("title")}
icon={<DNSIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/dns/nameservers"}
label={t("nameservers")}
active
icon={<DNSIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("nameservers")}</h1>
<Paragraph>
{t("nameserversDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
target={"_blank"}
>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess
page={"Nameservers"}
hasAccess={permission.nameservers.read}
>
<Suspense fallback={<SkeletonTable />}>
<NameserverGroupTable
nameserverGroups={nameserverGroups}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
<RestrictedAccess
page={t("nameservers")}
hasAccess={permission.nameservers.read}
>
<Suspense fallback={<SkeletonTable />}>
<NameserverGroupTable
nameserverGroups={nameserverGroups}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -11,5 +11,5 @@ export default function DNS() {
router.push("/dns/nameservers");
}, [router]);
return <FullScreenLoading height={"auto"} />;
return <FullScreenLoading fullScreen={false} />;
}

View File

@@ -13,6 +13,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { IconSettings2 } from "@tabler/icons-react";
import useFetchApi, { useApiCall } from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React from "react";
import Skeleton from "react-loading-skeleton";
import { useSWRConfig } from "swr";
@@ -26,126 +27,128 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
export default function NameServerSettings() {
const { permission } = usePermissions();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: settings, isLoading } =
useFetchApi<NameserverSettings>("/dns/settings");
const { data: settings, isLoading } =
useFetchApi<NameserverSettings>("/dns/settings");
const initialDNSGroups = useGroupIdsToGroups(
settings?.disabled_management_groups,
);
const initialDNSGroups = useGroupIdsToGroups(
settings?.disabled_management_groups,
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns"}
label={"DNS"}
icon={<DNSIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/dns/settings"}
label={"DNS Settings"}
active
icon={<IconSettings2 size={15} />}
/>
</Breadcrumbs>
<h1>DNS Settings</h1>
<Paragraph>{"Manage your account's DNS settings."}</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
target={"_blank"}
>
DNS
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
<RestrictedAccess page={"DNS Settings"} hasAccess={permission.dns.read}>
{!isLoading && initialDNSGroups !== undefined ? (
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
) : (
<div>
<Skeleton
width={"100%"}
className={"mt-8 max-w-xl"}
height={240}
/>
</div>
)}
</RestrictedAccess>
</div>
</PageContainer>
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/dns"}
label={t("title")}
icon={<DNSIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/dns/settings"}
label={t("dnsSettings")}
active
icon={<IconSettings2 size={15} />}
/>
</Breadcrumbs>
<h1>{t("dnsSettings")}</h1>
<Paragraph>
{t("dnsSettingsDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
target={"_blank"}
>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<RestrictedAccess
page={t("dnsSettings")}
hasAccess={permission.dns.read}
>
{!isLoading && initialDNSGroups !== undefined ? (
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
) : (
<div>
<Skeleton
width={"100%"}
className={"mt-8 max-w-xl"}
height={240}
/>
</div>
)}
</RestrictedAccess>
</div>
</PageContainer>
);
}
const SettingDisabledManagementGroups = ({
initialGroups,
initialGroups,
}: {
initialGroups: Group[];
initialGroups: Group[];
}) => {
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const t = useTranslations("dns");
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
});
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
useGroupHelper({
initial: initialGroups,
});
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
selectedGroups,
]);
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
selectedGroups,
]);
const saveSettings = async () => {
const savedGroups = await saveGroups();
notify({
title: "DNS Settings",
description: "Settings saved successfully.",
promise: settingRequest
.put({
disabled_management_groups: savedGroups.map((g) => g.id),
})
.then(() => {
mutate("/dns/settings");
updateChangesRef([selectedGroups]);
}),
loadingMessage: "Saving the settings...",
});
};
const saveSettings = async () => {
const savedGroups = await saveGroups();
notify({
title: t("dnsSettings"),
description: t("settingsSaved"),
promise: settingRequest
.put({
disabled_management_groups: savedGroups.map((g) => g.id),
})
.then(() => {
mutate("/dns/settings");
updateChangesRef([selectedGroups]);
}),
loadingMessage: t("settingsSaving"),
});
};
return (
<Card className={"mt-8 max-w-xl"}>
<div className={"px-8 py-8"}>
<Label>Disable DNS management for these groups</Label>
<HelpText>
Peers in these groups will require manual domain name resolution
</HelpText>
<PeerGroupSelector
dataCy={"dns-groups-selector"}
onChange={setSelectedGroups}
values={selectedGroups}
disabled={!permission.dns.update}
/>
</div>
<div
className={
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
}
>
<Button
variant={"primary"}
size={"sm"}
onClick={saveSettings}
disabled={!hasChanges || !permission.dns.update}
data-cy={"save-changes"}
>
Save Changes
</Button>
</div>
</Card>
);
return (
<Card className={"mt-8 max-w-xl"}>
<div className={"px-8 py-8"}>
<Label>{t("disabledManagementGroup")}</Label>
<HelpText>{t("disabledManagementGroupHelp")}</HelpText>
<PeerGroupSelector
data-testid={"dns-groups-selector"}
onChange={setSelectedGroups}
values={selectedGroups}
disabled={!permission.dns.update}
/>
</div>
<div
className={
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
}
>
<Button
variant={"primary"}
size={"sm"}
onClick={saveSettings}
disabled={!hasChanges || !permission.dns.update}
data-testid={"save-changes"}
>
{t("saveChanges")}
</Button>
</div>
</Card>
);
};

View File

@@ -8,7 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import DNSIcon from "@/assets/icons/DNSIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
@@ -17,54 +18,52 @@ import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
const DNSZonesTable = lazy(
() => import("@/modules/dns/zones/table/DNSZonesTable"),
() => import("@/modules/dns/zones/table/DNSZonesTable"),
);
export default function DNSZonePage() {
const { permission } = usePermissions();
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
<Breadcrumbs.Item
href={"/dns/zones"}
label={"Zones"}
active
icon={<DNSZoneIcon size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Zones</h1>
<Paragraph>
Manage DNS zones to control domain name resolution for your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
DNS Zones
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={t("title")} icon={<DNSIcon size={13} />} />
<Breadcrumbs.Item
href={"/dns/zones"}
label={t("zones")}
active
icon={<DNSZoneIcon size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("zones")}</h1>
<Paragraph>
{t("zonesDescription")}{" "}
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
<Suspense fallback={<SkeletonTable />}>
<DNSZonesProvider>
<DNSZonesTable
isLoading={isLoading}
headingTarget={portalTarget}
data={zones}
/>
</DNSZonesProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
<RestrictedAccess page={t("zones")} hasAccess={permission?.dns?.read}>
<Suspense fallback={<SkeletonTable />}>
<DNSZonesProvider>
<DNSZonesTable
isLoading={isLoading}
headingTarget={portalTarget}
data={zones}
/>
</DNSZonesProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

Some files were not shown because too many files have changed in this diff Show More