Compare commits

...

70 Commits

Author SHA1 Message Date
sakuradairong
635603d62b feat(i18n): expand Chinese localization and fix env substitution
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-06-19 01:52:22 +08:00
dev
56c0ad9592 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
dev
14d3fec84a 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
dev
056e5c867b 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
dev
b0421b64ac 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
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
Misha Bragin
ea148545e8 Disable local users when LocalAuthDisabled = true (#546)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-01 14:31:57 +01:00
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

* Fix repeated fetches
2026-01-27 09:33:43 +01:00
Misha Bragin
d7d44b5817 Adjust Invites API (#541)
* Add API adjustments

* Invite_link renamed to invite_token
2026-01-26 19:25:56 +01:00
Misha Bragin
f67f39b68b Local user invites (#539) 2026-01-25 21:40:49 +01:00
dependabot[bot]
d2bc7a1f57 Bump lodash from 4.17.21 to 4.17.23 (#537)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-23 13:28:28 +01:00
Eduard Gert
818ba5daa4 Allow wildcard dns zone records (#536)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-20 17:32:14 +01:00
Ali Amer
3a30f76629 Add Frontend Support for Peer Debug Bundle Trigger and History (#485)
* implement debug ui

* update job ui

* Add type cell, show tooltip if peer is offline, add copy to clipboard for upload key, show error reason in tooltip

* update job event description

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-20 17:12:33 +01:00
Misha Bragin
34dc21c89d Add password change (embedded Idp) (#535) 2026-01-20 15:00:14 +01:00
Eduard Gert
2e37703622 Update CONTRIBUTOR_LICENSE_AGREEMENT.md (#534) 2026-01-19 14:55:04 +01:00
Eduard Gert
8aec338c43 Fix dns doc link (#533)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-19 10:01:55 +01:00
Viktor Liu
f4f0c240fd Bump wasm to v0.63.0 (#531) 2026-01-19 09:49:26 +01:00
Viktor Liu
04e22a3c7e Enable SSH for Windows and Android peers (#532)
* Enable SSH for Windows and Android peers, hide update badge for temporary peers

* Fix RDP to use tcp protocol instead of netbird-ssh
2026-01-19 09:49:08 +01:00
Eduard Gert
54ef076303 Fix config vars (#529)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 19:59:42 +01:00
Eduard Gert
92676b6c38 Add DNS zones (#528)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-16 17:33:16 +01:00
Eduard Gert
3affa8908f Redirect /setup to /peers if no setup is required (#526)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Redirect /setup to /peers if not setup is required

* Fix bad state while redirect

* Prevent redirect to /setup if already on /setup

* Fix loading state
2026-01-08 15:01:45 +01:00
413 changed files with 35613 additions and 10454 deletions

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

@@ -0,0 +1,12 @@
## 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/__

View File

@@ -19,7 +19,7 @@ jobs:
- name: setup-node
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
@@ -54,8 +54,19 @@ jobs:
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2

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

2
.gitignore vendored
View File

@@ -37,6 +37,8 @@ next-env.d.ts
# config
.local-config.json
.test-config.json
cypress.env.json
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json

View File

@@ -1,7 +1,7 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance

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`

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "New",
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
"link": "https://docs.netbird.io/manage/reverse-proxy",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

View File

@@ -15,4 +15,4 @@
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}
}

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

136
docker/server.js Normal file
View File

@@ -0,0 +1,136 @@
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;
// Try /zh prefix (next-intl locale)
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

@@ -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",
@@ -7,7 +11,9 @@ const nextConfig = {
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};
module.exports = nextConfig;
module.exports = withNextIntl(nextConfig);

5230
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,9 @@
"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",
@@ -13,34 +16,34 @@
"cypress:open": "cypress open"
},
"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/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 +52,9 @@
"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",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
@@ -58,39 +62,43 @@
"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",
"framer-motion": "^12.29.2",
"ip-address": "^10.1.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.539.0",
"next": "^14.2.35",
"lodash": "^4.17.23",
"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-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",
"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"

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

@@ -7,7 +7,8 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
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={<ServerIcon 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
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"}
>
{t("saveChanges")}
</Button>
</div>
</Card>
);
};

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: `Zones - DNS - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,69 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
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 { lazy, Suspense } from "react";
import DNSIcon from "@/assets/icons/DNSIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
import PageContainer from "@/layouts/PageContainer";
import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
const DNSZonesTable = lazy(
() => import("@/modules/dns/zones/table/DNSZonesTable"),
);
export default function DNSZonePage() {
const t = useTranslations("dns");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
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={t("zones")} hasAccess={permission?.dns?.read}>
<Suspense fallback={<SkeletonTable />}>
<DNSZonesProvider>
<DNSZonesTable
isLoading={isLoading}
headingTarget={portalTarget}
data={zones}
/>
</DNSZonesProvider>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -7,6 +7,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, LogsIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -15,6 +16,8 @@ import PageContainer from "@/layouts/PageContainer";
import ActivityTable from "@/modules/activity/ActivityTable";
export default function Activity() {
const t = useTranslations("activity");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: events, isLoading } =
@@ -28,31 +31,29 @@ export default function Activity() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={"Activity"}
label={t("title")}
disabled={true}
icon={<ActivityIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/events/audit"}
label={"Audit Events"}
label={t("auditEvents")}
icon={<LogsIcon size={18} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Audit Events</h1>
<Paragraph>Here you can see all the audit activity events.</Paragraph>
<h1 ref={headingRef}>{t("auditEvents")}</h1>
<Paragraph>
Learn more about{" "}
{t("auditEventsDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/audit-events-logging"}
target={"_blank"}
>
Audit Events
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
<RestrictedAccess page={t("title")} hasAccess={permission.events.read}>
<ActivityTable
events={events}
isLoading={isLoading}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function ProxyEventsPage() {
redirect("/reverse-proxy/logs");
}

View File

@@ -12,9 +12,11 @@ import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
@@ -24,6 +26,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { Group, GROUP_TOOLTIP_TEXT } from "@/interfaces/Group";
import PageContainer from "@/layouts/PageContainer";
import { GroupDNSZonesSection } from "@/modules/groups/details/GroupDNSZonesSection";
import { GroupNameserversSection } from "@/modules/groups/details/GroupNameserversSection";
import { GroupNetworkRoutesSection } from "@/modules/groups/details/GroupNetworkRoutesSection";
import { GroupPeersSection } from "@/modules/groups/details/GroupPeersSection";
@@ -34,6 +37,7 @@ import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
export default function GroupPage() {
const t = useTranslations("groups");
const queryParameter = useSearchParams();
const { isRestricted } = usePermissions();
const groupId = queryParameter.get("id");
@@ -48,7 +52,7 @@ export default function GroupPage() {
if (isRestricted) {
return (
<PageContainer>
<RestrictedAccess page={"Group Information"} />
<RestrictedAccess page={t("title")} />
</PageContainer>
);
}
@@ -71,7 +75,7 @@ export default function GroupPage() {
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
label={t("title")}
icon={<FolderGit2Icon size={14} />}
/>
<Breadcrumbs.Item label={group.name} active />
@@ -134,10 +138,14 @@ const validAllGroupTabs = [
"resources",
"network-routes",
"nameservers",
"zones",
];
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
const GroupOverviewTabs = ({ group }: { group: Group }) => {
const t = useTranslations("groups");
const tNetworks = useTranslations("networks");
const searchParams = useSearchParams();
const getInitialTab = () => {
@@ -162,6 +170,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
const resourcesCount = groupDetails?.resources_count || 0;
const routesCount = groupDetails?.routes?.length || 0;
const nameserversCount = groupDetails?.nameservers?.length || 0;
const zonesCount = groupDetails?.zones?.length || 0;
const setupKeysCount = groupDetails?.setupKeys?.length || 0;
return (
@@ -183,7 +192,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Users", usersCount)}
{singularize(t("users"), usersCount)}
</TabsTrigger>
)}
@@ -198,7 +207,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Peers", peersCount)}
{singularize(t("peers"), peersCount)}
</TabsTrigger>
)}
@@ -212,7 +221,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Policies", policiesCount)}
{singularize(t("policies"), policiesCount)}
</TabsTrigger>
<TabsTrigger
@@ -220,7 +229,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
className={groupDetails === null ? "animate-pulse" : ""}
>
<Layers3Icon size={14} />
{singularize("Resources", resourcesCount)}
{singularize(t("resources"), resourcesCount)}
</TabsTrigger>
<TabsTrigger
@@ -233,7 +242,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Network Routes", routesCount)}
{singularize(tNetworks("networkRoutes"), routesCount)}
</TabsTrigger>
<TabsTrigger
@@ -246,7 +255,20 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Nameservers", nameserversCount)}
{singularize(t("nameservers"), nameserversCount)}
</TabsTrigger>
<TabsTrigger
value={"zones"}
className={groupDetails === null ? "animate-pulse" : ""}
>
<DNSZoneIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize(t("zones"), zonesCount)}
</TabsTrigger>
{group.name !== "All" && (
@@ -260,7 +282,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Setup Keys", setupKeysCount)}
{singularize(t("setupKeys"), setupKeysCount)}
</TabsTrigger>
)}
</TabsList>
@@ -304,6 +326,13 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
/>
</TabsContent>
<TabsContent value={"zones"} className={"pb-8"}>
<GroupDNSZonesSection
zones={groupDetails?.zones}
isLoading={isLoading}
/>
</TabsContent>
<TabsContent value={"setup-keys"} className={"pb-8"}>
<GroupSetupKeysSection
setupKeys={groupDetails?.setupKeys}

View File

@@ -5,6 +5,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import Breadcrumbs from "@/components/Breadcrumbs";
import InlineLink from "@/components/InlineLink";
@@ -14,43 +15,39 @@ import PageContainer from "@/layouts/PageContainer";
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
export default function GroupsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const t = useTranslations("groups");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={"Groups"}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>Groups</h1>
<Paragraph>
Here is the overview of the groups of your organization. You can
delete the unused ones.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
<Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/groups"}
label={t("title")}
icon={<FolderGit2Icon size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
{t("groupsDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
{t("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess hasAccess={permission.groups.read} page={t("title")}>
<Suspense fallback={<SkeletonTable />}>
<GroupsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -8,7 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -19,72 +20,71 @@ import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
import { Callout } from "@components/Callout";
const NetworkRoutesTable = lazy(
() => import("@/modules/route-group/NetworkRoutesTable"),
() => import("@/modules/route-group/NetworkRoutesTable"),
);
export default function NetworkRoutes() {
const { permission } = usePermissions();
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
const groupedRoutes = useGroupedRoutes({ routes });
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
const groupedRoutes = useGroupedRoutes({ routes });
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<RoutesProvider>
<PeersProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/network-routes"}
label={"Network Routes"}
icon={<NetworkRoutesIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Network Routes</h1>
<Paragraph>
Network routes allow you to access other networks like LANs and
VPCs without installing NetBird on every resource.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
>
Network Routes
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
return (
<PageContainer>
<RoutesProvider>
<PeersProvider>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
label={t("networkRoutes")}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item href={"/network-routes"} label={t("routes")} />
</Breadcrumbs>
<h1 ref={headingRef}>{t("routes")}</h1>
<Paragraph>
{t("routesDescription")}{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
}
target={"_blank"}
aria-label={
"Learn more about routing traffic to private networks"
}
>
<>{tCommon("learnMore")}</>
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
<InlineLink href={"/networks"}>
Go to Networks
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
<span>
{t("newNetworksRecommendation")}{" "}
<InlineLink href={"/networks"}>
{t("goToNetworks")}
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<RestrictedAccess hasAccess={permission.routes.read}>
<Suspense fallback={<SkeletonTable />}>
<NetworkRoutesTable
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PeersProvider>
</RoutesProvider>
</PageContainer>
);
<RestrictedAccess hasAccess={permission.routes.read}>
<Suspense fallback={<SkeletonTable />}>
<NetworkRoutesTable
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PeersProvider>
</RoutesProvider>
</PageContainer>
);
}

View File

@@ -12,14 +12,13 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { cn, singularize } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
Layers3Icon,
MoreVertical,
PencilLineIcon,
ServerIcon,
@@ -28,19 +27,30 @@ import {
Trash2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useTranslations } from "next-intl";
import React, { useMemo } from "react";
import useUrlTab from "@/hooks/useUrlTab";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
import { NetworkRoutingPeersTabContent } from "@/modules/networks/routing-peers/NetworkRoutingPeersTabContent";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -53,17 +63,36 @@ export default function NetworkDetailPage() {
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<NetworkOverview network={network} />
<ReverseProxiesProvider initialNetwork={network}>
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
<SkeletonNetwork />
);
}
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const t = useTranslations("networks");
const tReverseProxy = useTranslations("reverseProxy");
const { permission } = usePermissions();
const [networkModal, setNetworkModal] = useState(false);
const { mutate } = useSWRConfig();
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>(`/networks/${network.id}/resources`);
const { data: routers, isLoading: isRoutersLoading } = useFetchApi<
NetworkRouter[]
>(`/networks/${network.id}/routers`);
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const services = useMemo(
() => flattenReverseProxies({ reverseProxies, network }),
[reverseProxies, network],
);
const [tab, setTab] = useUrlTab(
["resources", "routing-peers", "services"],
"resources",
);
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
@@ -71,56 +100,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={t("title")}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkActions />
</NetworkProvider>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</div>
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize(t("resources"), network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize(t("routingPeers"), network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize(tReverseProxy("services"), services.length)}
</TabsTrigger>
</TabsList>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
</NetworkProvider>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
);
}
@@ -176,6 +252,8 @@ function NetworkActions() {
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
);
@@ -184,22 +262,26 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
() => (
<>
High availability is currently{" "}
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
network.
<span className={"text-yellow-400 font-medium"}>
{tCommon("inactive")}
</span>{" "}
for this network.
</>
),
[],
[tCommon],
);
const enabledText = useMemo(
() => (
<>
High availability is{" "}
<span className={"text-green-500 font-medium"}>active</span> for this
network.
<span className={"text-green-500 font-medium"}>
{tCommon("active")}
</span>{" "}
for this network.
</>
),
[],
[tCommon],
);
const policyCount = network.policies?.length ?? 0;
@@ -246,7 +328,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
!isHighlyAvailable ? "bg-yellow-400" : "bg-green-500",
)}
></span>
{isHighlyAvailable ? "Active" : "Inactive"}
{isHighlyAvailable ? tCommon("active") : tCommon("inactive")}
<HelpCircle size={12} />
</div>
</FullTooltip>
@@ -258,20 +340,19 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
policyCount > 0 ? (
<>
<ShieldCheckIcon size={16} className={"text-green-500"} />
{policyCount}{" "}
{policyCount === 1 ? "Active Policy" : "Active Policies"}
{t("activePoliciesCount", { count: policyCount })}
</>
) : (
<>
<ShieldXIcon size={16} className={"text-red-500"} />
No Active Policies
{t("noActivePolicies")}
</>
)
}
value={
policyCount > 0 ? (
<InlineLink href={"/access-control"}>
Go to Policies
{t("goToPolicies")}
<ArrowUpRightIcon size={14} />
</InlineLink>
) : null

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, { Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -16,6 +17,9 @@ import PageContainer from "@/layouts/PageContainer";
import NetworksTable from "@/modules/networks/table/NetworksTable";
export default function Networks() {
const t = useTranslations("networks");
const tCommon = useTranslations("common");
const tNavigation = useTranslations("navigation");
const { data: networks, isLoading } = useFetchApi<Network[]>("/networks");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
@@ -26,26 +30,21 @@ export default function Networks() {
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
label={tNavigation("networkRouting")}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item href={"/networks"} label={t("title")} />
</Breadcrumbs>
<h1 ref={headingRef}>Networks</h1>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
Networks allow you to access internal resources in LANs and VPCs
without installing NetBird on every machine.
</Paragraph>
<Paragraph>
Learn more about
{t("pageDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/networks"}
target={"_blank"}
>
Networks
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function Peers() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<PeersBlockedView />
) : (
<PeersProvider>
<PeersView />
</PeersProvider>
)}
</PageContainer>
);
}
function PeersView() {
const { peers, isLoading } = usePeers();
const { users } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const peersWithUser = peers?.map((peer) => {
if (!users) return peer;
return {
...peer,
user: users?.find((user) => user.id === peer.user_id),
};
});
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Peers</h1>
<Paragraph>
A list of all machines and devices connected to your private network.
Use this view to manage peers.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
Peers
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
/>
</Suspense>
</>
);
}
function PeersBlockedView() {
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>Add new device to your network</h1>
<Paragraph className={"inline"}>
To get started, install NetBird and log in using your email account.
After that you should be connected. If you have further questions
check out our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
Installation Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
}
>
<SetupModalContent header={false} footer={false} />
</div>
</div>
</div>
);
export default function PeersIndex() {
redirect("/peers/users");
}

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: `Servers - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,125 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function ServersPage() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<ServersBlockedView />
) : (
<PeersProvider>
<ServersView />
</PeersProvider>
)}
</PageContainer>
);
}
function ServersView() {
const t = useTranslations("peers");
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
}));
}, [peers, users]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={t("title")} icon={<PeerIcon size={13} />} />
<Breadcrumbs.Item
href={"/peers/servers"}
label={t("servers")}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("servers")}</h1>
<Paragraph>
{t("serversDescription")}{" "}
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
{t("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"servers"}
/>
</Suspense>
</>
);
}
function ServersBlockedView() {
const t = useTranslations("peers");
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>{t("addNewServerTitle")}</h1>
<Paragraph className={"inline"}>
{t("addNewServerDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
{t("installationGuide")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent
header={false}
footer={false}
isUserDevice={false}
/>
</div>
</div>
</div>
);
}

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: `User Devices - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,119 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense, useMemo } from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import PageContainer from "@/layouts/PageContainer";
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
export default function UserDevicesPage() {
const { isRestricted } = usePermissions();
return (
<PageContainer>
{isRestricted ? (
<UserDevicesBlockedView />
) : (
<PeersProvider>
<UserDevicesView />
</PeersProvider>
)}
</PageContainer>
);
}
function UserDevicesView() {
const t = useTranslations("peers");
const { peers, isLoading: isPeersLoading } = usePeers();
const { users, isLoading: isUsersLoading } = useUsers();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
// The kind filter classifies peers by whether their owner is a real
// user vs a service/no-user, so we must wait until both peers and
// users have loaded before joining them — otherwise peers temporarily
// render with peer.user === undefined and get misclassified.
const isLoading = isPeersLoading || isUsersLoading;
const peersWithUser = useMemo(() => {
if (!peers || !users) return undefined;
return peers.map((peer) => ({
...peer,
user: users.find((u) => u.id === peer.user_id),
}));
}, [peers, users]);
return (
<>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item label={t("title")} icon={<PeerIcon size={13} />} />
<Breadcrumbs.Item
href={"/peers/users"}
label={t("userDevices")}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("userDevices")}</h1>
<Paragraph>
{t("userDevicesDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
target={"_blank"}
>
{t("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<Suspense fallback={<SkeletonTable />}>
<PeersTable
isLoading={isLoading}
peers={peersWithUser}
headingTarget={portalTarget}
kind={"users"}
/>
</Suspense>
</>
);
}
function UserDevicesBlockedView() {
const t = useTranslations("peers");
return (
<div className={"flex items-center justify-center flex-col"}>
<div className={"p-default py-6 max-w-3xl text-center"}>
<h1>{t("addNewDeviceTitle")}</h1>
<Paragraph className={"inline"}>
{t("addNewDeviceDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started#installation"}
target={"_blank"}
>
{t("installationGuide")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
}
>
<SetupModalContent header={false} footer={false} isUserDevice />
</div>
</div>
</div>
);
}

View File

@@ -6,76 +6,61 @@ import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
import React, { lazy, Suspense } from "react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import GroupsProvider from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import PoliciesProvider from "@/contexts/PoliciesProvider";
import { PostureCheck } from "@/interfaces/PostureCheck";
import PageContainer from "@/layouts/PageContainer";
import useFetchApi from "@utils/api";
const PostureCheckTable = lazy(
() => import("@/modules/posture-checks/table/PostureCheckTable"),
() => import("@/modules/posture-checks/table/PostureCheckTable"),
);
export default function PostureChecksPage() {
const { permission } = usePermissions();
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
const t = useTranslations("postureChecks");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { data: postureChecks, isLoading } =
useFetchApi<PostureCheck[]>("/posture-checks");
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.Item
href={"/posture-checks"}
label={"Posture Checks"}
active
icon={<ShieldCheck size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Posture Checks</h1>
<Paragraph>
Use posture checks to further restrict access in your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
target={"_blank"}
>
Posture Checks
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Posture Checks"}
hasAccess={permission.policies.read}
>
<PoliciesProvider>
<Suspense fallback={<SkeletonTable />}>
<PostureCheckTable
headingTarget={portalTarget}
isLoading={isLoading}
postureChecks={postureChecks}
/>
</Suspense>
</PoliciesProvider>
</RestrictedAccess>
</GroupsProvider>
</PageContainer>
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/posture-checks"}
label={t("title")}
icon={<ShieldCheck size={14} />}
active
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
{t("pageDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
target={"_blank"}
>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess page={t("title")} hasAccess={permission.policies.read}>
<Suspense fallback={<SkeletonTable />}>
<PostureCheckTable
postureChecks={postureChecks}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</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: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,66 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const ClustersTable = lazy(
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
);
export default function ReverseProxyClustersPage() {
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={t("title")}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/clusters"}
label={t("clusters")}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("clusters")}</h1>
<Paragraph>
{t("clustersDescription")}{" "}
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<RestrictedAccess
page={t("clusters")}
hasAccess={permission.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxiesProvider>
<ClustersTable headingTarget={portalTarget} />
</ReverseProxiesProvider>
</Suspense>
</RestrictedAccess>
</div>
</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: `Custom Domains - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,69 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const CustomDomainsTable = lazy(
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
);
export default function ReverseProxyCustomDomainsPage() {
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={t("title")}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/custom-domains"}
label={t("customDomains")}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("customDomains")}</h1>
<Paragraph>
{t("customDomainsDescription")}{" "}
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<RestrictedAccess
page={t("customDomains")}
hasAccess={permission.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxiesProvider>
<CustomDomainsTable headingTarget={portalTarget} />
</ReverseProxiesProvider>
</Suspense>
</RestrictedAccess>
</div>
</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: `Access Logs - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,63 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const ReverseProxyEventsTable = lazy(
() => import("@/modules/reverse-proxy/events/ReverseProxyEventsTable"),
);
export default function ProxyLogsPage() {
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={t("title")}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/logs"}
label={t("accessLogs")}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("accessLogs")}</h1>
<Paragraph>
{t("accessLogsDescription")}{" "}
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target={"_blank"}>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
<RestrictedAccess
page={t("accessLogs")}
hasAccess={permission.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ReverseProxyRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/reverse-proxy/services");
}, [router]);
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: `Services - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,79 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import { Callout } from "@components/Callout";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { isNetBirdHosted } from "@utils/netbird";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const ReverseProxyTable = lazy(
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
);
export default function ReverseProxyServicesPage() {
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={t("title")}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={t("services")}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("services")}</h1>
<Paragraph>
{t("servicesDescription")}{" "}
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
{tCommon("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
{isNetBirdHosted() ? (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
{t("betaNoticeCloud")}
</Callout>
) : (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
{t("betaNoticeSelfHosted")}
</Callout>
)}
<RestrictedAccess
page={t("services")}
hasAccess={permission.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxiesProvider>
<ReverseProxyTable headingTarget={portalTarget} />
</ReverseProxiesProvider>
</Suspense>
</RestrictedAccess>
</div>
</PageContainer>
);
}

View File

@@ -3,14 +3,16 @@
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { VerticalTabs } from "@components/VerticalTabs";
import {
AlertOctagonIcon,
FingerprintIcon,
FolderGit2Icon,
LockIcon,
MonitorSmartphoneIcon,
NetworkIcon,
ShieldIcon,
AlertOctagonIcon,
FingerprintIcon,
FolderGit2Icon,
KeyRound,
LockIcon,
MonitorSmartphoneIcon,
NetworkIcon,
ShieldIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -23,95 +25,105 @@ import DangerZoneTab from "@/modules/settings/DangerZoneTab";
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
import PermissionsTab from "@/modules/settings/PermissionsTab";
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
import GroupsSettings from "@/modules/settings/GroupsSettings";
export default function NetBirdSettings() {
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const { permission } = usePermissions();
const t = useTranslations("settings");
const queryParams = useSearchParams();
const queryTab = queryParams.get("tab");
const { permission } = usePermissions();
const initialTab = useMemo(() => {
if (permission.settings.read) return "authentication";
return "authentication";
}, [permission]);
const initialTab = useMemo(() => {
if (permission.settings.read) return "authentication";
return "authentication";
}, [permission]);
const [tab, setTab] = useState(queryTab ?? initialTab);
const [tab, setTab] = useState(queryTab ?? initialTab);
const account = useAccount();
const account = useAccount();
useEffect(() => {
if (queryTab) {
setTab(queryTab);
}
}, [queryTab]);
useEffect(() => {
if (queryTab) {
setTab(queryTab);
}
}, [queryTab]);
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
{permission.settings.read && (
<>
<VerticalTabs.Trigger value="authentication">
<ShieldIcon size={14} />
Authentication
</VerticalTabs.Trigger>
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && (
<VerticalTabs.Trigger value="identity-providers">
<FingerprintIcon size={14} />
Identity Providers
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger value="groups">
<FolderGit2Icon size={14} />
Groups
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="permissions">
<LockIcon size={14} />
Permissions
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="networks">
<NetworkIcon size={14} />
Networks
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="clients">
<MonitorSmartphoneIcon size={14} />
Clients
</VerticalTabs.Trigger>
</>
)}
return (
<PageContainer>
<VerticalTabs value={tab} onChange={setTab}>
<VerticalTabs.List>
{permission.settings.read && (
<>
<VerticalTabs.Trigger value="authentication">
<ShieldIcon size={14} />
{t("authentication")}
</VerticalTabs.Trigger>
{permission.setup_keys.read && (
<VerticalTabs.Trigger value="setup-keys">
<KeyRound size={14} />
{t("setupKeys")}
</VerticalTabs.Trigger>
)}
{account?.settings?.embedded_idp_enabled &&
permission?.identity_providers?.read && (
<VerticalTabs.Trigger value="identity-providers">
<FingerprintIcon size={14} />
{t("identityProviders")}
</VerticalTabs.Trigger>
)}
<VerticalTabs.Trigger value="groups">
<FolderGit2Icon size={14} />
{t("groupsTab")}
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="permissions">
<LockIcon size={14} />
{t("permissions")}
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="networks">
<NetworkIcon size={14} />
{t("networksTab")}
</VerticalTabs.Trigger>
<VerticalTabs.Trigger value="clients">
<MonitorSmartphoneIcon size={14} />
{t("clients")}
</VerticalTabs.Trigger>
</>
)}
<DangerZoneTabTrigger />
</VerticalTabs.List>
<RestrictedAccess
page={"Settings"}
hasAccess={permission.settings.read}
>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && <IdentityProvidersTab />}
{account && <PermissionsTab account={account} />}
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
<DangerZoneTabTrigger />
</VerticalTabs.List>
<RestrictedAccess
page={t("title")}
hasAccess={permission.settings.read}
>
<div className={"border-l border-nb-gray-930 w-full"}>
{account && <AuthenticationTab account={account} />}
{permission.setup_keys.read && <SetupKeysTab />}
{account?.settings?.embedded_idp_enabled &&
permission.identity_providers.read && <IdentityProvidersTab />}
{account && <PermissionsTab account={account} />}
{account && <GroupsSettings account={account} />}
{account && <NetworkSettingsTab account={account} />}
{account && <ClientSettingsTab account={account} />}
{account && <DangerZoneTab account={account} />}
</div>
</RestrictedAccess>
</VerticalTabs>
</PageContainer>
);
}
const DangerZoneTabTrigger = () => {
const { isOwner } = useLoggedInUser();
const t = useTranslations("settings");
const { isOwner } = useLoggedInUser();
return (
isOwner && (
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
<AlertOctagonIcon size={14} />
Danger zone
</VerticalTabs.Trigger>
)
);
return (
isOwner && (
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
<AlertOctagonIcon size={14} />
{t("dangerZone")}
</VerticalTabs.Trigger>
)
);
};

View File

@@ -1,90 +1,5 @@
"use client";
import { redirect } from "next/navigation";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
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, useMemo } from "react";
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { SetupKey } from "@/interfaces/SetupKey";
import PageContainer from "@/layouts/PageContainer";
const SetupKeysTable = lazy(
() => import("@/modules/setup-keys/SetupKeysTable"),
);
export default function SetupKeys() {
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
const { permission } = usePermissions();
const { groups } = useGroups();
const setupKeysWithGroups = useMemo(() => {
if (!setupKeys) return [];
return setupKeys?.map((setupKey) => {
if (!setupKey.auto_groups) return setupKey;
if (!groups) return setupKey;
return {
...setupKey,
groups: setupKey.auto_groups
?.map((group) => {
return groups.find((g) => g.id === group) || undefined;
})
.filter((group) => group !== undefined) as Group[],
};
});
}, [setupKeys, groups]);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/setup-keys"}
label={"Setup Keys"}
icon={<SetupKeysIcon size={13} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Setup Keys</h1>
<Paragraph>
Setup keys are pre-authentication keys that allow to register new
machines in your network.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
}
target={"_blank"}
>
Setup Keys
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Setup Keys"}
hasAccess={permission.setup_keys.read}
>
<Suspense fallback={<SkeletonTable />}>
<SetupKeysTable
headingTarget={portalTarget}
setupKeys={setupKeysWithGroups}
isLoading={isLoading}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}
export default function SetupKeysIndex() {
redirect("/settings?tab=setup-keys");
}

View File

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

View File

@@ -9,6 +9,7 @@ import { usePortalElement } from "@hooks/usePortalElement";
import { IconSettings2 } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import TeamIcon from "@/assets/icons/TeamIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -16,63 +17,57 @@ import { User } from "@/interfaces/User";
import PageContainer from "@/layouts/PageContainer";
const ServiceUsersTable = lazy(
() => import("@/modules/users/ServiceUsersTable"),
() => import("@/modules/users/ServiceUsersTable"),
);
export default function ServiceUsers() {
const { permission } = usePermissions();
const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=true",
);
const t = useTranslations("serviceUsers");
const tUsers = useTranslations("users");
const { permission } = usePermissions();
const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=true",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
icon={<TeamIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/team/service-users"}
label={"Service Users"}
active
icon={<IconSettings2 size={17} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Service Users</h1>
<Paragraph>
Use service users to create API tokens and avoid losing automated
access.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/access-netbird-public-api"}
target={"_blank"}
>
Service Users
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Service Users"}
hasAccess={permission.users.read}
>
<Suspense fallback={<SkeletonTable />}>
<ServiceUsersTable
users={users}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={tUsers("team")}
icon={<TeamIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/team/service-users"}
label={t("title")}
active
icon={<IconSettings2 size={17} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
{t("serviceUsersDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/access-netbird-public-api"}
target={"_blank"}
>
{tUsers("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess page={t("title")} hasAccess={permission.users.read}>
<Suspense fallback={<SkeletonTable />}>
<ServiceUsersTable
users={users}
isLoading={isLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon, User2 } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { lazy, Suspense } from "react";
import TeamIcon from "@/assets/icons/TeamIcon";
import { useGroups } from "@/contexts/GroupsProvider";
@@ -18,57 +19,53 @@ import PageContainer from "@/layouts/PageContainer";
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
export default function TeamUsers() {
const { isLoading: isGroupsLoading } = useGroups();
const { permission } = usePermissions();
const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
const t = useTranslations("users");
const { isLoading: isGroupsLoading } = useGroups();
const { permission } = usePermissions();
const { data: users, isLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={"Team"}
icon={<TeamIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/team/users"}
label={"Users"}
active
icon={<User2 size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Users</h1>
<Paragraph>
Manage users and their permissions. Same-domain email users are added
automatically on first sign-in.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={"https://docs.netbird.io/how-to/add-users-to-your-network"}
target={"_blank"}
>
Users
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess page={"Users"} hasAccess={permission.users.read}>
<Suspense fallback={<SkeletonTable />}>
<UsersTable
users={users}
isLoading={isLoading || isGroupsLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/team"}
label={t("team")}
icon={<TeamIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/team/users"}
label={t("title")}
active
icon={<User2 size={16} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>{t("title")}</h1>
<Paragraph>
{t("usersPageDescription")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/add-users-to-your-network"}
target={"_blank"}
>
{t("learnMore")}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<RestrictedAccess page={t("title")} hasAccess={permission.users.read}>
<Suspense fallback={<SkeletonTable />}>
<UsersTable
users={users}
isLoading={isLoading || isGroupsLoading}
headingTarget={portalTarget}
/>
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -20,7 +20,6 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -85,11 +84,8 @@ function RDPSession({ peer }: Props) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
await client.connectTemporary(peer.id, [
`${protocol}/${rdpCredentials.port}`,
`tcp/${rdpCredentials.port}`,
]);
setIsNetBirdConnecting(false);
} catch (error) {

View File

@@ -18,7 +18,7 @@ import {
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const { peerId, username, port, ipVersion } = useSSHQueryParams();
const {
data: peer,
@@ -48,6 +48,7 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
@@ -60,9 +61,10 @@ type Props = {
username: string;
port: string;
peer: Peer;
ipVersion: string | null;
};
function SSHTerminal({ username, port, peer }: Props) {
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
@@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
document.title = `${username}@${sshHost} - ${peer.hostname}`;
}, [username, peer, client, sshHost]);
const handleReconnect = async () => {
if (!peer?.id) return;
@@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;

View File

@@ -2,7 +2,14 @@
@tailwind components;
@tailwind utilities;
:root {
--toasts-before: 0;
--lift: 1;
}
html{
@apply bg-nb-gray;
}
h1 {
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
@@ -169,6 +176,25 @@ p {
@apply m-0 p-0 box-border;
}
/* Disable sonner's opacity fade-in for custom toasts, but respect visibility */
[data-sonner-toast][data-visible="true"] {
opacity: 1 !important;
}
/* Adjust sonner stacking: less shrink and less lift per toast */
[data-sonner-toast][data-expanded="false"][data-front="false"] {
--scale: calc(var(--toasts-before) * 0.03 - 1) !important;
--lift-amount: calc(var(--lift) * 10px) !important;
}
/* Override stacked toast removal to move up instead of down */
[data-sonner-toast][data-removed='true'][data-front='false'][data-swipe-out='false'][data-expanded='false'] {
--y: translateY(calc(var(--lift) * -20%)) !important;
opacity: 0 !important;
transition: transform 400ms ease, opacity 300ms ease !important;
}
/* Control Center */
.react-flow__node-groupNode .selected{

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: `Accept Invite - ${globalMetaTitle}`,
};
export default BlankLayout;

321
src/app/invite/page.tsx Normal file
View File

@@ -0,0 +1,321 @@
"use client";
import Button from "@components/Button";
import { Input } from "@components/Input";
import Paragraph from "@components/Paragraph";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { acceptInvite, fetchInviteInfo } from "@utils/unauthenticatedApi";
import {
AlertCircle,
CheckCircle2,
Clock,
KeyRound,
Mail,
User2,
} from "lucide-react";
import dayjs from "dayjs";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { UserInviteInfo } from "@/interfaces/User";
export default function InviteAcceptPage() {
return (
<Suspense fallback={<FullScreenLoading />}>
<InviteAcceptContent />
</Suspense>
);
}
function InviteAcceptContent() {
const searchParams = useSearchParams();
const router = useRouter();
const token = searchParams?.get("token");
const [loading, setLoading] = useState(true);
const [inviteInfo, setInviteInfo] = useState<UserInviteInfo | null>(null);
const [error, setError] = useState<string | null>(null);
const [isRateLimited, setIsRateLimited] = useState(false);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError("No invite token provided");
setLoading(false);
return;
}
fetchInviteInfo(token)
.then((info) => {
setInviteInfo(info);
setLoading(false);
})
.catch((err) => {
if (err.code === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setIsRateLimited(true);
} else {
setError(err.message || "Invalid or expired invite link");
setIsRateLimited(false);
}
setLoading(false);
});
}, [token]);
const passwordsMatch = password === confirmPassword;
const hasMinLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const passwordValid = hasMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecialChar;
const canSubmit = passwordValid && passwordsMatch && !submitting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit || !token) return;
setSubmitting(true);
setError(null);
try {
await acceptInvite(token, password);
setSuccess(true);
} catch (err: any) {
setError(err.message || "Failed to accept invite");
} finally {
setSubmitting(false);
}
};
const isExpired = useMemo(() => {
if (!inviteInfo) return false;
return new Date(inviteInfo.expires_at) < new Date();
}, [inviteInfo]);
if (loading) {
return <FullScreenLoading />;
}
if (error && !inviteInfo) {
if (isRateLimited) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<Clock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Too Many Requests
</h1>
<Paragraph className="text-nb-gray-400 text-base">
You&apos;ve made too many requests. Please wait a moment and try
again.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => window.location.reload()}
>
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invalid Invite
</h1>
<Paragraph className="text-nb-gray-400 text-base">
This invite link is invalid or has expired. Please contact your
administrator to receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-500/10 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-8 h-8 text-green-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Account Created!
</h1>
<Paragraph className="text-nb-gray-400">
Your account has been created successfully. You can now log in with
your email and password.
</Paragraph>
<Button
variant="primary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
if (isExpired || !inviteInfo?.valid) {
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full text-center">
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-yellow-500/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-yellow-500" />
</div>
</div>
<h1 className="text-2xl font-semibold text-white mb-2">
Invite Expired
</h1>
<Paragraph className="text-nb-gray-400">
This invite link has expired. Please contact your administrator to
receive a new invitation.
</Paragraph>
<Button
variant="secondary"
className="mt-6"
onClick={() => router.push("/")}
>
Go to Login
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-nb-gray-950 p-4">
<div className="max-w-md w-full">
<div className="mb-8 flex justify-center">
<NetBirdIcon size={48} />
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-white mb-2">
Welcome to NetBird
</h1>
<p className="dark:text-nb-gray-400 text-nb-gray-500 text-base">
You&apos;ve been invited by <span className="dark:text-white text-nb-gray-900 font-medium">{inviteInfo.invited_by}</span> to join the network. Set your password to complete your account setup.
</p>
</div>
<div className="bg-nb-gray-930 border border-nb-gray-900 rounded-lg p-6 mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-nb-gray-900 rounded-full flex items-center justify-center">
<User2 className="w-5 h-5 text-nb-gray-400" />
</div>
<div>
<div className="text-white font-medium">{inviteInfo.name}</div>
<div className="text-nb-gray-400 text-sm flex items-center gap-1">
<Mail className="w-3 h-3" />
{inviteInfo.email}
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{password && (
<div className="mt-2 space-y-1">
<PasswordRule met={hasMinLength} text="At least 8 characters" />
<PasswordRule met={hasUppercase} text="One uppercase letter" />
<PasswordRule met={hasLowercase} text="One lowercase letter" />
<PasswordRule met={hasNumber} text="One number" />
<PasswordRule met={hasSpecialChar} text="One special character (!@#$%^&*)" />
</div>
)}
</div>
<div>
<Input
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
customPrefix={
<KeyRound size={16} className="text-nb-gray-400" />
}
/>
{confirmPassword && !passwordsMatch && (
<p className="text-xs text-red-500 mt-1">
Passwords do not match
</p>
)}
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-3">
<p className="text-sm text-red-500">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
className="w-full"
disabled={!canSubmit}
>
{submitting ? "Creating Account..." : "Create Account"}
</Button>
</form>
</div>
<p className="text-center text-xs text-nb-gray-500">
Invite expires on {dayjs(inviteInfo.expires_at).format("D MMMM, YYYY [at] h:mm A")}
</p>
</div>
</div>
);
}
function PasswordRule({ met, text }: { met: boolean; text: string }) {
return (
<div className="flex items-center gap-2 text-xs">
{met ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<AlertCircle className="w-3 h-3 text-nb-gray-500" />
)}
<span className={met ? "text-green-500" : "text-nb-gray-500"}>{text}</span>
</div>
);
}

View File

@@ -1,7 +1,22 @@
"use client";
import InstanceSetupWizard from "@/modules/instance-setup/InstanceSetupWizard";
import { useInstanceSetup } from "@/contexts/InstanceSetupProvider";
import { useRouter } from "next/navigation";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useEffect } from "react";
export default function SetupPage() {
return <InstanceSetupWizard />;
const { setupRequired, loading } = useInstanceSetup();
const router = useRouter();
useEffect(() => {
if (!loading && !setupRequired) router.replace("/peers");
}, [loading, setupRequired]);
return loading || !setupRequired ? (
<FullScreenLoading />
) : (
<InstanceSetupWizard />
);
}

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function DNSZoneIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
fillRule="evenodd"
d="M5 5a2 2 0 0 0-2 2v3a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V7a2 2 0 0 0-2-2H5Zm9 2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17ZM3 17v-3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm11-2a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H14Zm3 0a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H17Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -23,6 +23,7 @@ export const idpIcon = (
zitadel: <ZitadelIcon size={size} />,
authentik: <AuthentikIcon size={size} />,
keycloak: <KeycloakIcon size={size} />,
adfs: <MicrosoftIcon size={size} />,
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
};

View File

@@ -0,0 +1,26 @@
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type Props = {
os: string;
};
export const PeerOSIcon = ({ os }: Props) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { PeerOSIcon } from "./PeerOSIcon";
import { ResourceIcon } from "./ResourceIcon";
type Props = {
peer?: Peer;
resource?: NetworkResource;
};
export const PeerOrResourceIcon = ({ peer, resource }: Props) => {
return (
<>
{peer && <PeerOSIcon os={peer.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
</>
);
};

View File

@@ -0,0 +1,20 @@
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
type Props = {
type: "domain" | "host" | "subnet";
size?: number;
};
export const ResourceIcon = ({ type, size = 15 }: Props) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};

View File

@@ -0,0 +1,19 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ReverseProxyIcon(props: IconProps) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
fill={"currentColor"}
>
<path
fill={"currentColor"}
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
/>
</svg>
);
}

View File

@@ -0,0 +1,30 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function SlackIcon(props: Readonly<IconProps>) {
return (
<svg
width="127"
height="127"
viewBox="0 0 127 127"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
>
<path
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
fill="#E01E5A"
/>
<path
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
fill="#36C5F0"
/>
<path
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
fill="#2EB67D"
/>
<path
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
fill="#ECB22E"
/>
</svg>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -6,9 +6,8 @@ import {
OidcProvider,
} from "@axa-fr/react-oidc";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useLocalStorage } from "@hooks/useLocalStorage";
import loadConfig, { buildExtras } from "@utils/config";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { OIDCError } from "@/auth/OIDCError";
import { SecureProvider } from "@/auth/SecureProvider";
@@ -43,33 +42,6 @@ export default function OIDCProvider({ children }: Props) {
const [mounted, setMounted] = useState(false);
const router = useRouter();
const path = usePathname();
const params = useSearchParams()?.toString();
const [, setQueryParams] = useLocalStorage("netbird-query-params", params);
useEffect(() => {
const validParams = [
"tab",
"search",
"id",
"invite",
"utm_source",
"utm_medium",
"utm_content",
"utm_campaign",
"hs_id",
"page",
"page_size",
"user",
"port",
];
try {
const urlParams = new URLSearchParams(params);
if (validParams.some((param) => urlParams.has(param))) {
setQueryParams(params);
}
} catch (e) {}
}, []);
const withCustomHistory = () => {
return {
@@ -104,7 +76,9 @@ export default function OIDCProvider({ children }: Props) {
// We bypass authentication for pages that do not require auth.
// E.g., when we just want to show installation steps for public.
// Or the instance setup wizard for first-time setup.
if (path === "/install" || path === "/setup") return children;
// Or the invite acceptance page for new users.
if (path === "/install" || path === "/setup" || path?.startsWith("/invite"))
return children;
return mounted && providerConfig ? (
<OidcProvider

View File

@@ -3,6 +3,24 @@ import { usePathname } from "next/navigation";
import * as React from "react";
import { useEffect } from "react";
const QUERY_PARAMS_KEY = "netbird-query-params";
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp"];
const VALID_PARAMS = [
"tab",
"search",
"id",
"invite",
"utm_source",
"utm_medium",
"utm_content",
"utm_campaign",
"hs_id",
"page",
"page_size",
"user",
"port",
];
type Props = {
children: React.ReactNode;
};
@@ -10,6 +28,22 @@ export const SecureProvider = ({ children }: Props) => {
const { isAuthenticated, login } = useOidc();
const currentPath = usePathname();
useEffect(() => {
if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) {
localStorage.removeItem(QUERY_PARAMS_KEY);
} else if (!isAuthenticated) {
try {
const params = window.location.search.substring(1);
if (params) {
const urlParams = new URLSearchParams(params);
if (VALID_PARAMS.some((param) => urlParams.has(param))) {
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(params));
}
}
} catch (e) {}
}
}, [isAuthenticated, currentPath]);
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = undefined;
if (!isAuthenticated) {

View File

@@ -2,6 +2,7 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@utils/helpers";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
className,
)}
{...props}
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className=" pt-0">{children}</div>
</AccordionPrimitive.Content>
));
>(({ className, children }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const el = wrapperRef.current?.closest("[data-state]");
if (!el) return;
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef}>
<motion.div
ref={ref}
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn("overflow-hidden text-sm", className)}
>
<div className="pt-0">{children}</div>
</motion.div>
</div>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -1,90 +0,0 @@
import { Checkbox } from "@components/Checkbox";
import { Input } from "@components/Input";
import { Popover, PopoverContent } from "@components/Popover";
import { useElementSize } from "@hooks/useElementSize";
import { Anchor } from "@radix-ui/react-popover";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { FaWindows } from "react-icons/fa6";
type Props = {};
export const AutoCompleteInput = ({}: Props) => {
const [open, setOpen] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null);
const [elementWidth, { width }] = useElementSize<HTMLDivElement>();
useEffect(() => {
const input = inputRef.current;
const onFocus = () => {
setOpen(true);
};
if (input) {
inputRef.current.addEventListener("focus", onFocus);
}
return () => {
if (input) {
inputRef.current.removeEventListener("focus", onFocus);
}
};
}, []);
return (
<div className={"z-10 relative"}>
<Popover modal={false} open={open} onOpenChange={setOpen}>
<Anchor ref={elementWidth}>
<Input
placeholder={"11"}
ref={inputRef}
maxWidthClass={"max-w-[200px]"}
customPrefix={
<div className={"flex items-center gap-2"}>
<Checkbox></Checkbox>
<div
className={"flex gap-2 items-center text-sm text-nb-gray-200"}
>
<FaWindows className={"text-sky-600 text-lg"} />
Windows
</div>
</div>
}
/>
</Anchor>
<PopoverContent
hideWhenDetached={false}
className="w-full p-0 shadow-sm shadow-nb-gray-950"
style={{
width: width,
}}
forceMount={true}
align="start"
side={"bottom"}
sideOffset={10}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onPointerDownOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
onFocusOutside={(event) => {
event.preventDefault();
if (event.target !== inputRef.current) {
setOpen(false);
}
}}
></PopoverContent>
</Popover>
</div>
);
};

View File

@@ -23,6 +23,7 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
@@ -45,6 +46,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
lightGray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],

View File

@@ -1,6 +1,6 @@
import { cn } from "@utils/helpers";
import { ChevronRightIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import React from "react";
type Props = {
@@ -25,8 +25,6 @@ export const Item = ({
active,
disabled = false,
}: ItemProps) => {
const router = useRouter();
return (
<div
className={cn(
@@ -45,7 +43,13 @@ export const Item = ({
)}
>
{icon && icon}
{href ? <span onClick={() => router.push(href)}>{label}</span> : label}
{href ? (
<Link href={href} data-cy={"breadcrumb-item"}>
{label}
</Link>
) : (
label
)}
</div>
</div>
);

View File

@@ -54,7 +54,7 @@ export const buttonVariants = cva(
dotted: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 ",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-nb-gray-900/50",
],
tertiary: [
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
@@ -73,9 +73,13 @@ export const buttonVariants = cva(
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
"",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
],
danger: [
"", // TODO - add danger button styles for light mode

View File

@@ -19,6 +19,8 @@ export const calloutVariants = cva(
default: "bg-nb-gray-900/60 border-nb-gray-800/80 text-nb-gray-300",
warning: "bg-netbird-500/10 border-netbird-400/20 text-netbird-150",
info: "bg-sky-400/10 border-sky-400/20 text-sky-100",
success: "bg-green-400/15 border-green-400/20 text-green-100",
error: "bg-red-500/10 border-red-400/20 text-red-100",
},
},
},

View File

@@ -50,11 +50,11 @@ function CardListItem({
return (
<li
className={cn(
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
className,
)}
>
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
<div className={"flex flex-col gap-2"}>
<CardTextItem
label={label}
@@ -100,7 +100,7 @@ const CardTextItem = ({
return (
<div
className={cn(
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>

View File

@@ -0,0 +1,129 @@
import useCopyToClipboard from "@hooks/useCopyToClipboard";
import { cn } from "@utils/helpers";
import { Copy } from "lucide-react";
import React from "react";
type CardTableProps = {
children: React.ReactNode;
className?: string;
};
function CardTable({ children, className }: CardTableProps) {
return (
<div
className={cn(
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
className,
)}
>
<table className={"w-full border-collapse text-sm"}>{children}</table>
</div>
);
}
function CardTableHeader({ children, className }: CardTableProps) {
return (
<thead>
<tr
className={cn(
"border-b border-nb-gray-900",
className,
)}
>
{children}
</tr>
</thead>
);
}
type CardTableHeaderCellProps = {
children: React.ReactNode;
width?: number;
className?: string;
};
function CardTableHeaderCell({
children,
width,
className,
}: CardTableHeaderCellProps) {
return (
<th
className={cn(
"px-4 py-2.5 text-left text-sm font-normal",
className,
)}
style={width ? { width } : undefined}
>
{children}
</th>
);
}
function CardTableBody({ children, className }: CardTableProps) {
return <tbody className={className}>{children}</tbody>;
}
type CardTableRowProps = {
children: React.ReactNode;
className?: string;
};
function CardTableRow({ children, className }: CardTableRowProps) {
return (
<tr
className={cn(
"border-b border-nb-gray-900 last:border-b-0",
className,
)}
>
{children}
</tr>
);
}
type CardTableCellProps = {
children: React.ReactNode;
copy?: boolean;
copyText?: string;
width?: number;
className?: string;
};
function CardTableCell({
children,
copy = false,
copyText,
width,
className,
}: CardTableCellProps) {
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
return (
<td
className={cn("px-4 py-3", className)}
style={width ? { width } : undefined}
>
<div
className={cn(
"text-nb-gray-400 text-sm flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy &&
copyToClipBoard(`${copyText} has been copied to clipboard.`)
}
>
{children}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
</td>
);
}
CardTable.Header = CardTableHeader;
CardTable.HeaderCell = CardTableHeaderCell;
CardTable.Body = CardTableBody;
CardTable.Row = CardTableRow;
CardTable.Cell = CardTableCell;
export default CardTable;

View File

@@ -9,6 +9,11 @@ type Props = {
iconAlignment?: "left" | "right";
className?: string;
alwaysShowIcon?: boolean;
// Overrides the rendered innerText as the value written to the
// clipboard. Use when the displayed text is an abbreviation of the
// canonical value (e.g. the short DNS label) but the user should
// still get the full string when they click.
textToCopy?: string;
};
export default function CopyToClipboardText({
@@ -17,16 +22,13 @@ export default function CopyToClipboardText({
iconAlignment = "right",
className,
alwaysShowIcon = false,
textToCopy,
}: Props) {
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
const [wrapper, copyToClipboard, copied] = useCopyToClipboard(textToCopy);
return (
<div
className={cn(
"flex gap-2 items-center group cursor-pointer transition-all hover:underline underline-offset-4 decoration-dashed decoration-nb-gray-600",
!copied && "hover:opacity-90",
className,
)}
className={cn("flex gap-2 items-center group cursor-pointer", className)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
@@ -34,27 +36,34 @@ export default function CopyToClipboardText({
}}
ref={wrapper}
>
{children}
<span className="relative truncate">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover:border-nb-gray-500 pointer-events-none" />
</span>
{copied ? (
<span
className={cn(
"shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
)}
>
<CheckIcon
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
"text-nb-gray-100 group-hover:opacity-100",
!copied && "hidden",
!alwaysShowIcon && !copied && "opacity-0",
)}
size={11}
/>
) : (
<CopyIcon
className={cn(
"text-nb-gray-100 group-hover:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
"text-nb-gray-100 group-hover:opacity-100",
copied && "hidden",
!alwaysShowIcon && "opacity-0",
)}
size={11}
/>
)}
</span>
</div>
);
}

View File

@@ -15,6 +15,7 @@ interface Props {
value?: DateRange;
onChange?: (range: DateRange | undefined) => void;
className?: string;
disabled?: boolean;
}
const defaultRanges = {
@@ -61,6 +62,7 @@ export function DatePickerWithRange({
className,
value,
onChange,
disabled = false,
}: Readonly<Props>) {
const isActive = useMemo(() => {
return {
@@ -120,6 +122,7 @@ export function DatePickerWithRange({
<Button
id="date"
variant={"secondary"}
disabled={disabled}
className={cn("max-w-[260px] justify-start text-left font-normal")}
>
<CalendarIcon size={16} className={"shrink-0"} />

View File

@@ -0,0 +1,95 @@
import TruncatedText from "@components/ui/TruncatedText";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { PeerOSIcon } from "@/assets/icons/PeerOSIcon";
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
address?: string;
description?: string;
};
export const DeviceCard = ({
device,
resource,
className,
address,
description,
}: DeviceCardProps) => {
if (!device && !resource) return null;
const descriptionText = useMemo(() => {
return description !== undefined
? description
: address || device?.ip || resource?.address;
}, [description, address, device]);
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-200 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[230px]",
!descriptionText && "py-2",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-900 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device ? (
<PeerOSIcon os={device.os} />
) : resource?.type ? (
<ResourceIcon type={resource.type} />
) : null}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div
className={
"flex flex-col gap-0 justify-center top-[0.15rem] leading-tight relative"
}
>
<span
className={
"font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
{descriptionText && (
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
)}
</div>
</div>
);
};

View File

@@ -93,25 +93,53 @@ const DropdownMenuItem = React.forwardRef<
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "danger";
href?: string;
target?: string;
rel?: string;
}
>(({ className, inset, variant = "default", onClick, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
>(
(
{
className,
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
/>
));
inset,
variant = "default",
onClick,
href,
target,
rel,
...props
},
ref,
) => {
return (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex select-none items-center rounded-md pr-2 pl-3 py-1.5 text-sm outline-none",
"transition-colors focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 cursor-pointer ",
inset && "pl-8",
menuItemVariants({ variant }),
className,
)}
onClick={(e) => {
if (href) return;
e.preventDefault();
e.stopPropagation();
onClick && onClick(e);
}}
{...props}
>
{href ? (
<a href={href} target={target} rel={rel}>
{props.children}
</a>
) : (
props.children
)}
</DropdownMenuPrimitive.Item>
);
},
);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<

View File

@@ -0,0 +1,45 @@
import { cn } from "@utils/helpers";
import { ExternalLinkIcon } from "lucide-react";
import React from "react";
type Props = {
href: string;
children: React.ReactNode;
iconAlignment?: "left" | "right";
className?: string;
alwaysShowIcon?: boolean;
};
export default function ExternalLinkText({
href,
children,
iconAlignment = "right",
className,
alwaysShowIcon = false,
}: Props) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex gap-2 items-center group/link cursor-pointer hover:opacity-90",
className,
)}
onClick={(e) => e.stopPropagation()}
>
<span className="relative">
{children}
<span className="absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/link:border-nb-gray-500 pointer-events-none" />
</span>
<ExternalLinkIcon
className={cn(
"text-nb-gray-100 group-hover/link:opacity-100 shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
!alwaysShowIcon && "opacity-0",
)}
size={12}
/>
</a>
);
}

View File

@@ -8,7 +8,7 @@ import React from "react";
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
default: ["px-5 py-4 border rounded-md"],
blank: null,
},
state: {
@@ -45,6 +45,8 @@ interface Props extends FancyToggleSwitchVariants {
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
@@ -57,6 +59,8 @@ export default function FancyToggleSwitch({
dataCy,
className,
variant = "default",
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
@@ -87,8 +91,8 @@ export default function FancyToggleSwitch({
)}
>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>
@@ -99,7 +103,11 @@ export default function FancyToggleSwitch({
/>
</div>
</div>
<div>{children && value ? children : null}</div>
{children && value ? (
<div className="mt-4" onClick={(e) => e.stopPropagation()}>
{children}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import FullTooltip from "@components/FullTooltip";
import { HelpCircle } from "lucide-react";
import { cn } from "@utils/helpers";
import { TooltipVariants } from "@components/Tooltip";
type Props = {
content: React.ReactNode;
children?: React.ReactNode;
interactive?: boolean;
className?: string;
triggerClassName?: string;
align?: "start" | "center" | "end";
side?: "top" | "right" | "bottom" | "left";
alignOffset?: number;
sideOffset?: number;
iconSize?: number;
delayDuration?: number;
} & TooltipVariants;
export const HelpTooltip = ({
content,
children,
interactive = false,
className,
variant = "default",
triggerClassName,
align = "start",
side = "top",
alignOffset = 0,
sideOffset,
iconSize = 12,
delayDuration = 300,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={side}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
delayDuration={delayDuration}
variant={variant}
className={
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
}
content={
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
}
>
{children ? (
children
) : (
<span
className={cn(
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
triggerClassName,
)}
>
<HelpCircle
size={iconSize}
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
/>
</span>
)}
</FullTooltip>
</>
);
};

View File

@@ -17,7 +17,7 @@ export interface InputProps
icon?: React.ReactNode;
error?: string;
errorTooltip?: boolean;
errorTooltipPosition?: "top" | "top-right";
errorTooltipPosition?: "top" | "top-right" | "bottom";
prefixClassName?: string;
showPasswordToggle?: boolean;
}
@@ -127,6 +127,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
suffix && "!pr-16",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
className,
)}
/>

View File

@@ -9,17 +9,34 @@ const labelVariants = cva(
"text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2",
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className, "select-none")}
{...props}
/>
));
type LabelProps = React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants> & {
as?: "label" | "div";
};
const Label = React.forwardRef<HTMLElement, LabelProps>(
({ className, as = "label", children, ...props }, ref) => {
const classes = cn(labelVariants(), className, "select-none");
if (as === "div") {
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={classes}>
{children}
</div>
);
}
return (
<LabelPrimitive.Root
ref={ref as React.Ref<HTMLLabelElement>}
className={classes}
{...props}
>
{children}
</LabelPrimitive.Root>
);
},
);
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -6,24 +6,26 @@ export const ListItem = ({
label,
value,
className,
children,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
<div className={"text-nb-gray-300"}>{value}</div>
{children}
</div>
);
};

View File

@@ -3,42 +3,50 @@ import SquareIcon from "@components/SquareIcon";
import AddPeerButton from "@components/ui/AddPeerButton";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ExternalLinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import * as React from "react";
import PeerIcon from "@/assets/icons/PeerIcon";
type Props = {
showBackground?: boolean;
showBackground?: boolean;
// When set, tailors the empty-state copy and threads isUserDevice
// through AddPeerButton so the right Install NetBird flow opens:
// true → User Devices empty state (browser/SSO flow, mobile tabs).
// false → Servers empty state (setup-key flow, no mobile tabs).
// undefined → legacy/global empty state (no kind preference).
isUserDevice?: boolean;
};
export const NoPeersGettingStarted = ({ showBackground = true }) => {
return (
<GetStartedTest
showBackground={showBackground}
icon={
<SquareIcon
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Get Started with NetBird"}
description={
"It looks like you don't have any connected machines.\n" +
"Get started by adding one to your network."
}
button={<AddPeerButton />}
learnMore={
<>
Learn more in our{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started"}
target={"_blank"}
>
Getting Started Guide
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
);
export const NoPeersGettingStarted = ({
showBackground = true,
isUserDevice,
}: Readonly<Props>) => {
const t = useTranslations("peers");
return (
<GetStartedTest
showBackground={showBackground}
icon={
<SquareIcon
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={t("getStarted")}
description={t("getStartedDescription")}
button={<AddPeerButton isUserDevice={isUserDevice} />}
learnMore={
<>
{t("learnMoreInOur")}{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/getting-started"}
target={"_blank"}
>
{t("gettingStartedGuide")}
<ExternalLinkIcon size={12} />
</InlineLink>
</>
}
/>
);
};

View File

@@ -2,11 +2,11 @@ import { IconCircleX } from "@tabler/icons-react";
import type { ErrorResponse } from "@utils/api";
import { cn } from "@utils/helpers";
import classNames from "classnames";
import { AnimatePresence, motion } from "framer-motion";
import { motion } from "framer-motion";
import { CheckIcon, Loader2, XIcon } from "lucide-react";
import * as React from "react";
import { useEffect, useState } from "react";
import toast, { type Toast } from "react-hot-toast";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
export interface NotifyProps<T> {
title: string;
@@ -18,38 +18,95 @@ export interface NotifyProps<T> {
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
showOnlyError?: boolean;
errorMessages?: ErrorResponse[];
}
interface NotificationProps<T> extends NotifyProps<T> {
t: Toast;
toastId: string | number;
}
export default function Notification<T>({
title,
description,
icon,
backgroundColor,
t,
toastId,
promise,
loadingTitle,
loadingMessage,
duration = 3500,
preventSuccessToast = false,
showOnlyError = false,
errorMessages,
}: NotificationProps<T>) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(!!promise);
const [readyToDismiss, setReadyToDismiss] = useState(!promise);
const [toastDuration] = useState(duration);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const remainingRef = useRef(duration);
const startTimeRef = useRef<number | null>(null);
const [preventSuccess, setPreventSuccess] = useState(false);
const startTimer = useCallback(() => {
if (timerRef.current) return;
startTimeRef.current = Date.now();
timerRef.current = setTimeout(
() => {
timerRef.current = null;
toast.dismiss(toastId);
},
Math.max(0, remainingRef.current),
);
}, [toastId]);
const closeToast = () => {
setTimeout(() => {
setLoading(false);
toast.dismiss(t.id);
}, toastDuration);
};
const pauseTimer = useCallback(() => {
if (!timerRef.current || !startTimeRef.current) return;
clearTimeout(timerRef.current);
timerRef.current = null;
remainingRef.current = Math.max(
0,
remainingRef.current - (Date.now() - startTimeRef.current),
);
}, []);
const notificationRef = useRef<HTMLDivElement>(null);
// Watch for sonner's expanded state to pause/resume timer
useEffect(() => {
if (!readyToDismiss) return;
const toastEl = notificationRef.current?.closest(
"[data-sonner-toast]",
) as HTMLElement | null;
if (!toastEl) {
startTimer();
return;
}
const observer = new MutationObserver(() => {
const expanded = toastEl.getAttribute("data-expanded") === "true";
if (expanded) {
pauseTimer();
} else {
startTimer();
}
});
observer.observe(toastEl, {
attributes: true,
attributeFilter: ["data-expanded"],
});
// Start immediately if not expanded
const expanded = toastEl.getAttribute("data-expanded") === "true";
if (!expanded) startTimer();
return () => {
observer.disconnect();
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [readyToDismiss, toastId, startTimer, pauseTimer]);
useEffect(() => {
// Run the promise
@@ -57,8 +114,11 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
closeToast();
if (preventSuccessToast) setPreventSuccess(true);
if (showOnlyError || preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
}
})
.catch((e) => {
const err = e as ErrorResponse;
@@ -78,78 +138,79 @@ export default function Notification<T>({
}
setLoading(false);
closeToast();
setReadyToDismiss(true);
});
} else {
closeToast();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AnimatePresence>
{t.visible && !preventSuccess && (
<motion.div
initial={{ opacity: 1, y: -50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -50 }}
className={cn(
"max-w-md w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
)}
>
<div className={"flex items-center gap-4"}>
<div
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
? "bg-nb-gray-900"
: error
? "bg-red-500"
: backgroundColor || "bg-green-500",
)}
>
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}
</div>
<div className={"flex flex-col text-sm"}>
<p>
<span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p
className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}
>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
const hideUntilError = showOnlyError && loading && !error;
if (hideUntilError) return null;
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(t.id)}
return (
<motion.div
ref={notificationRef}
initial={{ y: -20 }}
animate={{ y: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 20 }}
data-toast-notification
className="w-[28rem] pb-2"
>
<div
className={cn(
"w-full justify-between bg-white dark:bg-nb-gray-940 shadow-lg rounded-md px-4 py-2.5 pointer-events-auto flex border dark:border-nb-gray-900",
)}
>
<div className={"flex items-center gap-4"}>
<div
className={classNames(
"h-8 w-8 shadow-sm text-white flex items-center justify-center rounded-md shrink-0",
loading
? "bg-nb-gray-900"
: error
? "bg-red-500"
: backgroundColor || "bg-green-500",
)}
>
<div
className={
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
}
>
<XIcon size={16} />
</div>
</button>
</motion.div>
)}
</AnimatePresence>
{loading ? (
<Loader2 size={14} className={"animate-spin"} />
) : error ? (
<IconCircleX size={24} />
) : (
icon || <CheckIcon size={14} />
)}
</div>
<div className={"flex flex-col text-sm"}>
<p>
<span className={"font-semibold"}>
{loading ? loadingTitle || title : title}
</span>
</p>
<p className={"text-xs dark:text-nb-gray-300 text-gray-600 mt-0.5"}>
{loading ? loadingMessage : error ? error : description}
</p>
</div>
</div>
<button
className="flex dark:border-nb-gray-900 items-center cursor-pointer group"
onClick={() => toast.dismiss(toastId)}
>
<div
className={
"p-2 hover:bg-nb-gray-900 rounded-md opacity-50 group-hover:opacity-100"
}
>
<XIcon size={16} />
</div>
</button>
</div>
</motion.div>
);
}
export function notify<T>(props: NotifyProps<T>) {
return toast.custom((t) => <Notification {...props} t={t} />, {
return toast.custom((id) => <Notification {...props} toastId={id} />, {
duration: Infinity,
});
}

View File

@@ -30,7 +30,10 @@ import {
MonitorSmartphoneIcon,
NetworkIcon,
SearchIcon,
ServerIcon,
ShieldCheck,
WorkflowIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { Fragment, useEffect, useMemo, useState } from "react";
@@ -40,10 +43,21 @@ import { useElementSize } from "@/hooks/useElementSize";
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import TruncatedText from "@components/ui/TruncatedText";
type PeerGroupSelectorTab = "peers" | "groups" | "resources" | "clusters";
export type ClusterOption = {
/** Cluster apex domain (e.g. "eu.proxy.netbird.io"); also the value
* that downstream code stores in target_id / proxy_cluster. */
domain: string;
/** Human-friendly label; falls back to domain. */
label?: string;
};
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
@@ -68,14 +82,29 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
showPeerCounter?: boolean;
hideGroupsTab?: boolean;
tabOrder?: PeerGroupSelectorTab[];
closeOnSelect?: boolean;
/** Show a Clusters tab. Off by default; flip on with clusters list. */
showClusters?: boolean;
/** Clusters offered in the Clusters tab. When empty the tab is hidden. */
clusters?: ClusterOption[];
/** Currently-selected cluster (domain string), if any. */
selectedCluster?: string;
/** Called when the user picks (or clears) a cluster. */
onClusterChange?: (cluster?: string) => void;
resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string;
placeholder?: React.ReactNode | string;
customTrigger?: React.ReactNode;
align?: "start" | "end";
side?: "top" | "bottom";
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
onChange,
@@ -94,6 +123,10 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
showPeerCounter = true,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
@@ -102,11 +135,26 @@ export function PeerGroupSelector({
side = "bottom",
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
additionalResources,
policies,
showClusters = false,
clusters,
selectedCluster,
onClusterChange,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const resources = useMemo(() => {
if (!additionalResources?.length) return fetchedResources;
const additional = additionalResources.filter(
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
);
return [...(fetchedResources || []), ...additional];
}, [fetchedResources, additionalResources]);
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
@@ -229,7 +277,13 @@ export function PeerGroupSelector({
const [slice, setSlice] = useState(10);
const [tab, setTab] = useState("groups");
const getDefaultTab = (): PeerGroupSelectorTab => {
if (tabOrder?.[0]) return tabOrder[0];
if (hideGroupsTab) return showPeers ? "peers" : "resources";
return "groups";
};
const [tab, setTab] = useState<PeerGroupSelectorTab>(getDefaultTab);
useEffect(() => {
if (open) {
@@ -258,10 +312,30 @@ export function PeerGroupSelector({
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
if (tab === "peers") return "Search peer by name or ip...";
if (tab === "clusters") return "Search cluster...";
return "Search...";
}, [tab, placeholderForSearch]);
const filteredClusters = useMemo(() => {
if (!clusters || clusters.length === 0) return [];
if (!search) return clusters;
const q = search.toLowerCase();
return clusters.filter(
(c) =>
c.domain.toLowerCase().includes(q) ||
c.label?.toLowerCase().includes(q),
);
}, [clusters, search]);
const selectCluster = (cluster?: ClusterOption) => {
onClusterChange?.(cluster?.domain);
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
const selectResource = (resource?: NetworkResource) => {
onResourceChange?.(
resource
@@ -272,6 +346,9 @@ export function PeerGroupSelector({
: undefined,
);
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
const selectPeer = (peer?: Peer) => {
@@ -281,6 +358,9 @@ export function PeerGroupSelector({
type: "peer",
});
onChange([]);
if (closeOnSelect) {
setOpen(false);
}
};
return (
@@ -306,7 +386,7 @@ export function PeerGroupSelector({
"min-h-[46px] w-full relative items-center group",
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
"disabled:pointer-events-none disabled:opacity-30 transition-all",
"disabled:pointer-events-none disabled:opacity-60 transition-all",
)}
disabled={disabled}
data-cy={dataCy}
@@ -320,7 +400,14 @@ export function PeerGroupSelector({
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
resource={
resources?.find((r) => r.id === resource.id) ??
({
id: resource.id,
name: resource.id,
type: resource.type,
} as NetworkResource)
}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
@@ -330,6 +417,36 @@ export function PeerGroupSelector({
showX={true}
/>
)}
{selectedCluster && (
<Badge
useHover={true}
data-cy={"cluster-badge"}
variant={"gray-ghost"}
className={
"py-[3px] transition-all group whitespace-nowrap"
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClusterChange?.(undefined);
}}
>
<ServerIcon size={12} className={"shrink-0"} />
<TruncatedText
text={
(clusters ?? []).find((c) => c.domain === selectedCluster)
?.label ?? selectedCluster
}
maxChars={20}
/>
<XIcon
size={12}
className={
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
}
/>
</Badge>
)}
{values.map((group) => {
return (
<div
@@ -373,8 +490,10 @@ export function PeerGroupSelector({
);
})}
{values.length == 0 && !resource && (
<span className={"pl-1"}>{placeholder}</span>
{values.length == 0 && !resource && !selectedCluster && (
<span className={cn(typeof placeholder === "string" && "pl-1")}>
{placeholder}
</span>
)}
</div>
@@ -438,11 +557,21 @@ export function PeerGroupSelector({
</div>
</div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
<Tabs
defaultValue={
tabOrder?.[0] ??
(hideGroupsTab ? (showPeers ? "peers" : "resources") : "groups")
}
value={tab}
onValueChange={(v) => setTab(v as PeerGroupSelectorTab)}
>
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
showClusters={showClusters}
hideGroupsTab={hideGroupsTab}
tabOrder={tabOrder}
/>
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
@@ -481,9 +610,6 @@ export function PeerGroupSelector({
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
const isDisabled = disabledGroups
? disabledGroups?.findIndex(
(g) => g.id === option.id,
@@ -535,12 +661,21 @@ export function PeerGroupSelector({
<ResourcesCounter group={option} />
)}
{policies && (
<PolicyCounter
group={option}
policies={policies}
/>
)}
<div className={"flex gap-4 items-center"}>
{!users ? (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
showPeerCounter && (
<PeerCounter
group={option}
showResourceCounter={showResourceCounter}
/>
)
) : (
<UsersCounter
group={option}
@@ -562,7 +697,11 @@ export function PeerGroupSelector({
<TabsContent value={"resources"} className={"p-0 my-0"}>
<ResourcesList
search={search}
resources={resources}
resources={
resourceIds
? resources?.filter((r) => resourceIds.includes(r.id))
: resources
}
isLoading={isResourcesLoading}
value={resource}
onChange={selectResource}
@@ -580,6 +719,15 @@ export function PeerGroupSelector({
/>
</TabsContent>
)}
{showClusters && (
<TabsContent value={"clusters"} className={"p-0 my-0"}>
<ClustersList
clusters={filteredClusters}
value={selectedCluster}
onChange={selectCluster}
/>
</TabsContent>
)}
</Tabs>
</CommandList>
</Command>
@@ -592,60 +740,113 @@ const TabTriggers = ({
searchRef,
showResources = false,
showPeers = false,
showClusters = false,
hideGroupsTab = false,
tabOrder,
}: {
searchRef: React.MutableRefObject<HTMLInputElement | null>;
showResources?: boolean;
showPeers?: boolean;
showClusters?: boolean;
hideGroupsTab?: boolean;
tabOrder?: PeerGroupSelectorTab[];
}) => {
if (!showResources && !showPeers) return null;
const tabCount =
(!hideGroupsTab ? 1 : 0) +
(showResources ? 1 : 0) +
(showPeers ? 1 : 0) +
(showClusters ? 1 : 0);
if (tabCount <= 1) return null;
const groupsTab = !hideGroupsTab && (
<TabsTrigger
key="groups"
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
);
const resourcesTab = showResources && (
<TabsTrigger
key="resources"
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
);
const peersTab = showPeers && (
<TabsTrigger
key="peers"
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
);
const clustersTab = showClusters && (
<TabsTrigger
key="clusters"
value={"clusters"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<ServerIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Proxy Clusters
</TabsTrigger>
);
const tabMap: Record<PeerGroupSelectorTab, React.ReactNode> = {
groups: groupsTab,
peers: peersTab,
resources: resourcesTab,
clusters: clustersTab,
};
if (tabOrder) {
return (
<TabsList justify={"start"} className={"px-3"}>
{tabOrder.map((tab) => tabMap[tab])}
</TabsList>
);
}
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
value={"groups"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<FolderGit2
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Groups
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
{groupsTab}
{resourcesTab}
{peersTab}
{clustersTab}
</TabsList>
);
};
@@ -723,6 +924,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
) : null;
};
const PolicyCounter = ({
group,
policies,
}: {
group: Group;
policies: Policy[];
}) => {
const count = useMemo(() => {
if (!group.id) return 0;
return policies.filter((policy) => {
const destinations = policy.rules?.[0]?.destinations as
| (Group | string)[]
| undefined;
return destinations?.some((d) =>
typeof d === "string" ? d === group.id : d.id === group.id,
);
}).length;
}, [group.id, policies]);
if (count === 0) return null;
return (
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
}
>
<ShieldCheck size={14} className={"shrink-0"} />
{count} {count === 1 ? "Policy" : "Policies"}
</div>
);
};
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
@@ -787,6 +1021,7 @@ const ResourcesList = ({
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
return (
@@ -833,10 +1068,74 @@ const ResourcesList = ({
);
};
const ClustersList = ({
clusters,
value,
onChange,
}: {
clusters: ClusterOption[];
value?: string;
onChange: (cluster?: ClusterOption) => void;
}) => {
if (clusters.length === 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
No proxy clusters available. Go to{" "}
<InlineLink href={"/reverse-proxy/custom-domains"}>
Custom Domains
</InlineLink>{" "}
to configure one that supports private services.
</DropdownInfoText>
);
}
return (
<Radio defaultValue={value} name={"cluster"} value={value}>
<ScrollArea
className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}
>
{clusters.map((c) => (
<CommandItem
key={c.domain}
value={c.domain}
onSelect={() => onChange(c)}
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<Badge
useHover={false}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap h-7 px-2",
)}
onClick={(e) => {
e.preventDefault();
}}
>
<ServerIcon size={12} className={"shrink-0"} />
<TextWithTooltip text={c.label ?? c.domain} maxChars={32} />
</Badge>
</div>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{c.label && c.label !== c.domain ? c.domain : null}
<RadioItem value={c.domain} />
</div>
</CommandItem>
))}
</ScrollArea>
</Radio>
);
};
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
const PeersList = ({
@@ -896,6 +1195,7 @@ const PeersList = ({
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
estimatedItemHeight={42}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
@@ -904,7 +1204,7 @@ const PeersList = ({
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
useHover={false}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
@@ -915,7 +1215,7 @@ const PeersList = ({
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
<TruncatedText text={res?.name || ""} maxWidth={"270px"} />
</Badge>
</div>

View File

@@ -10,10 +10,10 @@ import { cn } from "@utils/helpers";
import { isRoutingPeerSupported } from "@utils/version";
import { sortBy, unionBy } from "lodash";
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react";
import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
@@ -31,7 +31,8 @@ const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
@@ -40,6 +41,7 @@ export function PeerSelector({
excludedPeers,
disabled = false,
}: MultiSelectProps) {
const t = useTranslations('peers');
const { data: peers } = useFetchApi<Peer[]>("/peers");
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -125,12 +127,11 @@ export function PeerSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPinIcon />
{value.ip}
</div>
</div>
) : (
<span>Select a peer...</span>
<span>{t('selectPeer')}</span>
)}
</div>
@@ -151,20 +152,20 @@ export function PeerSelector({
<DropdownInput
value={search}
onChange={setSearch}
placeholder={"Search for peers by name or ip..."}
placeholder={t('searchPlaceholder')}
/>
{unfilteredItems.length == 0 && !search && (
<div className={"max-w-xs mx-auto"}>
<DropdownInfoText>
{"No peers available to select."}
{t('noPeersAvailable')}
</DropdownInfoText>
</div>
)}
{filteredItems.length == 0 && search != "" && (
<DropdownInfoText>
There are no peers matching your search.
{t('noPeersMatching')}
</DropdownInfoText>
)}
@@ -194,9 +195,7 @@ export function PeerSelector({
className={"w-full flex items-center justify-between"}
content={
<div className={"max-w-[240px] text-xs"}>
Please update NetBird to at least{" "}
<span className={"text-netbird"}>v0.36.6</span> or later
to use this peer as a routing peer.
{t('updateRequired')}
</div>
}
>
@@ -239,7 +238,6 @@ export function PeerSelector({
!isSupported && "opacity-50",
)}
>
<MapPinIcon />
{option.ip}
</div>
</FullTooltip>

View File

@@ -0,0 +1,123 @@
"use client";
import { cn } from "@utils/helpers";
import React, {
ClipboardEvent,
forwardRef,
KeyboardEvent,
useImperativeHandle,
useRef,
} from "react";
export interface PinCodeInputRef {
focus: () => void;
}
interface Props {
value: string;
onChange: (value: string) => void;
length?: number;
disabled?: boolean;
className?: string;
type?: "text" | "password";
}
const PinCodeInput = forwardRef<PinCodeInputRef, Props>(function PinCodeInput(
{ value, onChange, length = 6, disabled = false, className, type = "text" },
ref,
) {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useImperativeHandle(ref, () => ({
focus: () => {
inputRefs.current[0]?.focus();
},
}));
const digits = value
.split("")
.concat(Array(length).fill(""))
.slice(0, length);
const handleChange = (index: number, digit: string) => {
if (!/^\d*$/.test(digit)) return;
const newDigits = [...digits];
newDigits[index] = digit.slice(-1);
const newValue = newDigits.join("").replace(/\s/g, "");
onChange(newValue);
if (digit && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
if (e.key === "ArrowLeft" && index > 0) {
inputRefs.current[index - 1]?.focus();
}
if (e.key === "ArrowRight" && index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
if (/^\d$/.test(e.key) && digits[index]) {
e.preventDefault();
const newDigits = [...digits];
newDigits[index] = e.key;
onChange(newDigits.join("").replace(/\s/g, ""));
if (index < length - 1) {
inputRefs.current[index + 1]?.focus();
}
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedData = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length);
onChange(pastedData);
const nextIndex = Math.min(pastedData.length, length - 1);
inputRefs.current[nextIndex]?.focus();
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
e.target.select();
};
return (
<div className={cn("flex gap-2", className)}>
{digits.map((digit, index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type={type}
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
className={cn(
"w-[42px] h-[42px] text-center text-sm rounded-md",
"dark:bg-nb-gray-900 border dark:border-nb-gray-700",
"dark:placeholder:text-neutral-400/70",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
"disabled:cursor-not-allowed disabled:opacity-40",
)}
/>
))}
</div>
);
});
export default PinCodeInput;

View File

@@ -188,7 +188,6 @@ export function PortSelector({
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-10",
)}
data-cy={"port-input"}
typeof={"number"}
ref={searchRef}
value={search}
onValueChange={setSearch}

View File

@@ -8,6 +8,7 @@ type Props = {
description: ReactNode;
icon?: ReactNode;
className?: string;
disabled?: boolean;
};
export const RadioCard = ({
@@ -16,15 +17,18 @@ export const RadioCard = ({
description,
className,
icon,
disabled,
}: Props) => {
return (
<RadioGroup.Item
value={value}
disabled={disabled}
className={cn(
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
"hover:bg-nb-gray-930",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
className,
)}
>

View File

@@ -1,7 +1,6 @@
import * as RadixRadioGroup from "@radix-ui/react-radio-group";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useState } from "react";
type Props = {
value: string;
@@ -10,10 +9,8 @@ type Props = {
};
export const RadioGroup = ({ value, onChange, children }: Props) => {
const [defaultValue] = useState(value);
return (
<RadixRadioGroup.Root
defaultValue={defaultValue}
value={value}
onValueChange={onChange}
className={

View File

@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, icon, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
icon ? "pl-2 pr-8" : "pl-8 pr-2",
description && "py-2",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{icon ? (
<>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</div>
</>
) : (
<>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</>
)}
{extra}
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -0,0 +1,116 @@
"use client";
import Button from "@components/Button";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import { PlusCircle, SquarePen } from "lucide-react";
import React from "react";
type SettingCardItemProps = {
label: React.ReactNode;
description: React.ReactNode;
enabled: boolean;
onClick: () => void;
disabled?: boolean;
};
function SettingCardItem({
label,
description,
enabled,
onClick,
disabled = false,
}: Readonly<SettingCardItemProps>) {
const handleClick = () => {
if (disabled) return;
onClick();
};
return (
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onClick={handleClick}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
className={cn(
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors",
disabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-nb-gray-935 cursor-pointer",
)}
>
<div className={"max-w-sm"}>
<div className="flex items-center gap-2">
<Label>{label}</Label>
{enabled && (
<SmallBadge
text="Enabled"
variant="green"
size="md"
className={"-top-[0.25rem]"}
/>
)}
</div>
<HelpText margin={false}>{description}</HelpText>
</div>
<div onClick={(e) => e.stopPropagation()}>
{enabled ? (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={handleClick}
disabled={disabled}
>
<SquarePen size={12} />
Edit
</Button>
) : (
<Button
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={handleClick}
disabled={disabled}
>
<PlusCircle size={12} />
Add
</Button>
)}
</div>
</div>
);
}
type SettingCardProps = {
children: React.ReactNode;
className?: string;
};
function SettingCard({ children, className }: Readonly<SettingCardProps>) {
return (
<div
className={cn(
"border-nb-gray-920 bg-nb-gray-800/10 border rounded-md",
className,
)}
>
{children}
</div>
);
}
const SettingCardWithItem = SettingCard as React.FC<Readonly<SettingCardProps>> & {
Item: typeof SettingCardItem;
};
SettingCardWithItem.Item = SettingCardItem;
export default SettingCardWithItem;

View File

@@ -5,7 +5,7 @@ import { cn } from "@utils/helpers";
import classNames from "classnames";
import { ChevronDownIcon, ChevronUpIcon, DotIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import React, { useMemo } from "react";
import React, { useEffect, useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
export type SidebarItemProps = {
@@ -36,8 +36,42 @@ export default function SidebarItem({
labelClassName,
visible,
}: Readonly<SidebarItemProps>) {
const [open, setOpen] = React.useState(false);
const path = usePathname();
// Hrefs of nested child items, so a collapsible parent without its
// own href (e.g. "Network Routing") can still tell when one of its
// children matches the current route.
const childRoutes = useMemo(() => {
const routes: { href: string; exact: boolean }[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) return;
const props = child.props as Partial<SidebarItemProps>;
if (props.href) {
routes.push({ href: props.href, exact: !!props.exactPathMatch });
}
});
return routes;
}, [children]);
// Check if any child route is active (for collapsible items)
const hasActiveChild = useMemo(() => {
if (!collapsible) return false;
if (href && (path === href || path.startsWith(href + "/"))) return true;
return childRoutes.some(({ href: childHref, exact }) =>
exact
? path === childHref
: path === childHref || path.startsWith(childHref + "/"),
);
}, [collapsible, href, path, childRoutes]);
const [open, setOpen] = React.useState(hasActiveChild);
// Open the collapsible if a child route becomes active
useEffect(() => {
if (hasActiveChild && !open) {
setOpen(true);
}
}, [hasActiveChild]);
const router = useRouter();
const { mobileNavOpen, toggleMobileNav, isNavigationCollapsed } =
useApplicationContext();
@@ -48,6 +82,7 @@ export default function SidebarItem({
? path == href
: path.includes(href)
: false;
if (collapsible && href) return;
if (collapsible && mobileNavOpen) return;
if (collapsible && open) return;
if (preventRedirect) return;
@@ -66,7 +101,7 @@ export default function SidebarItem({
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
<Collapsible.Trigger asChild>
<li className={"px-4 cursor-pointer list-none"}>
<li className={"px-3 cursor-pointer list-none"}>
<button
className={classNames(
"rounded-lg text-[.87rem] w-full relative font-normal",
@@ -101,7 +136,7 @@ export default function SidebarItem({
<span
className={cn(
"px-4 whitespace-nowrap flex-1 w-full text-left",
"px-3 whitespace-nowrap flex-1 w-full text-left",
labelClassName,
isNavigationCollapsed &&
!mobileNavOpen &&

View File

@@ -53,13 +53,10 @@ const TooltipContent = React.forwardRef<
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
asChild={true}
sideOffset={sideOffset}
className={cn(tooltipVariants({ variant }), className)}
{...props}
>
<div>{props.children}</div>
</TooltipPrimitive.Content>
/>
</TooltipPrimitive.Portal>
),
);

View File

@@ -0,0 +1,36 @@
import { cn } from "@utils/helpers";
import * as React from "react";
export const TooltipListItem = ({
icon,
label,
value,
className,
labelClassName,
}: {
icon?: React.ReactNode;
label: string;
value: string | React.ReactNode;
className?: string;
labelClassName?: string;
}) => {
return (
<div
className={cn(
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
className,
)}
>
<div
className={cn(
"flex items-center gap-2 text-nb-gray-100 font-medium",
labelClassName,
)}
>
{icon}
{label}
</div>
<div className={"text-nb-gray-300"}>{value}</div>
</div>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import FullTooltip from "@components/FullTooltip";
import { cn } from "@utils/helpers";
import { ArrowUpCircle } from "lucide-react";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import useFetchApi from "@utils/api";
import { isNetBirdHosted } from "@utils/netbird";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { VersionInfo as VersionInfoType } from "@/interfaces/Instance";
function formatVersion(version: string): string {
if (!version) return "";
// Add "v" prefix if version starts with a number
if (/^\d/.test(version)) return `v${version}`;
return version;
}
function compareVersions(current: string, latest: string): boolean {
// Returns true if latest is newer than current
if (!current || !latest) return false;
if (current === "development") return false;
// Strip "v" prefix if present
const normalizedCurrent = current.replace(/^v/, "");
const normalizedLatest = latest.replace(/^v/, "");
const currentParts = normalizedCurrent
.split(".")
.map((p) => parseInt(p, 10) || 0);
const latestParts = normalizedLatest
.split(".")
.map((p) => parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] || 0;
const l = latestParts[i] || 0;
if (l > c) return true;
if (l < c) return false;
}
return false;
}
export const NavigationVersionInfo = () => {
const { isNavigationCollapsed, mobileNavOpen } = useApplicationContext();
// Only show for self-hosted, not cloud
if (isNetBirdHosted()) return null;
return (
<div
className={cn(
"px-4 py-4 animate-fade-in",
isNavigationCollapsed &&
!mobileNavOpen &&
"hidden md:group-hover/navigation:block",
)}
>
<NavigationVersionInfoContent />
</div>
);
};
const NavigationVersionInfoContent = () => {
const { data: versionInfo, isLoading } = useFetchApi<VersionInfoType>(
"/instance/version",
true, // ignore errors
false, // don't revalidate on focus
);
const dashboardVersion =
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development";
if (isLoading)
return <Skeleton height={80} className={"rounded-lg opacity-60"} />;
if (!versionInfo) return null;
// Compare versions to detect updates (returns false for "development" versions)
const managementUpdateAvailable = compareVersions(
versionInfo.management_current_version,
versionInfo.management_available_version,
);
const dashboardUpdateAvailable = compareVersions(
dashboardVersion,
versionInfo.dashboard_available_version,
);
const hasUpdate = managementUpdateAvailable || dashboardUpdateAvailable;
return (
<div
className={cn(
"w-full rounded-md text-xs flex flex-col gap-2 whitespace-normal border text-left",
"bg-nb-gray-900/20 py-3 px-3 border-nb-gray-800/30",
)}
>
<div className="flex flex-col gap-1 text-nb-gray-400">
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.management_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Management</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(versionInfo.management_current_version)}
</span>
</div>
</FullTooltip>
<FullTooltip
content={
<span className="text-xs">
Latest: {formatVersion(versionInfo.dashboard_available_version)}
</span>
}
side="top"
className="w-full"
>
<div className="flex items-center justify-between w-full cursor-default">
<span>Dashboard</span>
<span className="text-nb-gray-300 font-medium">
{formatVersion(dashboardVersion)}
</span>
</div>
</FullTooltip>
</div>
{hasUpdate && (
<a
href="https://docs.netbird.io/selfhosted/maintenance/upgrade"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-white font-medium bg-netbird hover:bg-netbird-500 transition-colors rounded-md py-1.5 px-2 mt-1"
>
<ArrowUpCircle size={12} />
<span>Update available</span>
</a>
)}
</div>
);
};
export default NavigationVersionInfo;

View File

@@ -1,12 +1,22 @@
import {
MemoizedScrollArea,
MemoizedScrollAreaViewport,
} from "@components/ScrollArea";
import { MemoizedScrollArea, ScrollAreaViewport } from "@components/ScrollArea";
import { cn } from "@utils/helpers";
import * as React from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
const VirtuosoScroller = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>((props, ref) => <ScrollAreaViewport ref={ref} {...props} />);
type Props<T extends { id?: string }> = {
items: T[];
onSelect: (item: T) => void;
@@ -183,7 +193,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
}}
style={virtuosoHeight}
components={{
Scroller: MemoizedScrollAreaViewport,
Scroller: VirtuosoScroller,
}}
/>
</MemoizedScrollArea>

View File

@@ -2,6 +2,7 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { DialogTriggerProps } from "@radix-ui/react-dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { cn } from "@utils/helpers";
import { X } from "lucide-react";
import * as React from "react";
@@ -37,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));
@@ -58,6 +60,7 @@ const ModalContent = React.forwardRef<
children,
showClose = true,
maxWidthClass = "max-w-3xl",
onPointerDownOutside,
...props
},
ref,
@@ -71,21 +74,35 @@ const ModalContent = React.forwardRef<
className,
maxWidthClass,
)}
onPointerDownOutside={(e) => {
// Prevent closing modal when clicking on toast notifications
try {
const target = e.target as HTMLElement;
if (target?.closest("[data-toast-notification]")) {
e.preventDefault();
return;
}
} catch {
// Ignore errors
}
onPointerDownOutside?.(e);
}}
{...props}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</ModalOverlay>
</ModalPortal>
@@ -129,18 +146,19 @@ const SidebarModalContent = React.forwardRef<
}}
onClick={(e) => e.stopPropagation()}
>
<>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
</VisuallyHidden>
{children}
{showClose && (
<DialogPrimitive.Close
data-cy={"modal-close"}
className="absolute right-4 z-10 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-500 dark:ring-offset-neutral-950 dark:focus:ring-neutral-300 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-400"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</div>
</ModalPortal>

View File

@@ -14,6 +14,7 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react";
import Skeleton from "react-loading-skeleton";
import { useElementSize } from "@/hooks/useElementSize";
import { DropdownInfoText } from "@components/DropdownInfoText";
export interface SelectOption {
label: string | React.ReactNode;
@@ -25,13 +26,16 @@ export interface SelectOption {
}>;
renderItem?: () => React.ReactNode;
searchValue?: string;
className?: string;
disabled?: boolean;
}
interface SelectDropdownProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
popoverWidth?: "auto" | number;
popoverWidth?: "auto" | "content" | number;
popoverMinWidth?: number;
options: SelectOption[];
showSearch?: boolean;
showValues?: boolean;
@@ -44,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -51,6 +58,7 @@ export function SelectDropdown({
value,
disabled = false,
popoverWidth = "auto",
popoverMinWidth,
options,
showSearch = false,
showValues = false,
@@ -63,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -102,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -169,9 +183,18 @@ export function SelectDropdown({
)}
</PopoverTrigger>
<PopoverContent
className="w-full p-0 shadow-sm shadow-nb-gray-950 focus:outline-none"
className={cn(
"p-0 shadow-sm shadow-nb-gray-950 focus:outline-none",
popoverWidth !== "content" && "w-full",
)}
style={{
width: popoverWidth === "auto" ? width : popoverWidth,
width:
popoverWidth === "content"
? "auto"
: popoverWidth === "auto"
? width
: popoverWidth,
minWidth: popoverMinWidth,
}}
align="start"
side={"bottom"}
@@ -194,27 +217,30 @@ export function SelectDropdown({
)}
{filteredItems.length == 0 && (
<div className={"text-center pb-2 px-3 text-nb-gray-400 text-xs"}>
There are no results matching your search.
</div>
<DropdownInfoText className={"max-w-sm mx-auto px-4"}>
There are no results matching your search. Please try a
different search term.
</DropdownInfoText>
)}
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -234,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -253,22 +281,34 @@ const SelectDropdownItem = ({
}, [isVisible]);
return (
<div ref={elementRef} className={"transition-all"}>
<div ref={elementRef} className={"transition-all w-full"}>
{visible ? (
<CommandItem
value={option?.searchValue ?? value}
ref={elementRef}
className={"py-1 px-2"}
onSelect={() => toggle(option.value)}
className={"py-1 px-2 w-full"}
onSelect={() => !option?.disabled && toggle(option.value)}
onClick={(e) => e.preventDefault()}
disabled={option?.disabled}
>
<div className={"flex items-center gap-2.5 p-1"}>
{option.icon && <option.icon size={14} width={14} />}
<div
className={cn(
"flex items-center gap-2.5 p-1 w-full",
option?.className,
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
className={cn(
"flex flex-col text-sm font-medium",
"flex flex-col text-sm font-medium w-full",
size === "xs" && "text-xs",
)}
>

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { cn } from "@utils/helpers";
type Props = {
className?: string;
};
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
return (
<div
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
</div>
);
};

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