Compare commits

...

43 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
278 changed files with 22740 additions and 8701 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/__

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

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",
@@ -12,4 +16,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = withNextIntl(nextConfig);

917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,8 +67,9 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -89,6 +90,9 @@
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",

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

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

View File

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

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

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

@@ -1,78 +1,5 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import dayjs from "dayjs";
import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import React, { useMemo } from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import { redirect } from "next/navigation";
export default function ProxyEventsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const defaultFilters = useMemo(
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
}),
[],
);
return (
<PageContainer>
<div className="p-default py-6">
<Breadcrumbs>
<Breadcrumbs.Item
label="Activity"
disabled
icon={<ActivityIcon size={13} />}
/>
<Breadcrumbs.Item
href="/events/proxy"
label="Proxy Events"
icon={<ReverseProxyIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Proxy Events</h1>
<Paragraph>
View access logs for your reverse proxy services, including allowed
and denied requests.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
Proxy Events <ExternalLinkIcon size={12} />
</InlineLink>{" "}
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page="Proxy Events"
hasAccess={permission?.services?.read}
>
<ServerPaginationProvider
url="/events/proxy"
defaultPageSize={10}
defaultFilters={defaultFilters}
>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</ServerPaginationProvider>
</RestrictedAccess>
</PageContainer>
);
redirect("/reverse-proxy/logs");
}

View File

@@ -12,6 +12,7 @@ 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";
@@ -36,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");
@@ -50,7 +52,7 @@ export default function GroupPage() {
if (isRestricted) {
return (
<PageContainer>
<RestrictedAccess page={"Group Information"} />
<RestrictedAccess page={t("title")} />
</PageContainer>
);
}
@@ -73,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 />
@@ -142,6 +144,8 @@ const validAllGroupTabs = [
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
const GroupOverviewTabs = ({ group }: { group: Group }) => {
const t = useTranslations("groups");
const tNetworks = useTranslations("networks");
const searchParams = useSearchParams();
const getInitialTab = () => {
@@ -188,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>
)}
@@ -203,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>
)}
@@ -217,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
@@ -225,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
@@ -238,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
@@ -251,7 +255,7 @@ 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
@@ -264,7 +268,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Zones", zonesCount)}
{singularize(t("zones"), zonesCount)}
</TabsTrigger>
{group.name !== "All" && (
@@ -278,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>

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-5"} 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,7 +12,6 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
@@ -28,6 +27,7 @@ import {
Trash2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useMemo } from "react";
import useUrlTab from "@/hooks/useUrlTab";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -35,6 +35,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
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,
@@ -49,6 +50,7 @@ import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -65,11 +67,13 @@ export default function NetworkDetailPage() {
<NetworkOverview network={network} />
</ReverseProxiesProvider>
) : (
<FullScreenLoading />
<SkeletonNetwork />
);
}
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const t = useTranslations("networks");
const tReverseProxy = useTranslations("reverseProxy");
const { permission } = usePermissions();
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
@@ -96,103 +100,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkProvider network={network}>
<div className={"p-default py-6"}>
<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>
<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("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"
}
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
{singularize("Routing Peers", 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"
}
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
</TabsContent>
<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>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
</NetworkProvider>
</NetworkAccessControlProvider>
</PageContainer>
);
}
@@ -248,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
);
@@ -256,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;
@@ -318,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>
@@ -330,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 stepper-bg-variant"
}
>
<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

@@ -7,7 +7,8 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "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";
@@ -15,56 +16,54 @@ import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProx
import PageContainer from "@/layouts/PageContainer";
const CustomDomainsTable = lazy(
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
);
export default function ReverseProxyCustomDomainsPage() {
const { permission } = usePermissions();
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/custom-domains"}
label={"Custom Domains"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Domains</h1>
<Paragraph>
Add and manage custom domains for your reverse proxy services.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
Custom Domains
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Custom Domains"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<CustomDomainsTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
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

@@ -1,83 +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";
import { Callout } from "@components/Callout";
import { isNetBirdHosted } from "@utils/netbird";
const ReverseProxyTable = lazy(
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
);
export default function ReverseProxyServicesPage() {
const { permission } = usePermissions();
const t = useTranslations("reverseProxy");
const tCommon = useTranslations("common");
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Services"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Services</h1>
<Paragraph>
Expose services securely through NetBird&apos;s reverse proxy.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
Services
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
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"}>
NetBird&apos;s Reverse Proxy is currently in beta and available at
no cost during this period. Features, functionality, and pricing are
subject to change upon release.
</Callout>
) : (
<Callout className={"max-w-xl mt-5"} variant={"info"}>
NetBird&apos;s Reverse Proxy is currently in beta. <br /> Features
and functionality are subject to change upon release.
</Callout>
)}
</div>
{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={"Services"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<ReverseProxyTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
<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

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

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

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

@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
fill={"currentColor"}
>
<path 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" />
<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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

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

@@ -74,7 +74,7 @@ export const buttonVariants = cva(
"",
],
"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",
"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",

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,8 +22,9 @@ export default function CopyToClipboardText({
iconAlignment = "right",
className,
alwaysShowIcon = false,
textToCopy,
}: Props) {
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
const [wrapper, copyToClipboard, copied] = useCopyToClipboard(textToCopy);
return (
<div

View File

@@ -80,13 +80,15 @@ export const DeviceCard = ({
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</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

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

View File

@@ -1,29 +1,68 @@
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;
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 = true,
interactive = false,
className,
variant = "default",
triggerClassName,
align = "start",
side = "top",
alignOffset = 0,
sideOffset,
iconSize = 12,
delayDuration = 300,
}: Props) => {
return (
<>
<FullTooltip
interactive={interactive}
side={"top"}
align={"start"}
alignOffset={0}
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={content}
content={
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
}
>
{children}
{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

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

@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
icon?: React.ReactNode;
backgroundColor?: string;
preventSuccessToast?: boolean;
showOnlyError?: boolean;
errorMessages?: ErrorResponse[];
}
@@ -36,6 +37,7 @@ export default function Notification<T>({
loadingMessage,
duration = 3500,
preventSuccessToast = false,
showOnlyError = false,
errorMessages,
}: NotificationProps<T>) {
const [error, setError] = useState("");
@@ -49,10 +51,13 @@ export default function Notification<T>({
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));
timerRef.current = setTimeout(
() => {
timerRef.current = null;
toast.dismiss(toastId);
},
Math.max(0, remainingRef.current),
);
}, [toastId]);
const pauseTimer = useCallback(() => {
@@ -88,7 +93,10 @@ export default function Notification<T>({
}
});
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
observer.observe(toastEl, {
attributes: true,
attributeFilter: ["data-expanded"],
});
// Start immediately if not expanded
const expanded = toastEl.getAttribute("data-expanded") === "true";
@@ -106,7 +114,7 @@ export default function Notification<T>({
promise
.then(() => {
setLoading(false);
if (preventSuccessToast) {
if (showOnlyError || preventSuccessToast) {
toast.dismiss(toastId);
} else {
setReadyToDismiss(true);
@@ -136,6 +144,9 @@ export default function Notification<T>({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hideUntilError = showOnlyError && loading && !error;
if (hideUntilError) return null;
return (
<motion.div
ref={notificationRef}

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,13 +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";
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();
@@ -71,18 +82,29 @@ interface MultiSelectProps {
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
showPeerCounter?: boolean;
hideGroupsTab?: boolean;
tabOrder?: ("groups" | "peers" | "resources")[];
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,
@@ -101,6 +123,7 @@ export function PeerGroupSelector({
showResourceCounter = true,
showResources = false,
showPeers = false,
showPeerCounter = true,
hideGroupsTab = false,
tabOrder,
closeOnSelect = false,
@@ -113,11 +136,25 @@ export function PeerGroupSelector({
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");
@@ -275,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
@@ -329,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}
@@ -343,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();
@@ -353,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
@@ -396,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>
@@ -473,6 +569,7 @@ export function PeerGroupSelector({
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
showClusters={showClusters}
hideGroupsTab={hideGroupsTab}
tabOrder={tabOrder}
/>
@@ -513,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,
@@ -567,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}
@@ -616,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>
@@ -628,17 +740,22 @@ 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?: ("groups" | "peers" | "resources")[];
tabOrder?: PeerGroupSelectorTab[];
}) => {
const tabCount =
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
(!hideGroupsTab ? 1 : 0) +
(showResources ? 1 : 0) +
(showPeers ? 1 : 0) +
(showClusters ? 1 : 0);
if (tabCount <= 1) return null;
const groupsTab = !hideGroupsTab && (
@@ -692,10 +809,28 @@ const TabTriggers = ({
</TabsTrigger>
);
const tabMap = {
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) {
@@ -711,6 +846,7 @@ const TabTriggers = ({
{groupsTab}
{resourcesTab}
{peersTab}
{clustersTab}
</TabsList>
);
};
@@ -788,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;
@@ -899,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 = ({

View File

@@ -10,6 +10,7 @@ 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";
@@ -30,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({
@@ -39,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>();
@@ -124,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>
@@ -150,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>
)}
@@ -193,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>
}
>
@@ -238,7 +238,6 @@ export function PeerSelector({
!isSupported && "opacity-50",
)}
>
<MapPinIcon />
{option.ip}
</div>
</FullTooltip>

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

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

@@ -13,6 +13,7 @@ type SettingCardItemProps = {
description: React.ReactNode;
enabled: boolean;
onClick: () => void;
disabled?: boolean;
};
function SettingCardItem({
@@ -20,21 +21,31 @@ function SettingCardItem({
description,
enabled,
onClick,
disabled = false,
}: Readonly<SettingCardItemProps>) {
const handleClick = () => {
if (disabled) return;
onClick();
};
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
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={
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
}
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">
@@ -56,7 +67,8 @@ function SettingCardItem({
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
onClick={handleClick}
disabled={disabled}
>
<SquarePen size={12} />
Edit
@@ -66,7 +78,8 @@ function SettingCardItem({
variant={"secondaryLighter"}
size={"xs"}
className={"pl-3 pr-3"}
onClick={onClick}
onClick={handleClick}
disabled={disabled}
>
<PlusCircle size={12} />
Add

View File

@@ -38,11 +38,31 @@ export default function SidebarItem({
}: Readonly<SidebarItemProps>) {
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 || !href) return false;
return path === href || path.startsWith(href + "/");
}, [collapsible, href, path]);
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);

View File

@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));

View File

@@ -48,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -68,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -107,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>
);
@@ -216,20 +225,22 @@ export function SelectDropdown({
<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 w-full"}>
<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}
/>
@@ -249,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);
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />}
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div

View File

@@ -1,15 +1,20 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { cn } from "@utils/helpers";
export const SkeletonDeviceCard = () => {
type Props = {
className?: string;
};
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
return (
<div className={"min-h-[59px] relative -left-2"}>
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
<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
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>
);

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import { useEffect, useState } from "react";
import Skeleton from "react-loading-skeleton";
import SkeletonTable from "@components/skeletons/SkeletonTable";
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
const [show, setShow] = useState(delay === 0);
useEffect(() => {
if (delay === 0) return;
const timer = setTimeout(() => setShow(true), delay);
return () => clearTimeout(timer);
}, [delay]);
if (!show) return null;
return (
<div className={"p-default py-6 w-full"}>
<Skeleton height={24} width={240} className={"mb-4"} />
<div className={"mb-8 flex items-center gap-4"}>
<Skeleton height={48} width={48} />
<Skeleton height={20} width={200} />
</div>
<div className={"mb-4"}>
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
</div>
<div className={"flex items-center gap-4 mb-8"}>
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
<Skeleton height={24} width={130} />
</div>
<div>
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
</div>
<div className={"w-full"}>
<SkeletonTable withHeader={false} />
</div>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
export const SkeletonSettings = () => {
return (
<div className={"p-default py-6 max-w-2xl"}>
<Skeleton height={24} width={200} className={"mb-6"} />
<Skeleton height={32} width={110} className={"mb-10"} />
<div className={"mb-8"}>
<Skeleton height={17} width={200} className={"mb-2"} />
<Skeleton height={80} width={"100%"} />
</div>
<div className={"mb-8"}>
<Skeleton height={17} width={200} className={"mb-2"} />
<Skeleton height={80} width={"100%"} />
</div>
<Skeleton height={80} width={"100%"} />
</div>
);
};

View File

@@ -14,6 +14,7 @@ import {
TableWrapper,
} from "@components/table/Table";
import NoResults from "@components/ui/NoResults";
import { useTranslations } from 'next-intl';
import { RankingInfo } from "@tanstack/match-sorter-utils";
import {
ColumnDef,
@@ -53,6 +54,7 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -99,6 +101,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -183,12 +194,12 @@ export function DataTable<TData, TValue>({
columns,
data,
children,
searchPlaceholder = "Search...",
searchPlaceholder,
columnVisibility = {},
setColumnVisibility,
sorting = [],
setSorting,
text = "rows",
text,
onRowClick,
getStartedCard,
renderExpandedRow,
@@ -239,9 +250,13 @@ export function DataTable<TData, TValue>({
initialSearch,
onSearchClick,
}: Readonly<DataTableProps<TData, TValue>>) {
const t = useTranslations('table');
const path = usePathname();
const isInitialRender = useRef(true);
const resolvedSearchPlaceholder = searchPlaceholder || t('search');
const resolvedText = text || t('rows');
const [showOverlay, setShowOverlay] = useState(false);
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
@@ -296,6 +311,7 @@ export function DataTable<TData, TValue>({
autoResetAll: false,
autoResetExpanded: false,
manualPagination: manualPagination,
manualSorting: serverSidePagination,
manualFiltering: manualFiltering || manualColumnFiltering,
pageCount: pageCount,
state: {
@@ -323,6 +339,7 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
@@ -449,7 +466,7 @@ export function DataTable<TData, TValue>({
}
resetRowSelectionOnSearch && setRowSelection?.({});
}}
placeholder={searchPlaceholder}
placeholder={resolvedSearchPlaceholder}
/>
{children?.(table)}
{showResetFilterButton && (
@@ -622,7 +639,7 @@ export function DataTable<TData, TValue>({
<div className={paginationClassName}>
<DataTablePagination
table={table}
text={text}
text={resolvedText}
paginationPadding={paginationPaddingClassName}
totalRecords={totalRecords}
/>

View File

@@ -31,8 +31,12 @@ export default function DataTableGlobalSearch({
}, [debouncedValue]);
useEffect(() => {
if (globalSearch !== undefined && globalSearch !== inputValue) {
setInputValue(globalSearch);
// Coalesce undefined → "" so a reset (which clears the table's
// global filter to undefined) also clears the visible input text,
// not just the results.
const next = globalSearch ?? "";
if (next !== inputValue) {
setInputValue(next);
}
}, [globalSearch]);

View File

@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
import type { Column } from "@tanstack/table-core";
import { cn } from "@utils/helpers";
import React from "react";
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
type Props = {
column: Column<any>;
@@ -13,6 +14,8 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
children,
@@ -21,15 +24,28 @@ export default function DataTableHeader({
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};
return (
<FullTooltip content={tooltip} disabled={!tooltip}>
<div
onClick={
sorting
? () => column.toggleSorting(column.getIsSorted() === "asc")
: undefined
}
onClick={sorting ? handleSort : undefined}
className={cn(
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
sorting &&

View File

@@ -4,6 +4,7 @@ import { IconX } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { AnimatePresence, motion } from "framer-motion";
import { MonitorSmartphoneIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react";
type Props<T> = {
@@ -15,11 +16,14 @@ type Props<T> = {
export function DataTableMultiSelectPopup<T>({
onCanceled,
label = "Peer(s) selected",
label,
selectedItems,
rightSide,
}: Props<T>) {
const t = useTranslations('table');
const count = selectedItems?.length || 0;
const defaultLabel = label || t('selected', { count });
return (
<AnimatePresence>
{count > 0 && (
@@ -59,13 +63,13 @@ export function DataTableMultiSelectPopup<T>({
<span className={"font-medium text-white"}>
{count}
</span>{" "}
{label}
{defaultLabel}
</span>
</div>
<div className={"flex gap-2 items-center"}>
{rightSide}
<FullTooltip
content={<span className={"text-xs"}>Cancel</span>}
content={<span className={"text-xs"}>{t('cancel')}</span>}
>
<Button
onClick={onCanceled}

View File

@@ -7,6 +7,7 @@ import {
ChevronsLeft,
ChevronsRight,
} from "lucide-react";
import { useTranslations } from 'next-intl';
interface DataTablePaginationProps<TData> {
table: Table<TData>;
@@ -21,6 +22,7 @@ export function DataTablePagination<TData>({
paginationPadding = "px-8 py-8",
totalRecords,
}: DataTablePaginationProps<TData>) {
const t = useTranslations('table');
const rowsPerPage = table.getState().pagination.pageSize;
const currentPage = table.getState().pagination.pageIndex + 1;
const pageCount = table.getPageCount();
@@ -39,11 +41,11 @@ export function DataTablePagination<TData>({
className={cn("flex items-center justify-between", paginationPadding)}
>
<div className="text-nb-gray-400">
Showing{" "}
{t('showing')}{" "}
<span className={"font-medium text-white"}>
{showingFrom} to {showingTo}
{showingFrom} {t('to')} {showingTo}
</span>{" "}
of <span className={"font-medium text-white"}>{totalRows}</span>{" "}
{t('of')} <span className={"font-medium text-white"}>{totalRows}</span>{" "}
{text}
</div>
{pageCount > 1 && (

View File

@@ -2,6 +2,7 @@ import Button from "@components/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
import { Table } from "@tanstack/react-table";
import { FilterX } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react";
import { useState } from "react";
@@ -16,6 +17,7 @@ export default function DataTableResetFilterButton<TData>({
onClick,
hasServerSideFilters = undefined,
}: Props<TData>) {
const t = useTranslations('table');
const [hovered, setHovered] = useState(false);
const hasClientSideFilters =
@@ -52,7 +54,7 @@ export default function DataTableResetFilterButton<TData>({
}}
>
<span className={"text-xs text-neutral-300"}>
Reset Filters & Search
{t('resetFilters')}
</span>
</TooltipContent>
</Tooltip>

View File

@@ -4,6 +4,7 @@ import { Table } from "@tanstack/react-table";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandItem } from "cmdk";
import { Check, ChevronDown, RowsIcon } from "lucide-react";
import { useTranslations } from 'next-intl';
import * as React from "react";
interface DataTablePaginationProps<TData> {
@@ -17,6 +18,7 @@ export function DataTableRowsPerPage<TData>({
table,
disabled,
}: DataTablePaginationProps<TData>) {
const t = useTranslations('table');
const [open, setOpen] = React.useState(false);
return (
@@ -36,7 +38,7 @@ export function DataTableRowsPerPage<TData>({
<span className={"text-white"}>
{table.getState().pagination.pageSize}
</span>
<span className={"text-nb-gray-300"}> rows per page</span>
<span className={"text-nb-gray-300"}> {t('rowsPerPage')}</span>
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>

View File

@@ -0,0 +1,269 @@
"use client";
import Button from "@components/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@components/Popover";
import { Table } from "@tanstack/react-table";
import { cn } from "@utils/helpers";
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsUpDown,
FilterIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { useState } from "react";
// A TableFilterDef wires one TanStack column to the consolidated filter UI.
// Each filter renders its own picker — the framework just provides the
// popover container and chip row.
export type TableFilterDef<V = unknown> = {
id: string; // tan-stack column id
label: string;
renderPicker: (props: {
value: V | undefined;
onChange: (next: V | undefined) => void;
close: () => void;
}) => React.ReactNode;
// Returns the chip body. Null means no chip (filter inactive).
formatChip: (value: V | undefined) => string | null;
};
type ButtonProps<TData> = {
table: Table<TData>;
filters: TableFilterDef[];
disabled?: boolean;
};
export function TableFiltersButton<TData>({
table,
filters,
disabled,
}: ButtonProps<TData>) {
const [open, setOpen] = useState(false);
const [activeFilterId, setActiveFilterId] = useState<string | null>(null);
const activeCount = filters.reduce((n, f) => {
const v = table.getColumn(f.id)?.getFilterValue();
return f.formatChip(v as never) !== null ? n + 1 : n;
}, 0);
const activeFilter = filters.find((f) => f.id === activeFilterId);
return (
<Popover
open={open}
onOpenChange={(o) => {
if (!o) setActiveFilterId(null);
setOpen(o);
}}
>
<PopoverTrigger asChild>
<Button variant={"secondary"} disabled={disabled}>
<FilterIcon size={16} className={"shrink-0"} />
<span className={"flex items-center gap-1.5"}>
Filters
{activeCount > 0 && (
<span
className={
"inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-netbird text-white text-[10px] font-semibold !leading-[0] px-1.5"
}
>
{activeCount}
</span>
)}
</span>
<ChevronsUpDown size={16} className={"shrink-0"} />
</Button>
</PopoverTrigger>
<PopoverContent
className={"w-[280px] p-0 shadow-sm shadow-nb-gray-950"}
align={"start"}
sideOffset={7}
>
{activeFilter ? (
<div className={"flex flex-col"}>
<div
className={
"flex items-center gap-2 px-3 py-2 border-b border-nb-gray-900"
}
>
<button
aria-label={"Back"}
className={
"flex items-center justify-center w-7 h-7 -ml-1 shrink-0 text-nb-gray-400 hover:text-white hover:bg-nb-gray-900 rounded transition-colors"
}
onClick={() => setActiveFilterId(null)}
>
<ChevronLeftIcon size={16} />
</button>
<span className={"text-sm font-medium text-nb-gray-100"}>
{activeFilter.label}
</span>
</div>
<div className={"p-2"}>
{activeFilter.renderPicker({
value: table.getColumn(activeFilter.id)?.getFilterValue() as never,
onChange: (next) => {
table.setPageIndex(0);
table.getColumn(activeFilter.id)?.setFilterValue(next);
},
close: () => {
setOpen(false);
setActiveFilterId(null);
},
})}
</div>
</div>
) : (
<div className={"p-2 flex flex-col gap-0.5"}>
{filters.map((f) => {
const v = table.getColumn(f.id)?.getFilterValue();
const chip = f.formatChip(v as never);
return (
<button
key={f.id}
className={
"w-full text-left px-2 py-1.5 rounded hover:bg-nb-gray-900 transition-colors text-sm flex items-center gap-2.5 text-nb-gray-200"
}
onClick={() => setActiveFilterId(f.id)}
>
<span className={"flex-1"}>{f.label}</span>
{chip && (
<span
className={
"text-xs text-nb-gray-400 truncate max-w-[110px]"
}
>
{chip}
</span>
)}
<ChevronRightIcon
size={14}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
);
})}
</div>
)}
</PopoverContent>
</Popover>
);
}
type ChipsProps<TData> = {
table: Table<TData>;
filters: TableFilterDef[];
className?: string;
};
export function TableFilterChips<TData>({
table,
filters,
className,
}: ChipsProps<TData>) {
const active = filters
.map((f) => {
const value = table.getColumn(f.id)?.getFilterValue();
const text = f.formatChip(value as never);
if (!text) return null;
return { def: f, text };
})
.filter((c): c is { def: TableFilterDef; text: string } => !!c);
if (active.length === 0) return null;
return (
<div
className={cn(
"flex flex-wrap items-center gap-2 p-default pt-6",
className,
)}
>
{active.map(({ def, text }) => (
<FilterChip key={def.id} def={def} text={text} table={table} />
))}
</div>
);
}
type FilterChipProps<TData> = {
def: TableFilterDef;
text: string;
table: Table<TData>;
};
function FilterChip<TData>({ def, text, table }: FilterChipProps<TData>) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<div
className={cn(
"flex items-stretch h-8 rounded-md border border-nb-gray-900",
"bg-nb-gray-930/40 text-sm text-nb-gray-200 overflow-hidden",
"hover:border-nb-gray-700 transition-colors",
)}
>
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center gap-2 px-3",
"hover:bg-nb-gray-900 transition-colors",
)}
>
<span className={"text-nb-gray-400"}>{def.label}:</span>
<span className={"font-medium"}>{text}</span>
</button>
</PopoverTrigger>
<button
aria-label={`Remove ${def.label} filter`}
className={cn(
"flex items-center justify-center px-2",
"border-l border-nb-gray-900",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-white transition-colors",
)}
onClick={(e) => {
e.stopPropagation();
table.setPageIndex(0);
table.getColumn(def.id)?.setFilterValue(undefined);
}}
>
<XIcon size={14} />
</button>
</div>
<PopoverContent
className={"w-[280px] p-0 shadow-sm shadow-nb-gray-950"}
align={"start"}
sideOffset={6}
>
<div className={"flex flex-col"}>
<div
className={
"flex items-center gap-2 px-3 py-2 border-b border-nb-gray-900"
}
>
<span className={"text-sm font-medium text-nb-gray-100"}>
{def.label}
</span>
</div>
<div className={"p-2"}>
{def.renderPicker({
value: table.getColumn(def.id)?.getFilterValue() as never,
onChange: (next) => {
table.setPageIndex(0);
table.getColumn(def.id)?.setFilterValue(next);
},
close: () => setOpen(false),
})}
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { Checkbox } from "@components/Checkbox";
import { cn } from "@utils/helpers";
import * as React from "react";
// CheckboxListPicker — multi-select with checkboxes. Designed for small
// finite option sets (no search). For long, dynamic lists with search,
// use GroupsPicker or UsersPicker as the model.
export type CheckboxOption<V> = {
value: V;
label: string;
};
type Props<V extends string | number> = {
value: V[] | undefined;
onChange: (next: V[] | undefined) => void;
close: () => void;
options: CheckboxOption<V>[];
};
export function CheckboxListPicker<V extends string | number>({
value,
onChange,
options,
}: Props<V>) {
const selected = value ?? [];
const toggle = (v: V) => {
const isSelected = selected.includes(v);
const next = isSelected
? selected.filter((s) => s !== v)
: [...selected, v];
onChange(next.length ? next : undefined);
};
return (
<div className={"flex flex-col gap-0.5"}>
{options.map((option) => {
const isSelected = selected.includes(option.value);
return (
<div
key={String(option.value)}
role={"button"}
tabIndex={0}
className={cn(
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer",
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
isSelected && "text-white",
)}
onClick={() => toggle(option.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle(option.value);
}
}}
>
<Checkbox checked={isSelected} tabIndex={-1} />
{option.label}
</div>
);
})}
</div>
);
}
// Helper to produce a chip body for multi-select filters with bounded
// option sets. Returns:
// null when no selections
// the option's label when exactly one is selected
// `N {plural}` when multiple are selected (e.g. "3 OSes")
export function formatCheckboxChip<V>(
value: V[] | undefined,
options: CheckboxOption<V>[],
plural: string,
): string | null {
if (!value || value.length === 0) return null;
if (value.length === 1) {
return options.find((o) => o.value === value[0])?.label ?? String(value[0]);
}
return `${value.length} ${plural}`;
}

View File

@@ -0,0 +1,132 @@
"use client";
import { Checkbox } from "@components/Checkbox";
import { CommandItem } from "@components/Command";
import { ScrollArea } from "@components/ScrollArea";
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { cn } from "@utils/helpers";
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
import { orderBy, trim } from "lodash";
import { MonitorSmartphoneIcon, SearchIcon } from "lucide-react";
import * as React from "react";
import { useRef } from "react";
import { Group } from "@/interfaces/Group";
import { useTranslations } from "next-intl";
// GroupsPicker — multi-select search list of group names. The value
// stored on the column filter is an array of group *names* (matching
// PeersTable's group_names column which uses arrIncludesSome).
type Props = {
value: string[] | undefined;
onChange: (next: string[] | undefined) => void;
close: () => void; // unused (multi-select stays open while picking)
groups: Group[] | undefined;
};
export function GroupsPicker({ value, onChange, groups }: Props) {
const searchRef = useRef<HTMLInputElement>(null);
const selected = value ?? [];
const toggle = (name: string) => {
const isSelected = selected.includes(name);
const next = isSelected
? selected.filter((n) => n !== name)
: [...selected, name];
onChange(next.length ? next : undefined);
};
const t = useTranslations("groups");
return (
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
return formatValue.includes(formatSearch) ? 1 : 0;
}}
>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
className={cn(
"min-h-[38px] w-full relative bg-transparent text-sm",
"border-b border-nb-gray-900 outline-none",
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-9",
)}
ref={searchRef}
placeholder={t("searchPlaceholder")}
/>
<div
className={
"absolute left-0 top-0 h-full flex items-center pl-3 text-nb-gray-400"
}
>
<SearchIcon size={13} />
</div>
</div>
<ScrollArea
className={
"max-h-[300px] overflow-y-auto flex flex-col gap-1 p-1.5"
}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-0.5"}>
{orderBy(groups, ["peers_count"], ["desc"])?.map((group) => {
const name = group?.name;
if (!name) return null;
const isSelected = selected.includes(name);
return (
<CommandItem
key={group.id || name}
value={name}
className={"p-1"}
onSelect={() => {
toggle(name);
searchRef.current?.focus();
}}
>
<div
className={
"text-nb-gray-300 font-medium flex items-center gap-2.5 py-1 px-1 w-full"
}
>
<Checkbox checked={isSelected} />
<div
className={
"flex items-center gap-1.5 whitespace-nowrap text-sm font-normal min-w-0"
}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
<TextWithTooltip text={name} maxChars={22} />
</div>
{group?.peers_count !== undefined && (
<span
className={
"ml-auto text-xs text-nb-gray-400 flex items-center gap-1"
}
>
<MonitorSmartphoneIcon size={11} />
{group.peers_count}
</span>
)}
</div>
</CommandItem>
);
})}
</div>
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
);
}
export function formatGroupsChip(value: string[] | undefined): string | null {
if (!value || value.length === 0) return null;
if (value.length === 1) return value[0];
return `${value.length} groups`;
}

View File

@@ -0,0 +1,78 @@
"use client";
import { cn } from "@utils/helpers";
import { CheckIcon } from "lucide-react";
import * as React from "react";
// RadioPicker — single-value radio list. Generic over the value type so
// it works for any boolean / string / numeric column filter that maps
// to a small finite set of options.
export type RadioOption<V> = {
value: V;
label: string;
dotClass?: string;
};
type Props<V> = {
value: V | undefined;
onChange: (next: V | undefined) => void;
close: () => void;
options: RadioOption<V | undefined>[];
};
export function RadioPicker<V>({
value,
onChange,
close,
options,
}: Props<V>) {
return (
<div className={"flex flex-col gap-0.5"}>
{options.map((option) => {
const selected = value === option.value;
return (
<button
key={option.label}
className={cn(
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors",
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
selected && "text-white",
)}
onClick={() => {
onChange(option.value);
close();
}}
>
<CheckIcon
size={14}
className={cn(
"shrink-0 text-white",
selected ? "opacity-100" : "opacity-0",
)}
/>
{option.dotClass && (
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
option.dotClass,
)}
/>
)}
{option.label}
</button>
);
})}
</div>
);
}
// Build the chip text given the selected value and options.
export function formatRadioChip<V>(
value: V | undefined,
options: RadioOption<V | undefined>[],
): string | null {
// Treat undefined-valued option (i.e. "All") as no chip.
if (value === undefined) return null;
const opt = options.find((o) => o.value === value);
return opt?.label ?? null;
}

View File

@@ -0,0 +1,73 @@
"use client";
import { cn } from "@utils/helpers";
import { CheckIcon } from "lucide-react";
import * as React from "react";
// StatusPicker — three-way radio that maps to the `connected` column.
// undefined → All
// true → Online
// false → Offline
type Option = {
value: boolean | undefined;
label: string;
dotClass: string;
};
const OPTIONS: Option[] = [
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
{ value: true, label: "Online", dotClass: "bg-green-500" },
{ value: false, label: "Offline", dotClass: "bg-nb-gray-700" },
];
type Props = {
value: boolean | undefined;
onChange: (next: boolean | undefined) => void;
close: () => void;
};
export function StatusPicker({ value, onChange, close }: Props) {
return (
<div className={"flex flex-col gap-0.5"}>
{OPTIONS.map((option) => {
const selected = value === option.value;
return (
<button
key={option.label}
className={cn(
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors",
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
selected && "text-white",
)}
onClick={() => {
onChange(option.value);
close();
}}
>
<CheckIcon
size={14}
className={cn(
"shrink-0 text-white",
selected ? "opacity-100" : "opacity-0",
)}
/>
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
option.dotClass,
)}
/>
{option.label}
</button>
);
})}
</div>
);
}
// formatChip helper exported for use in the filter def's formatChip().
export function formatStatusChip(value: boolean | undefined): string | null {
if (value === true) return "Online";
if (value === false) return "Offline";
return null;
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Input } from "@components/Input";
import * as React from "react";
import { useEffect, useRef, useState } from "react";
// TextInputPicker — single-value free-text filter. Use for columns
// where the user types a token (e.g. a port number) and the table
// matches via the column's filterFn (typically `includesString`).
type Props = {
value: string | undefined;
onChange: (next: string | undefined) => void;
close: () => void;
placeholder?: string;
};
export function TextInputPicker({ value, onChange, placeholder }: Props) {
// Mirror the current filter value in a local input state so the
// input stays controlled while the user types. Apply downstream
// immediately so the table updates as they type.
const [local, setLocal] = useState(value ?? "");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
useEffect(() => {
setLocal(value ?? "");
}, [value]);
const apply = (next: string) => {
setLocal(next);
onChange(next.trim() === "" ? undefined : next.trim());
};
return (
<div className={"p-1"}>
<Input
ref={ref}
value={local}
onChange={(e) => apply(e.target.value)}
placeholder={placeholder}
maxWidthClass={"w-full"}
/>
</div>
);
}
// formatChip returns the typed value if any, otherwise null.
export function formatTextChip(value: string | undefined): string | null {
if (!value || value.trim() === "") return null;
return value;
}

View File

@@ -0,0 +1,165 @@
"use client";
import { DropdownInput } from "@components/DropdownInput";
import { DropdownInfoText } from "@components/DropdownInfoText";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { useSearch } from "@hooks/useSearch";
import { sortBy, uniqBy } from "lodash";
import { UserCircle2 } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
// UsersPicker — single-select search list mirroring the Activity
// Audit Logs user filter. The value stored on the column filter is the
// user's email (matches PeersTable's user_email accessor).
export type UserOption = {
id: string;
name: string;
email: string;
external?: boolean;
};
type Props = {
value: string | undefined;
onChange: (next: string | undefined) => void;
close: () => void;
options: UserOption[];
};
const ALL_USERS_ID = "all-users";
const searchPredicate = (item: UserOption, query: string) => {
const q = query.toLowerCase();
if (item.email === "NetBird" && "NetBird System".toLowerCase().includes(q))
return true;
if (item.name?.toLowerCase().includes(q)) return true;
if (item.email?.toLowerCase().includes(q)) return true;
return item.id.toLowerCase().startsWith(q);
};
export function UsersPicker({ value, onChange, close, options }: Props) {
const [filteredItems, search, setSearch] = useSearch(
options.concat({
id: ALL_USERS_ID,
name: "All Users",
email: "Include all users",
}),
searchPredicate,
{ filter: true, debounce: 150 },
);
const sortedOptions = useMemo(() => {
const sorted = sortBy(
uniqBy(filteredItems, (o) => o.email),
["external", "name"],
);
const allUsersIndex = sorted.findIndex((o) => o.id === ALL_USERS_ID);
if (allUsersIndex > -1) {
const allUsers = sorted.splice(allUsersIndex, 1)[0];
sorted.unshift(allUsers);
}
return sorted;
}, [filteredItems]);
return (
<div className={"w-full"}>
<DropdownInput
value={search}
onChange={setSearch}
placeholder={"Search user..."}
hideEnterIcon={true}
/>
{options.length === 0 && !search && (
<div className={"max-w-xs mx-auto"}>
<DropdownInfoText>
{"No users available to select."}
</DropdownInfoText>
</div>
)}
{filteredItems.length === 0 && search !== "" && (
<div className={"px-10"}>
<DropdownInfoText>
There are no users matching your search.
</DropdownInfoText>
</div>
)}
{sortedOptions.length > 0 && (
<VirtualScrollAreaList
items={sortedOptions}
estimatedItemHeight={48}
maxHeight={300}
scrollAreaClassName={"pt-0"}
onSelect={(item) => {
if (item.id === ALL_USERS_ID) {
onChange(undefined);
} else {
onChange(item.email === value ? undefined : item.email);
}
close();
}}
renderItem={(user) => {
const isSystemUser = user.email === "NetBird";
const isSelected =
value === user.email ||
(user.id === ALL_USERS_ID && !value);
return (
<div
className={"flex items-center gap-2 w-full"}
data-selected={isSelected || undefined}
>
{user.id === ALL_USERS_ID ? (
<div
className={
"w-7 h-7 shrink-0 rounded-full flex items-center justify-center uppercase text-[9px] font-medium bg-sky-400 text-white"
}
>
<UserCircle2 size={16} />
</div>
) : (
<SmallUserAvatar
name={user?.name}
email={user?.email}
id={user?.id}
/>
)}
<div className={"flex flex-col text-xs w-full min-w-0"}>
<span className={"text-nb-gray-200 flex items-center gap-1.5"}>
<TextWithTooltip
text={isSystemUser ? "System" : user?.name || user?.id}
maxChars={22}
/>
</span>
<span
className={
"text-nb-gray-400 font-light flex items-center gap-1"
}
>
<TextWithTooltip
text={user?.email || "NetBird"}
maxChars={22}
/>
</span>
</div>
</div>
);
}}
/>
)}
</div>
);
}
export function formatUsersChip(
value: string | undefined,
options: UserOption[],
): string | null {
if (!value) return null;
const user = options.find((u) => u.email === value);
return user?.name || user?.email || value;
}

View File

@@ -11,6 +11,7 @@ import {
ModalTrigger,
} from "@components/modal/Modal";
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useSWRConfig } from "swr";
@@ -23,6 +24,7 @@ import Paragraph from "../Paragraph";
import Separator from "../Separator";
export const AddGroupButton = () => {
const t = useTranslations('groups');
const create = useApiCall<Group>("/groups", true).post;
const { mutate } = useSWRConfig();
const [name, setName] = useState<string>("");
@@ -32,9 +34,9 @@ export const AddGroupButton = () => {
const createGroup = () => {
notify({
title: "Create Group",
description: `Group '${name}' successfully created`,
loadingMessage: "Creating group...",
title: t('create'),
description: t('createSuccess', { name }),
loadingMessage: t('creating'),
promise: create({ name }).then((g) => {
setOpen(false);
setName("");
@@ -54,26 +56,26 @@ export const AddGroupButton = () => {
className={"ml-auto h-[42px]"}
>
<PlusCircle size={16} />
Create Group
{t('create')}
</Button>
</ModalTrigger>
<ModalContent maxWidthClass={"max-w-xl"}>
<ModalHeader
icon={<FolderGit2Icon size={18} />}
title="Create Group"
description="Create a group to manage and organize access in your network"
title={t('create')}
description={t('createDescription')}
color="netbird"
/>
<Separator />
<div className={"px-8 flex-col flex gap-6 py-6"}>
<div>
<Label>Name</Label>
<Label>{t('name')}</Label>
<HelpText>
Set an easily identifiable name for your group
{t('nameHelp')}
</HelpText>
<Input
tabIndex={0}
placeholder={"e.g., Developers"}
placeholder={t('namePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
@@ -82,19 +84,19 @@ export const AddGroupButton = () => {
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
{t('learnMore')}
<InlineLink
href={"https://docs.netbird.io/how-to/manage-network-access"}
target={"_blank"}
>
Groups
{t('title')}
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
<Button variant={"secondary"}>{t('cancel')}</Button>
</ModalClose>
<Button
@@ -104,7 +106,7 @@ export const AddGroupButton = () => {
onClick={createGroup}
>
<PlusCircle size={16} />
Create Group
{t('create')}
</Button>
</div>
</ModalFooter>

View File

@@ -3,51 +3,57 @@ import Button from "@components/Button";
import { Modal, ModalTrigger } from "@components/modal/Modal";
import useFetchApi from "@utils/api";
import { PlusCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { memo, useState } from "react";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { Peer } from "@/interfaces/Peer";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
function AddPeerButton() {
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { oidcUser: user } = useOidcUser();
type Props = {
isUserDevice?: boolean;
};
const [hasOnboardingFormCompleted] = useLocalStorage(
"netbird-onboarding-modal",
false,
);
function AddPeerButton({ isUserDevice }: Readonly<Props>) {
const t = useTranslations("peers");
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { oidcUser: user } = useOidcUser();
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
"netbird-first-run",
!(peers && peers.length > 0),
);
const [hasOnboardingFormCompleted] = useLocalStorage(
"netbird-onboarding-modal",
false,
);
const [installModal, setInstallModal] = useState(
!hasOnboardingFormCompleted
? process.env.APP_ENV !== "test"
? false
: isFirstRun
: isFirstRun,
);
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
"netbird-first-run",
!(peers && peers.length > 0),
);
const handleOpenChange = (open: boolean) => {
setInstallModal(open);
setIsFirstRun(false);
};
const [installModal, setInstallModal] = useState(
!hasOnboardingFormCompleted
? process.env.APP_ENV !== "test"
? false
: isFirstRun
: isFirstRun,
);
return (
<>
<Modal open={installModal} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
<PlusCircle size={16} />
Add Peer
</Button>
</ModalTrigger>
<SetupModal user={user} />
</Modal>
</>
);
const handleOpenChange = (open: boolean) => {
setInstallModal(open);
setIsFirstRun(false);
};
return (
<>
<Modal open={installModal} onOpenChange={handleOpenChange}>
<ModalTrigger asChild>
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
<PlusCircle size={16} />
{t("addPeer")}
</Button>
</ModalTrigger>
<SetupModal user={user} isUserDevice={isUserDevice} />
</Modal>
</>
);
}
export default memo(AddPeerButton);

View File

@@ -4,6 +4,7 @@ import {
} from "@components/select/SelectDropdown";
import useFetchApi from "@utils/api";
import { MapPin } from "lucide-react";
import { useTranslations } from 'next-intl';
import { createElement, useMemo } from "react";
import { City } from "@/interfaces/City";
@@ -13,6 +14,7 @@ type Props = {
country: string;
};
export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
const t = useTranslations('common');
const { data: cities, isLoading } = useFetchApi<City[]>(
`/locations/countries/${country}/cities`,
);
@@ -36,17 +38,17 @@ export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
} as SelectOption;
}) as SelectOption[];
all.unshift({ label: "All Locations", value: "", icon: pinIcon });
all.unshift({ label: t('allLocations'), value: "", icon: pinIcon });
return all;
}, [cities]);
}, [cities, t]);
return (
<div className={"block w-full"}>
<SelectDropdown
isLoading={isLoading}
showSearch={true}
placeholder={"Select city (optional)..."}
searchPlaceholder={"Search city..."}
placeholder={t('selectCityOptional')}
searchPlaceholder={t('searchCity')}
value={value}
onChange={onChange}
options={cityList || []}

View File

@@ -2,6 +2,7 @@ import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { useTranslations } from 'next-intl';
import { createElement, useMemo } from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { useCountries } from "@/contexts/CountryProvider";
@@ -9,8 +10,12 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange }: Props) => {
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const t = useTranslations('common');
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -22,7 +27,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
size: iconSize,
...props,
});
return {
@@ -38,11 +43,14 @@ export const CountrySelector = ({ value, onChange }: Props) => {
<SelectDropdown
isLoading={isLoading}
showSearch={true}
placeholder={"Select country..."}
searchPlaceholder={"Search country..."}
placeholder={t('selectCountry')}
searchPlaceholder={t('searchCountry')}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);

View File

@@ -12,62 +12,47 @@ type Props = {
domains: string[];
};
export default function MultipleDomains({ domains }: Props) {
const firstDomain = domains.length > 0 ? domains[0] : undefined;
const otherDomains = domains.length > 0 ? domains.slice(1) : [];
if (domains.length === 0) {
return (
<Badge
variant={"blue-darker"}
className={"uppercase tracking-wider font-medium"}
>
<GlobeIcon size={10} />
All
</Badge>
);
}
return domains.length > 0 ? (
<TooltipProvider>
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
<div className={"inline-flex items-center gap-2"}>
{firstDomain && (
<Badge variant={"blue-darker"}>
<GlobeIcon size={10} />
{firstDomain}
</Badge>
)}
{otherDomains && otherDomains.length > 0 && (
<Badge
variant={"blue-darker"}
className={"px-3 gap-2 whitespace-nowrap"}
>
+ {otherDomains.length}
</Badge>
)}
</div>
</TooltipTrigger>
{otherDomains && otherDomains.length > 0 && (
<TooltipContent sideOffset={10}>
<div className={"flex flex-col gap-2 items-start "}>
{otherDomains.map((domain) => {
return (
domain && (
<div
key={domain}
className={
"flex gap-2 items-center justify-between w-full"
}
>
<Badge variant={"blue-darker"}>
<GlobeIcon size={10} />
{domain}
</Badge>
</div>
)
);
})}
if (domains.length > 1) {
return (
<TooltipProvider>
<Tooltip delayDuration={1}>
<TooltipTrigger asChild={true}>
<Badge variant={"blue-darker"} className={"cursor-help"}>
<GlobeIcon size={10} />
{domains.length} Domains
</Badge>
</TooltipTrigger>
<TooltipContent className={"p-3"}>
<div className={"flex flex-col gap-1.5 items-start max-w-sm"}>
{domains.map((domain) => (
<Badge key={domain} variant={"blue-darker"}>
<GlobeIcon size={10} />
{domain}
</Badge>
))}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<Badge
variant={"blue-darker"}
className={"uppercase tracking-wider font-medium"}
>
</Tooltip>
</TooltipProvider>
);
}
return (
<Badge variant={"blue-darker"}>
<GlobeIcon size={10} />
All
{domains[0]}
</Badge>
);
}

View File

@@ -26,6 +26,16 @@ type Props = {
showResources?: boolean;
redirectGroupTab?: string;
showUsers?: boolean;
disableRedirect?: boolean;
// countOnly collapses the visible chip to a single "N Groups" badge
// when there are 2+ groups. The first-group + "+N" pattern is
// suppressed in favour of a count summary; the hover card still
// shows the full list.
countOnly?: boolean;
// countThreshold raises the size at which countOnly kicks in.
// Default 1 means "collapse when length > 1". A threshold of 2
// shows up to 2 groups inline and only collapses when there are 3+.
countThreshold?: number;
};
export default function MultipleGroups({
@@ -37,11 +47,14 @@ export default function MultipleGroups({
showResources = false,
showUsers = false,
redirectGroupTab,
disableRedirect = false,
countOnly = false,
countThreshold = 1,
}: Readonly<Props>) {
const { permission } = usePermissions();
if (!groups || groups?.length === 0) return <EmptyRow />;
const orderedGroups = groups.sort((a, b) => {
const orderedGroups = [...groups].sort((a, b) => {
if (a.name === "All") return 1;
if (b.name === "All") return -1;
const aPeerCount = a.peers_count ?? 0;
@@ -61,15 +74,7 @@ export default function MultipleGroups({
data-cy={"multiple-groups"}
onClick={onClick}
>
{firstGroup && (
<GroupBadge
group={firstGroup}
className={
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
}
/>
)}
{otherGroups && otherGroups.length > 0 && (
{countOnly && orderedGroups.length > countThreshold ? (
<Badge
variant={"gray-ghost"}
useHover={true}
@@ -78,8 +83,51 @@ export default function MultipleGroups({
permission.groups.update ? "group-hover:bg-nb-gray-800" : "",
)}
>
+ {otherGroups.length}
{orderedGroups.length} Groups
</Badge>
) : countOnly ? (
<div className={"inline-flex items-center gap-2"}>
{orderedGroups.map((group) => (
<GroupBadge
key={group?.id || group?.name}
group={group}
showNewBadge={true}
className={
permission.groups.update
? "group-hover:bg-nb-gray-800"
: ""
}
/>
))}
</div>
) : (
<>
{firstGroup && (
<GroupBadge
group={firstGroup}
showNewBadge={true}
className={
permission.groups.update
? "group-hover:bg-nb-gray-800"
: ""
}
/>
)}
{otherGroups && otherGroups.length > 0 && (
<Badge
variant={"gray-ghost"}
useHover={true}
className={cn(
"px-3 gap-2 whitespace-nowrap",
permission.groups.update
? "group-hover:bg-nb-gray-800"
: "",
)}
>
+ {otherGroups.length}
</Badge>
)}
</>
)}
</div>
</HoverCardTrigger>
@@ -101,7 +149,7 @@ export default function MultipleGroups({
return (
group && (
<div
key={group.id}
key={group?.id || group?.name}
className={
"flex gap-2 items-center justify-between w-full"
}
@@ -110,16 +158,23 @@ export default function MultipleGroups({
group={group}
className={"py-0"}
textClassName={"py-1.5"}
redirectToGroupPage={true}
showNewBadge={true}
redirectToGroupPage={!disableRedirect}
redirectGroupTab={redirectGroupTab}
></GroupBadge>
<ArrowRightIcon size={14} />
{showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge
group={group}
disableRedirect={disableRedirect}
/>
) : showUsers ? (
<UserCountStack group={group} />
) : (
<PeerCountBadge group={group} />
<PeerCountBadge
group={group}
disableRedirect={disableRedirect}
/>
)}
</div>
)

View File

@@ -2,6 +2,7 @@ import Button from "@components/Button";
import Paragraph from "@components/Paragraph";
import { cn } from "@utils/helpers";
import { FilterX } from "lucide-react";
import { useTranslations } from 'next-intl';
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useCallback } from "react";
import Skeleton from "react-loading-skeleton";
@@ -20,18 +21,22 @@ type Props = {
export default function NoResults({
icon,
title = "Could not find any results",
description = "We couldn't find any results. Please try a different search term or change your filters.",
title,
description,
children,
className,
hasFiltersApplied = false,
onResetFilters,
contentClassName,
}: Props) {
const t = useTranslations('table');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const defaultTitle = title || t('noResults');
const defaultDescription = description || t('noResultsDescription');
const handleResetClick = useCallback(() => {
if (onResetFilters) {
onResetFilters();
@@ -83,9 +88,9 @@ export default function NoResults({
</div>
<div className={"text-center"}>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{defaultTitle}</h1>
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
{description}
{defaultDescription}
</Paragraph>
{hasFiltersApplied && onResetFilters && (
<Button
@@ -94,7 +99,7 @@ export default function NoResults({
className="mt-4"
>
<FilterX size={16} />
Reset Filters & Search
{t('resetFilters')}
</Button>
)}
{children}

View File

@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
group,
variant = "gray",
className,
disableRedirect = false,
}: Props) {
const router = useRouter();
const { dropdownOptions, groups } = useGroups();
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
return peerCount;
}, [currentGroup]);
const canRedirect = !!group?.id && group?.name !== "All";
const canRedirect =
!!group?.id && group?.name !== "All" && !disableRedirect;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
const showResources = resourcesCount > 0 && peerCount === 0;
return showResources ? (
<ResourceCountBadge group={group} />
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
) : (
<Badge
variant={variant}

View File

@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
type Props = {
group?: Group;
disableRedirect?: boolean;
} & React.HTMLAttributes<HTMLDivElement> &
BadgeVariants;
export default function ResourceCountBadge({ group }: Props) {
export default function ResourceCountBadge({
group,
disableRedirect = false,
}: Props) {
const router = useRouter();
const hasId = !!group?.id;
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (disableRedirect) return;
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
};

View File

@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
getRegionText: (country_code: string, city_name: string) => string;
getRegionText: (
country_code: string,
city_name: string,
subdivision_code?: string,
) => string;
},
);
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
const getRegionText = (country_code: string, city_name: string) => "Unknown";
const getRegionText = (
country_code: string,
city_name: string,
subdivision_code?: string,
) => "Unknown";
return isRestricted ? (
<CountryContext.Provider
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
);
const getRegionText = useCallback(
(country_code: string, city_name: string) => {
(country_code: string, city_name: string, subdivision_code?: string) => {
if (!countries) return "Unknown";
const country = countries.find((c) => c.country_code === country_code);
if (!country) return "Unknown";
if (!city_name) return country.country_name;
return `${country.country_name}, ${city_name}`;
const parts = [country.country_name];
if (subdivision_code) parts.push(subdivision_code);
if (city_name) parts.push(city_name);
return parts.join(", ");
},
[countries],
);

View File

@@ -27,6 +27,8 @@ type DialogOptions = {
type?: "default" | "warning" | "danger" | "center";
children?: React.ReactNode;
maxWidthClass?: string;
hideIcon?: boolean;
center?: boolean;
};
export default function DialogProvider({ children }: Props) {
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
onPointerDownOutside={(e) => e.preventDefault()}
>
<ModalHeader
center={dialogOptions.type == "center"}
center={dialogOptions.center ?? dialogOptions.type == "center"}
title={dialogOptions.title || "Confirmation"}
margin={"mt-1"}
description={
dialogOptions.description ||
"Are you sure you want to continue? This action cannot be undone."
}
icon={dialogTypes[dialogOptions.type || "default"]}
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
color={
dialogOptions.type == "default"
? "blue"

View File

@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => Promise<Peer>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
@@ -43,8 +44,10 @@ export default function PeerProvider({
peer,
isPeerDetailPage = false,
}: Props) {
const user = usePeerUser(peer);
const { peerGroups, isLoading } = usePeerGroups(peer);
const { user, isLoading: isUserLoading } = usePeerUser(peer);
const { peerGroups, isLoading: isGroupsLoading } = usePeerGroups(peer);
const isLoading =
isGroupsLoading || (peer.user_id ? isUserLoading : false);
const peerRequest = useApiCall<Peer>("/peers", true);
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
@@ -80,6 +83,7 @@ export default function PeerProvider({
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => {
return peerRequest.put(
{
@@ -99,6 +103,7 @@ export default function PeerProvider({
? undefined
: props.approval_required,
ip: props.ip != undefined ? props.ip : undefined,
ipv6: props.ipv6 != undefined ? props.ipv6 : undefined,
},
`/${peer.id}`,
);
@@ -180,11 +185,13 @@ export const usePeerGroups = (peer?: Peer) => {
* @param peer
*/
export const usePeerUser = (peer: Peer) => {
const { users } = useUsers();
const { users, isLoading } = useUsers();
return useMemo(() => {
return users?.find((user) => user.id === peer.user_id);
const user = useMemo(() => {
return users?.find((u) => u.id === peer.user_id);
}, [users, peer]);
return { user, isLoading };
};
/**

View File

@@ -1,7 +1,12 @@
import { Modal } from "@components/modal/Modal";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { cloneDeep } from "@utils/helpers";
import React, { useState } from "react";
import { useSWRConfig } from "swr";
import { useGroups } from "@/contexts/GroupsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy } from "@/interfaces/Policy";
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
message?: string,
) => void;
createPolicy: (policy: Policy) => Promise<Policy>;
createPoliciesForResource: (
policies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => Promise<void>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
serializeRules: (
rules: Policy["rules"],
enabled?: boolean,
) => Policy["rules"];
},
);
export default function PoliciesProvider({ children }: Props) {
const { mutate } = useSWRConfig();
const request = useApiCall<Policy>("/policies");
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
const createPolicy = async (policy: Policy) => request.post(policy);
const createPolicyForResource = async (
policy: Policy,
resource: NetworkResource,
knownGroups?: Group[],
) => {
const rule = policy.rules[0];
const allGroups = [...(knownGroups || []), ...(groups || [])];
const resolveGroup = async (g: Group | string): Promise<string> => {
if (typeof g === "string") return g;
if (g.id) return g.id;
const existing = allGroups.find((eg) => eg.name === g.name);
if (existing?.id) return existing.id;
const created = await createOrUpdateGroup(g);
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
(ids) => ids.filter(Boolean) as string[],
);
const destinationResource = rule.destinationResource
? { id: resource.id, type: resource.type }
: undefined;
return createPolicy({
...policy,
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
typeof c === "string" ? c : c.id,
),
rules: [
{
...rule,
sources,
destinations,
destinationResource,
},
],
} as Policy);
};
const createPoliciesForResource = async (
newPolicies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => {
const policiesToCreate = newPolicies.filter((p) => !p.id);
if (policiesToCreate.length === 0) return;
await Promise.all(
policiesToCreate.map((p) =>
createPolicyForResource(p, resource, knownGroups),
),
);
await mutate("/policies");
};
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
rules = cloneDeep(rules);
rules.forEach((rule) => {
if (enabled !== undefined) rule.enabled = enabled;
rule.sources = rule.sources
? (rule.sources.map((s) => {
const group = s as Group;
return group.id ?? s;
}) as string[])
: [];
rule.destinations = rule.destinations
? (rule.destinations.map((d) => {
const group = d as Group;
return group.id ?? d;
}) as string[])
: [];
if (rule.destinationResource) rule.destinations = null;
if (rule.sourceResource) rule.sources = null;
});
return rules;
};
const updatePolicy = async (
policy: Policy,
toUpdate: Partial<Policy>,
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
});
};
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
const promise = request.del("", `/${policy.id}`).then(() => {
mutate("/policies");
onSuccess?.();
});
notify({
title: "Access Control Policy " + policy.name,
description: "The policy was successfully deleted.",
promise,
loadingMessage: "Deleting policy...",
});
return promise;
};
const openEditPolicyModal = (policy: Policy, tab?: string) => {
setCurrentPolicy(policy);
tab && setInitialPolicyTab(tab);
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
return (
<PoliciesContext.Provider
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
value={{
updatePolicy,
createPolicy,
createPoliciesForResource,
openEditPolicyModal,
deletePolicy,
serializeRules,
}}
>
{children}
<Modal

View File

@@ -2,6 +2,7 @@
import { notify } from "@components/Notification";
import useFetchApi, { useApiCall } from "@utils/api";
import { wrapIPv6 } from "@utils/ip";
import React, {
createContext,
useCallback,
@@ -15,6 +16,8 @@ import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyCluster,
ReverseProxyClusterType,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
@@ -23,9 +26,12 @@ import {
} from "@/interfaces/ReverseProxy";
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
import { usePermissions } from "@/contexts/PermissionsProvider";
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
resources: NetworkResource[] | undefined;
peers: Peer[] | undefined;
isLoading: boolean;
openModal: (options?: OpenModalOptions) => void;
openTargetModal: (options: OpenTargetModalOptions) => void;
@@ -49,6 +55,9 @@ type ReverseProxiesContextValue = {
domain: string,
targetCluster: string,
) => Promise<ReverseProxyDomain>;
clusters: ReverseProxyCluster[] | undefined;
isClustersLoading: boolean;
isSelfHostedCluster: (clusterAddress?: string) => boolean;
};
type OpenModalOptions = {
@@ -88,17 +97,24 @@ export default function ReverseProxiesProvider({
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const { permission } = usePermissions();
// Reverse Proxies
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
false,
true,
permission?.services.read,
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
false,
true,
permission?.services.read,
);
const resolveDestination = useCallback(
@@ -123,12 +139,28 @@ export default function ReverseProxiesProvider({
// Domains
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
>("/reverse-proxies/domains", false, true, permission.services?.read);
const domainRequest = useApiCall<ReverseProxyDomain>(
"/reverse-proxies/domains",
true,
);
// Clusters
const { data: clusters, isLoading: isClustersLoading } = useFetchApi<
ReverseProxyCluster[]
>("/reverse-proxies/clusters", false, true, permission.services?.read);
const isSelfHostedCluster = useCallback(
(clusterAddress?: string) => {
if (!clusterAddress) return false;
return (
clusters?.find((c) => c.address === clusterAddress)?.type ===
ReverseProxyClusterType.ACCOUNT
);
},
[clusters],
);
const [modalOpen, setModalOpen] = useState(false);
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
const [initialTab, setInitialTab] = useState<string | undefined>();
@@ -301,7 +333,18 @@ export default function ReverseProxiesProvider({
const handleToggleTarget = useCallback(
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
const newEnabled = !target.enabled;
const targetIndex = proxy.targets.indexOf(target);
let targetIndex = proxy.targets.indexOf(target);
if (targetIndex === -1) {
targetIndex = proxy.targets.findIndex(
(t) =>
t.target_id === target.target_id &&
t.target_type === target.target_type &&
t.path === target.path &&
t.host === target.host &&
t.port === target.port,
);
}
if (targetIndex === -1) return;
const updatedTargets = proxy.targets.map((t, i) => {
return i === targetIndex ? { ...t, enabled: newEnabled } : t;
});
@@ -371,7 +414,18 @@ export default function ReverseProxiesProvider({
loadingMessage: "Deleting service...",
});
} else {
const targetIndex = proxy.targets.indexOf(target);
let targetIndex = proxy.targets.indexOf(target);
if (targetIndex === -1) {
targetIndex = proxy.targets.findIndex(
(t) =>
t.target_id === target.target_id &&
t.target_type === target.target_type &&
t.path === target.path &&
t.host === target.host &&
t.port === target.port,
);
}
if (targetIndex === -1) return;
const updatedTargets = proxy.targets.filter(
(_, i) => i !== targetIndex,
);
@@ -465,6 +519,8 @@ export default function ReverseProxiesProvider({
<ReverseProxiesContext.Provider
value={{
reverseProxies,
resources,
peers,
isLoading,
openModal,
openTargetModal,
@@ -479,6 +535,9 @@ export default function ReverseProxiesProvider({
createDomain,
validateDomain,
deleteDomain,
clusters,
isClustersLoading,
isSelfHostedCluster,
}}
>
{children}
@@ -581,8 +640,20 @@ export function sanitizeTargets(
): ReverseProxyTarget[] {
return targets.map((t) => {
const { destination: _, ...target } = t;
// Subnet targets always own their Host. Cluster targets do too,
// and they imply direct_upstream — the proxy peer dials the
// operator-supplied upstream via the host network stack instead of
// through the embedded WG client. For peer/host/domain targets the
// backend resolves Host from the peer/resource unless the operator
// explicitly opted into direct_upstream.
if (t.target_type === ReverseProxyTargetType.SUBNET)
return target as ReverseProxyTarget;
if (t.target_type === ReverseProxyTargetType.CLUSTER) {
const opts = { ...(target.options ?? {}), direct_upstream: true };
return { ...target, options: opts } as ReverseProxyTarget;
}
if (target.options?.direct_upstream && target.host?.trim())
return target as ReverseProxyTarget;
const { host: __, ...rest } = target;
return rest as ReverseProxyTarget;
});
@@ -600,7 +671,7 @@ function formatTargetDestination(
target: ReverseProxyTarget,
resolvedHost?: string,
): string {
const host = target.host || resolvedHost || "localhost";
const host = wrapIPv6(target.host || resolvedHost || "localhost");
const isDefault =
(target.protocol === "http" && target.port === 80) ||
(target.protocol === "https" && target.port === 443) ||

View File

@@ -31,6 +31,7 @@ type ServerPaginationContextValue<T = unknown> = {
onGlobalFilterChange: (value: string) => void;
setFilter: (key: string, value: string | undefined) => void;
getFilter: (key: string) => string | undefined;
setSort: (name: string, direction: "asc" | "desc") => void;
hasActiveFilters: boolean;
resetFilters: () => void;
onFilterReset: () => void;
@@ -146,6 +147,15 @@ export default function ServerPaginationProvider({
const getFilter = useCallback((key: string) => filters[key], [filters]);
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
setFilters((prev) => ({
...prev,
sort_by: name,
sort_order: direction,
}));
setPage(1);
}, []);
const hasActiveFilters =
search !== "" ||
Object.entries(filters).some(
@@ -170,6 +180,7 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
pagination: { pageIndex: page - 1, pageSize },
@@ -193,6 +204,7 @@ export default function ServerPaginationProvider({
mutate,
setFilter,
getFilter,
setSort,
hasActiveFilters,
resetFilters,
page,
@@ -220,3 +232,8 @@ export function useServerPagination<T>() {
}
return context as ServerPaginationContextValue<T>;
}
export function useOptionalServerPagination<T>() {
const context = useContext(ServerPaginationContext);
return context as ServerPaginationContextValue<T> | null;
}

View File

@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
);
export default function UsersProvider({ children }: Readonly<Props>) {
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
"/users?service_user=false",
);
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
User[]
>("/users?service_user=true");
const refresh = () => {
mutate().then();
mutateServiceUsers().then();
};
const allUsers = useMemo(() => {
return [...(users ?? []), ...(serviceUsers ?? [])];
}, [users, serviceUsers]);
return (
<UsersContext.Provider value={{ users, refresh, isLoading }}>
<UsersContext.Provider
value={{
users: allUsers,
refresh,
isLoading: isLoading || isLoadingServiceUsers,
}}
>
<UserProfileProvider>{children}</UserProfileProvider>
</UsersContext.Provider>
);

View File

@@ -0,0 +1,17 @@
import { useAccount } from "@/modules/account/useAccount";
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
import useFetchApi from "@utils/api";
export function useEmbeddedIdentityProviders() {
const account = useAccount();
const isEmbeddedIdPEnabled = !!account?.settings?.embedded_idp_enabled;
const { data: providers } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
true,
true,
isEmbeddedIdPEnabled,
);
return { providers, isEmbeddedIdPEnabled };
}

View File

@@ -4,23 +4,30 @@ import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
paramName: string = "tab",
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const tab = useMemo(() => {
const tabParam = searchParams.get("tab");
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
}, [searchParams, validTabs, defaultTab]);
const getTab = useCallback(
(params: URLSearchParams) => {
const tabParam = params.get(paramName);
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
},
[validTabs, defaultTab, paramName],
);
const tab = useMemo(() => getTab(searchParams), [searchParams, getTab]);
const setTab = useCallback(
(value: string) => {
const nextTab = validTabs.includes(value) ? value : defaultTab;
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
params.set(paramName, nextTab);
router.replace(`?${params.toString()}`, { scroll: false });
},
[searchParams, router],
[searchParams, router, validTabs, defaultTab, paramName],
);
return [tab, setTab];

870
src/i18n/messages/en.ts Normal file
View File

@@ -0,0 +1,870 @@
export default {
common: {
loading: "Loading...",
error: "Error",
success: "Success",
cancel: "Cancel",
confirm: "Confirm",
save: "Save",
delete: "Delete",
edit: "Edit",
create: "Create",
search: "Search",
refresh: "Refresh",
close: "Close",
yes: "Yes",
no: "No",
back: "Back",
next: "Next",
previous: "Previous",
actions: "Actions",
status: "Status",
name: "Name",
description: "Description",
type: "Type",
enabled: "Enabled",
disabled: "Disabled",
active: "Active",
inactive: "Inactive",
connected: "Connected",
disconnected: "Disconnected",
online: "Online",
offline: "Offline",
unknown: "Unknown",
all: "All",
none: "None",
select: "Select",
selected: "Selected",
filter: "Filter",
filters: "Filters",
reset: "Reset",
apply: "Apply",
export: "Export",
import: "Import",
download: "Download",
upload: "Upload",
copy: "Copy",
settings: "Settings",
logout: "Logout",
login: "Login",
profile: "Profile",
allLocations: "All locations",
selectCityOptional: "Select city (optional)...",
searchCity: "Search city...",
selectCountry: "Select country...",
searchCountry: "Search country...",
learnMore: "Learn more"
},
navigation: {
controlCenter: "Control Center",
peers: "Peers",
userDevices: "User Devices",
servers: "Servers",
accessControl: "Access Control",
policies: "Policies",
groups: "Groups",
postureChecks: "Posture Checks",
networkRouting: "Network Routing",
networks: "Networks",
routes: "Routes",
reverseProxy: "Reverse Proxy",
services: "Services",
customDomains: "Custom Domains",
clusters: "Clusters",
accessLogs: "Access Logs",
dns: "DNS",
nameservers: "Nameservers",
zones: "Zones",
dnsSettings: "DNS Settings",
team: "Team",
users: "Users",
serviceUsers: "Service Users",
activity: "Activity",
auditEvents: "Audit Events",
settings: "Settings",
documentation: "Documentation",
helpAndSupport: "Help and Support"
},
table: {
search: "Search...",
noResults: "No results",
noResultsDescription: "We couldn't find any results. Please try a different search term or change your filters.",
rowsPerPage: "Rows per page",
previous: "Previous",
next: "Next",
resetFilters: "Reset Filters & Search",
loading: "Loading...",
error: "Error loading data",
empty: "No data available",
selected: "{count} selected",
total: "Total {total} items",
showing: "Showing",
to: "to",
of: "of",
cancel: "Cancel",
rows: "rows",
selectAll: "Select all",
selectRow: "Select row"
},
auth: {
login: "Login",
logout: "Logout",
signIn: "Sign In",
signOut: "Sign Out",
email: "Email",
password: "Password",
rememberMe: "Remember me",
forgotPassword: "Forgot password?",
resetPassword: "Reset password",
createAccount: "Create Account",
accountCreated: "Account Created",
invalidCredentials: "Invalid credentials",
accountBlocked: "Account Blocked",
accountPending: "Account Pending",
sessionExpired: "Session Expired",
loginRequired: "Login Required",
unauthorized: "Unauthorized",
forbidden: "Forbidden",
accessDenied: "Access Denied",
tooManyRequests: "Too Many Requests",
tryAgainLater: "Please try again later",
contactAdmin: "Please contact your administrator"
},
errors: {
generic: "An error occurred",
networkError: "Network error",
timeout: "Request timeout",
notFound: "Not found",
serverError: "Server error",
validationError: "Validation error",
permissionDenied: "Permission denied",
resourceNotFound: "Resource not found",
serviceUnavailable: "Service unavailable"
},
peers: {
title: "Peers",
name: "Name",
ip: "IP Address",
status: "Status",
connected: "Connected",
disconnected: "Disconnected",
lastSeen: "Last Seen",
os: "OS",
version: "Version",
address: "Address",
location: "Location",
groups: "Groups",
users: "Users",
routes: "Routes",
ssh: "SSH",
rdp: "RDP",
actions: "Actions",
approve: "Approve",
reject: "Reject",
delete: "Delete",
edit: "Edit",
view: "View",
viewDetails: "View Details",
connect: "Connect",
disconnect: "Disconnect",
approvePeer: "Approve Peer",
rejectPeer: "Reject Peer",
deletePeer: "Delete Peer",
confirmDelete: "Are you sure you want to delete this peer?",
confirmApprove: "Approve peer '{name}'?",
confirmApproveDescription: "Are you sure you want to approve this peer?",
approveSuccess: "Peer {name} approved",
approveSuccessDescription: "This peer was approved and can now connect to other peers.",
approveLoading: "Approving peer...",
approved: "Approved",
pending: "Pending",
pendingApprovals: "Pending Approvals",
noPeers: "No peers available",
noAccessiblePeersTitle: "This peer has no accessible peers",
noAccessiblePeersDescription: "Add more peers to your network or check your access control policies.",
searchPlaceholder: "Search by name, IP, owner or group...",
selectPeer: "Select a peer...",
noPeersAvailable: "No peers available to select.",
noPeersMatching: "No peers matching your search.",
updateRequired: "Please update NetBird to at least v0.36.6 or later to use this peer as a routing peer.",
serialNumber: "Serial number",
loginExpiration: "Session Expiration",
enableLoginExpiration: "Enable Session Expiration",
disableLoginExpiration: "Disable Session Expiration",
loginExpirationUpdated: "Session expiration is {state}",
loginExpirationUpdateDescription: "Session expiration for peer {name} was successfully {state}.",
loginExpirationUpdating: "Updating session expiration...",
enableSSH: "Enable SSH Access",
disableSSH: "Disable SSH Access",
disableSSHConfirmation: "Disable SSH Access?",
disableSSHDescription: "Starting from NetBird v0.61.0, once SSH access is disabled, you cannot re-enable it again from the dashboard. You'll need to create an explicit access control policy and update your NetBird client to restore SSH functionality.",
sshLearnMore: "Learn more",
browserPeerTooltip: "Show temporary peers created by the NetBird browser client. These peers are ephemeral and will be deleted automatically after a short period of time.",
connectTooltipOffline: "Connecting via SSH or RDP is only available when the peer is online.",
expirationDisabledTooltip: "Expiration is disabled for all peers added with an setup-key.",
justNow: "just now",
searchByNameIpOwnerOrGroup: "Search by name, IP, owner or group...",
selectedCount: "{count} Peer(s) selected",
assignGroups: "Assign Groups",
deleteAll: "Delete All",
deleteAllConfirm: "Delete '{count}' {peerWord}?",
deleteAllConfirmDescription: "Are you sure you want to delete these peers? This action cannot be undone.",
deleteAllConfirmText: "Delete All",
peersDeleted: "Peers were successfully deleted",
peersDeleting: "Deleting the selected peers...",
groupsAssigned: "Groups were successfully assigned to the peers",
groupsAssigning: "Updating the groups of the selected peers...",
assigningGroups: "Assigning groups...",
groupsAssignedSuccess: "Groups successfully assigned",
assignGroupsDescription: "Assign the following groups to the selected peers. Previously assigned groups will be kept unless you choose to overwrite them.",
overwriteGroups: "Overwrite Existing Groups",
overwriteGroupsHelp: "Overwrite the existing groups of the peers with the selected ones. Previously assigned groups will be removed.",
overwrite: "Overwrite",
overwriteGroupsConfirm: "Overwrite existing groups?",
overwriteGroupsConfirmDescription: "Are you sure you want to overwrite the existing groups of your {count} selected peer(s)? This action cannot be undone.",
addGroups: "Add Groups",
assignedGroups: "Assigned Groups",
groupsSaved: "Groups of the peer were successfully saved",
groupsSaving: "Saving the groups of the peer...",
assignedGroupsDescription: "Use groups to control what this peer can access",
peerWord: "peer",
peersWord: "peers",
createPeer: "Create Peer",
addPeer: "Add Peer",
server: "Server",
servers: "Servers",
userDevice: "User Device",
userDevices: "User Devices",
operatingSystem: {
linux: "Linux",
windows: "Windows",
macos: "macOS",
android: "Android",
ios: "iOS"
},
updateAvailable: "Update available",
updateDescription: "A new version of Netbird is available. Please update your client to get the latest features and bug fixes.",
downloadChangelog: "Download & Changelog",
dnsLabelCopied: "DNS label has been copied to your clipboard",
ipCopied: "IP address has been copied to your clipboard",
viewDetailsOf: "View details of peer",
userLabel: "user: {id}",
netbirdIp: "NetBird IP",
netbirdIpv6: "NetBird IPv6",
netbirdIpCopied: "NetBird IP has been copied to your clipboard",
netbirdIpv6Copied: "NetBird IPv6 has been copied to your clipboard",
publicIp: "Public IP",
publicIpCopied: "Public IP has been copied to your clipboard",
domain: "Domain",
region: "Region",
regionCopied: "Region has been copied to your clipboard",
peerNotFoundDescription: "The peer you are attempting to access cannot be found. It may have been deleted, or you may not have permission to view it. Please verify the URL or return to the dashboard.",
tabOverview: "Overview",
tabNetworkRoutes: "Network Routes",
tabAccessiblePeers: "Accessible Peers",
tabRemoteJobs: "Remote Jobs",
peerSaved: "Peer was successfully saved",
peerSaving: "Saving the peer...",
remoteAccess: "Remote Access",
remoteAccessDescription: "Connect directly to this peer via SSH or RDP.",
domainName: "Domain Name",
hostname: "Hostname",
operatingSystemLabel: "Operating System",
registeredOn: "Registered on",
agentVersion: "Agent Version",
uiVersion: "UI Version",
peerIpUpdated: "NetBird Peer IP was successfully updated",
peerIpUpdating: "Updating peer IP...",
peerIpv6Updated: "NetBird Peer IPv6 was successfully updated",
peerIpv6Updating: "Updating peer IPv6...",
noServicesForPeer: "This peer has no services",
addServicesDescription: "Add your services to this peer and securely expose them through NetBird's reverse proxy",
editPeerName: "Edit Peer Name",
editPeerNameDescription: "Set an easily identifiable name for your peer.",
peerNamePlaceholder: "e.g., AWS Servers",
domainNamePreview: "Domain Name Preview",
domainNamePreviewHelp: "If the domain name already exists, we add an increment number suffix to it.",
userDevicesDescription: "Laptops, phones and other personal devices with a user behind them, typically added when the user signs in with SSO.",
learnMore: "Learn more",
addNewDeviceTitle: "Add new device to your network",
addNewDeviceDescription: "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",
installationGuide: "Installation Guide",
serversDescription: "Servers, VMs, autonomous agents and other unattended machines with no user behind them, typically enrolled with a setup key.",
addNewServerTitle: "Add new server to your network",
addNewServerDescription: "To get started, install NetBird on the server and enroll it using a setup key. If you have further questions check out our",
saveGroups: "Save Groups",
getStarted: "Get Started with NetBird",
getStartedDescription: "It looks like you don't have any connected machines.\nGet started by adding one to your network.",
learnMoreInOur: "Learn more in our",
gettingStartedGuide: "Getting Started Guide"
},
policies: {
title: "Policies",
name: "Name",
description: "Description",
enabled: "Enabled",
disabled: "Disabled",
rules: "Rules",
sources: "Sources",
destinations: "Destinations",
actions: "Actions",
create: "Create Policy",
edit: "Edit Policy",
delete: "Delete Policy",
enable: "Enable",
disable: "Disable",
confirmDelete: "Are you sure you want to delete this policy?",
noPolicies: "No policies available",
searchPlaceholder: "Search policies...",
policyName: "Policy Name",
policyNameHelp: "Set an easily identifiable name for your policy",
policyNamePlaceholder: "e.g., Engineering Access",
enabledDescription: "Enable or disable this policy",
rule: "Rule",
addRule: "Add Rule",
removeRule: "Remove Rule",
source: "Source",
destination: "Destination",
protocol: "Protocol",
action: "Action",
port: "Port",
portRange: "Port Range",
direction: "Direction",
bidirectional: "Bidirectional",
oneWay: "One Way",
allow: "Allow",
deny: "Deny",
allProtocols: "All Protocols",
tcp: "TCP",
udp: "UDP",
icmp: "ICMP",
any: "Any",
netbirdSsh: "NetBird SSH",
protoPorts: "Proto & Ports",
portsPlaceholder: "e.g. 443",
addPolicy: "Add Policy",
createNewPolicy: "Create New Policy",
createNewPolicyDescription: "It looks like you don't have any policies yet. Policies can allow connections by specific protocol and ports.",
noPoliciesForGroup: "This group is not used within any policies yet",
noPoliciesForGroupDescription: "Assign this group as either a source or destination inside a policy to see them listed here.",
temporaryPoliciesTooltip: "Show temporary policies created by the NetBird browser client. These policies are ephemeral and will be deleted automatically after a short period of time.",
learnMoreAbout: "Learn more about",
accessControls: "Access Controls",
policyActions: "Policy actions",
policyEnabledSuccess: "The rule was successfully enabled",
policyDisabledSuccess: "The rule was successfully disabled",
confirmDeleteTitle: "Delete '{name}'?",
confirmDeleteDescription: "Are you sure you want to delete this access control policy? This action cannot be undone.",
updatePolicy: "Update Access Control Policy",
modalDescription: "Use this policy to restrict access to groups of resources.",
tabPolicy: "Policy",
tabNameDescription: "Name & Description",
protocolHelp: "Allow only specified network protocols. To change traffic direction and ports, select TCP or UDP protocol.",
selectProtocol: "Select protocol...",
netbirdSshHelp: "Select NetBird SSH for SSH-specific policies with fine-grained access control, or use TCP with port 22 for basic network-level SSH access",
sourceHelp: "Typically a group of user devices (e.g., Developers, Marketing) or individual devices in peer-to-peer connections that will access the destination.",
selectSource: "Select source(s)...",
destinationHelp: "Typically a group of peers or resources (e.g., Servers, Databases, Internal Services) that will be accessed by the source. Can also be an individual peer or resource.",
selectDestination: "Select destination(s)...",
resourcesBidirectionalWarning: "Some destination groups contain resources. Resources only support incoming traffic and cannot initiate connections.",
sshResourceWarning: "SSH access only works on peers, not on routed resources. Please ensure your destination groups contain peers for SSH connectivity.",
sshAccess: "SSH Access",
sshAccessHelp: "Select 'Full Access' to allow SSH as any local user, or 'Limited Access' to specify which local users each group is allowed to use.",
ports: "Ports",
portsHelp: "Allow network traffic and access only to specified ports. Select ports or port ranges between 1 and 65535.",
enablePolicy: "Enable Policy",
enablePolicyHelp: "Use this switch to enable or disable the policy.",
ruleName: "Name of the Rule",
ruleNameHelp: "Set an easily identifiable name for your policy.",
ruleNamePlaceholder: "e.g., Devs to Servers",
policyDescriptionLabel: "Description (optional)",
policyDescriptionHelp: "Write a short description to add more context to this policy.",
policyDescriptionPlaceholder: "e.g., Devs are allowed to access servers and servers are allowed to access Devs.",
accessControlDescription: "Create rules to manage access in your network and define what peers can connect.",
policyCreated: "Policy '{name}' successfully created",
policyUpdated: "Policy '{name}' successfully updated",
policyDeleted: "Policy '{name}' successfully deleted",
policyEnableLoading: "Enabling policy...",
policyDisableLoading: "Disabling policy...",
policySaveLoading: "Saving policy...",
policyDeleteLoading: "Deleting policy..."
},
groups: {
title: "Groups",
name: "Name",
peers: "Peers",
users: "Users",
policies: "Policies",
resources: "Resources",
create: "Create Group",
createDescription: "Create a group to manage and organize access in your network",
createSuccess: "Group '{name}' successfully created",
creating: "Creating group...",
nameHelp: "Set an easily identifiable name for your group",
namePlaceholder: "e.g., Developers",
learnMore: "Learn more about",
edit: "Edit Group",
delete: "Delete Group",
confirmDelete: "Are you sure you want to delete this group?",
noGroups: "No groups available",
searchPlaceholder: "Search groups...",
cancel: "Cancel",
addPeerToGroup: "Add Peer",
addUserToGroup: "Add User",
editName: "Edit Name",
groupName: "Group Name",
groupUpdated: "Group '{name}' successfully updated",
groupUpdating: "Updating group...",
assignedPeers: "Assigned Peers",
assignedUsers: "Assigned Users",
assignedResources: "Assigned Resources",
assignPeersDescription: "Use peers to control what this group can access",
assignUsersDescription: "Use users to control what this group can access",
assignResourcesDescription: "Use resources to control what this group can access",
addPeerToGroupTitle: "Add Peers to Group",
addUserToGroupTitle: "Add Users to Group",
addResourcesToGroupTitle: "Add Resources to Group",
searchPeer: "Search peers...",
searchUser: "Search users...",
searchResource: "Search resources...",
used: "Used",
unused: "Unused",
usage: "Usage",
inUse: "In Use",
nameservers: "Nameservers",
zones: "Zones",
routes: "Routes",
setupKeys: "Setup Keys",
viewDetails: "View Details",
rename: "Rename",
groupsDescription: "Organize peers, users and resources into groups to manage access.",
allGroups: "All Groups",
nGroups: "{n} Group(s)",
noGroupsMatching: "There are no groups matching your search. Try another search term.",
noGroupsAvailable: "Seems like you don't have any groups."
},
users: {
title: "Users",
name: "Name",
email: "Email",
role: "Role",
status: "Status",
lastLogin: "Last Login",
actions: "Actions",
invite: "Invite User",
edit: "Edit User",
delete: "Delete User",
block: "Block",
unblock: "Unblock",
approve: "Approve",
reject: "Reject",
resendInvite: "Resend Invite",
confirmDelete: "Are you sure you want to delete this user?",
noUsers: "No users available",
searchPlaceholder: "Search users...",
owner: "Owner",
user: "User",
admin: "Admin",
blocked: "Blocked",
invited: "Invited",
active: "Active",
inactive: "Inactive",
roleOwner: "Owner",
roleAdmin: "Admin",
roleUser: "User",
roleServiceUser: "Service User",
inviteUser: "Invite User",
inviteUserDescription: "Invite a user to join your NetBird network",
userEmail: "User Email",
userEmailPlaceholder: "user@example.com",
userEmailHelp: "Enter the email address of the user you want to invite",
group: "Group",
selectGroup: "Select group...",
selectRole: "Select role...",
autoSSODescription: "This user will be added automatically via SSO when they first sign in.",
inviteSuccess: "User {email} has been invited",
inviting: "Inviting user...",
userBlocked: "User {name} has been blocked",
userUnblocked: "User {name} has been unblocked",
blocking: "Blocking user...",
unblocking: "Unblocking user...",
userDeleted: "User {name} has been deleted",
deleting: "Deleting user...",
inviteResent: "Invite has been resent",
resending: "Resending invite...",
userApproved: "User {name} has been approved",
approving: "Approving user...",
userRejected: "User {name} has been rejected",
rejecting: "Rejecting user...",
copyUserId: "Copy User ID",
copyUserIdSuccess: "User ID copied to clipboard",
networkAdmin: "Network Admin",
billingAdmin: "Billing Admin",
auditor: "Auditor",
pending: "Pending",
lastLoginOn: "Last login on",
showInvites: "Show Invites",
addNewUsers: "Add New Users",
addNewUsersDescription: "It looks like you don't have any users yet. Get started by inviting users to your account.",
addUser: "Add User",
localAuthDisabled: "Local authentication is disabled. Use your IdP for authentication.",
team: "Team",
usersPageDescription: "Manage users and their permissions. Same-domain email users are added automatically on first sign-in."
},
serviceUsers: {
title: "Service Users",
name: "Name",
userId: "User ID",
createdAt: "Created At",
actions: "Actions",
create: "Create Service User",
edit: "Edit Service User",
delete: "Delete Service User",
noServiceUsers: "No service users available",
searchPlaceholder: "Search service users...",
confirmDelete: "Are you sure you want to delete this service user?",
serviceUserName: "Service User Name",
serviceUserNamePlaceholder: "e.g., CI/CD Pipeline",
serviceUserNameHelp: "Set a descriptive name for this service user",
copyUserId: "Copy User ID",
autoGroups: "Auto Assign Groups",
autoGroupsDescription: "Automatically assign these groups to peers that use this service user",
copySuccess: "Copied to clipboard",
userCreated: "Service user '{name}' successfully created",
userUpdated: "Service user '{name}' successfully updated",
userDeleted: "Service user '{name}' successfully deleted",
createLoading: "Creating service user...",
updateLoading: "Updating service user...",
deleteLoading: "Deleting service user...",
serviceUsersDescription: "Use service users to create API tokens and avoid losing automated access.",
serviceUsersEmptyDescription: "It looks like you don't have any service users. Get started by creating a service user.",
blocked: "Blocked"
},
settings: {
title: "Settings",
general: "General",
account: "Account",
security: "Security",
notifications: "Notifications",
appearance: "Appearance",
language: "Language",
theme: "Theme",
darkMode: "Dark Mode",
lightMode: "Light Mode",
systemDefault: "System Default",
save: "Save Settings",
cancel: "Cancel",
reset: "Reset to Default",
saved: "Settings successfully saved",
saving: "Saving settings...",
profile: "Profile",
changePassword: "Change Password",
currentPassword: "Current Password",
newPassword: "New Password",
confirmPassword: "Confirm Password",
twoFactor: "Two-Factor Authentication",
enable2FA: "Enable 2FA",
disable2FA: "Disable 2FA",
authentication: "Authentication",
setupKeys: "Setup Keys",
identityProviders: "Identity Providers",
groupsTab: "Groups",
permissions: "Permissions",
networksTab: "Networks",
clients: "Clients",
dangerZone: "Danger zone"
},
reverseProxy: {
title: "Reverse Proxy",
description: "Configure reverse proxy services and domains",
services: "Services",
customDomains: "Custom Domains",
clusters: "Clusters",
accessLogs: "Access Logs",
createService: "Create Service",
editService: "Edit Service",
deleteService: "Delete Service",
confirmDeleteService: "Are you sure you want to delete this service?",
serviceName: "Service Name",
serviceNamePlaceholder: "e.g., My Web App",
source: "Source Peer",
destination: "Destination",
port: "Port",
protocol: "Protocol",
enabled: "Enabled",
noServices: "No services available",
searchServices: "Search services...",
domain: "Domain",
path: "Path",
target: "Target",
selectSourcePeer: "Select source peer...",
selectTargetPeer: "Select target peer...",
selectPort: "Select port...",
customDomainsDescription: "Configure custom domains for your services",
noCustomDomains: "No custom domains available",
createDomain: "Create Custom Domain",
editDomain: "Edit Custom Domain",
domainName: "Domain Name",
domainNamePlaceholder: "example.com",
clustersDescription: "Configure clusters for high availability",
noClusters: "No clusters available",
createCluster: "Create Cluster",
editCluster: "Edit Cluster",
clusterName: "Cluster Name",
clusterNamePlaceholder: "e.g., Production Cluster",
clusterDescription: "Description",
accessLogsDescription: "View access logs for your reverse proxy services",
noAccessLogs: "No access logs available",
servicesDescription: "Expose services securely through NetBird's reverse proxy.",
betaNoticeCloud: "NetBird's Reverse Proxy is currently in beta and available at no cost during this period. Features, functionality, and pricing are subject to change upon release.",
betaNoticeSelfHosted: "NetBird's Reverse Proxy is currently in beta. Features, functionality, and pricing are subject to change upon release.",
saveChanges: "Save Changes",
addServiceBtn: "Add Service",
editServiceBtn: "Edit Service",
cancel: "Cancel",
back: "Back",
continue: "Continue",
service: "Service",
authentication: "Authentication",
accessControl: "Access Control",
advancedSettings: "Advanced Settings"
},
dns: {
title: "DNS",
description: "Manage DNS nameservers and zones for your network",
nameservers: "Nameservers",
zones: "Zones",
settings: "DNS Settings",
createNameserver: "Create Nameserver",
editNameserver: "Edit Nameserver",
deleteNameserver: "Delete Nameserver",
confirmDeleteNameserver: "Are you sure you want to delete this nameserver?",
nameserverName: "Nameserver Name",
nameserverNamePlaceholder: "e.g., Internal DNS",
nameserverDescription: "Description",
nameserverDomains: "Domains",
noNameservers: "No nameservers available",
searchNameservers: "Search nameservers...",
createZone: "Create Zone",
editZone: "Edit Zone",
deleteZone: "Delete Zone",
confirmDeleteZone: "Are you sure you want to delete this zone?",
zoneName: "Zone Name",
zoneNamePlaceholder: "e.g., example.com",
zoneDomains: "Domains",
noZones: "No zones available",
searchZones: "Search zones...",
dnsSettings: "DNS Settings",
dnsSettingsDescription: "Manage your account's DNS settings.",
disabledManagementGroup: "Disable DNS management for these groups",
disabledManagementGroupHelp: "Peers in these groups will require manual domain name resolution",
settingsSaved: "Settings saved successfully.",
settingsSaving: "Saving the settings...",
saveChanges: "Save Changes",
tabNameserver: "Nameserver",
tabDomains: "Domains",
tabNameDescription: "Name & Description",
addDomain: "Add Domain",
distributionGroups: "Distribution Groups",
matchDomains: "Match Domains",
enableNameserver: "Enable Nameserver",
enableSearchDomainsNS: "Mark match domains as search domains",
nameserverModalDescription: "Use a nameserver to resolve domains in your network",
DNSName: "DNS Name",
enterNameserverName: "Enter a name for this nameserver.",
nameserverNameInputPlaceholder: "e.g., Public DNS",
descriptionOptional: "Description (optional)",
writeDescription: "Write a short description to add more context to this nameserver.",
descriptionPlaceholder: "e.g., Berlin office resolver for remote developers",
advertiseToGroups: "Advertise this nameserver to peers that belong to the following groups",
enableDisableNameserver: "Use this switch to enable or disable the nameserver.",
addDomainHelp: "Add domain if you want to have a specific one resolved by this nameserver.",
searchDomainHelp: "E.g., 'peer.example.com' will be accessible with 'peer'",
updateZoneDescription: "Use a zone to control domain name resolution for your network.",
domainLabel: "Domain",
domainHelp: "Enter a domain for this zone (e.g., company.internal, intra.example.com)",
domainPlaceholder: "e.g., company.internal",
distributionGroupsLabel: "Distribution Groups",
zoneGroupsHelp: "Advertise this zone and its records to peers that belong to the following groups",
enableSearchDomains: "Enable Search Domains",
searchDomainHelpZone: "E.g., 'server.company.internal' will be accessible with 'server'",
enableDNSZone: "Enable DNS Zone",
enableDisableDNSZone: "Use this switch to enable or disable the dns zone.",
addDNSZone: "Add DNS Zone",
updateDNSZone: "Update DNS Zone",
addDNSRecord: "Add DNS Record",
updateDNSRecord: "Update DNS Record",
recordType: "Record Type",
recordTypeHelp: "Select the type of record you want to add",
hostname: "Hostname",
hostnameHelp: "Enter a subdomain, wildcard or leave empty to use the primary domain.",
hostnamePlaceholder: "E.g., dev, * or leave empty for primary domain",
ipv4Address: "IPv4 Address",
ipv6Address: "IPv6 Address",
targetDomain: "Target Domain",
ttl: "TTL (Time to Live)",
sec: "Sec.",
min: "Min.",
hour: "Hour",
day: "Day",
learnMoreAbout: "Learn more about",
dnsRecords: "DNS Records",
dnsZones: "DNS Zones",
dns: "DNS"
},
networks: {
title: "Networks",
description: "Manage networks and routing for your organization",
pageDescription: "Access internal resources in LANs and VPCs without installing NetBird on every machine.",
networkName: "Network Name",
networkNamePlaceholder: "e.g., Engineering Network",
createNetwork: "Create Network",
editNetwork: "Edit Network",
deleteNetwork: "Delete Network",
confirmDeleteNetwork: "Are you sure you want to delete this network?",
noNetworks: "No networks available",
searchNetworks: "Search networks...",
searchByNameOrDescription: "Search by network name or description...",
resources: "Resources",
routingPeers: "Routing Peers",
activePoliciesCount: "{count, plural, one {# Active Policy} other {# Active Policies}}",
noActivePolicies: "No Active Policies",
goToPolicies: "Go to Policies",
createResource: "Create Resource",
editResource: "Edit Resource",
deleteResource: "Delete Resource",
confirmDeleteResource: "Are you sure you want to delete this resource?",
resourceName: "Resource Name",
resourceAddress: "Address",
resourceDescription: "Description",
noResources: "No resources available",
addRoutingPeer: "Add Routing Peer",
removeRoutingPeer: "Remove Routing Peer",
networkRoutes: "Network Routes",
routesDescription: "Access other networks like LANs and VPCs without installing NetBird on every resource.",
learnMoreAbout: "Learn more about",
newNetworksRecommendation: "We recommend using the new Networks concept to easier visualise and manage access to your resources.",
goToNetworks: "Go to Networks",
createRoute: "Create Route",
editRoute: "Edit Route",
deleteRoute: "Delete Route",
confirmDeleteRoute: "Are you sure you want to delete this route?",
routeName: "Route Name",
routeDescription: "Description",
routeNetwork: "Network",
noRoutes: "No routes available",
saveChanges: "Save Changes",
addNetwork: "Add Network",
updateNetwork: "Update Network",
networkNameLabel: "Network Name",
networkNameHelp: "Provide a unique name for the network.",
networkNameModalPlaceholder: "e.g., Office Network",
networkDescriptionLabel: "Description (optional)",
networkDescriptionHelp: "Write a short description to add more context to this network.",
networkDescriptionPlaceholder: "e.g., Berlin, Münzstraße 12",
addResource: "Add Resource",
editResourceBtn: "Edit Resource",
resourceNameLabel: "Name",
resourceNameHelp: "Set an easily identifiable name for your resource",
resourceNamePlaceholder: "e.g., Postgres Database",
resourceDescriptionLabel: "Description",
resourceDescriptionHelp: "Write a short description to add more context to this resource.",
resourceDescriptionPlaceholder: "e.g., Production, Development",
resourceGroupsLabel: "Resource Groups",
resourceGroupsHelp: "Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management.",
resourceGroupsPlaceholder: "Add or select resource group(s)...",
accessControl: "Access Control",
resourceTab: "Resource",
optionalSettings: "Optional Settings"
},
postureChecks: {
title: "Posture Checks",
description: "Define posture checks to ensure peers meet security requirements",
createPostureCheck: "Create Posture Check",
editPostureCheck: "Edit Posture Check",
deletePostureCheck: "Delete Posture Check",
confirmDelete: "Are you sure you want to delete this posture check?",
name: "Name",
namePlaceholder: "e.g., OS Check",
checkDescription: "Description",
checks: "Checks",
addCheck: "Add Check",
removeCheck: "Remove Check",
noPostureChecks: "No posture checks available",
searchPlaceholder: "Search posture checks...",
operatingSystem: "Operating System",
geoLocation: "Geo Location",
peerNetworkRange: "Peer Network Range",
osVersion: "OS Version",
minVersion: "Minimum Version",
maxVersion: "Maximum Version",
country: "Country",
selectCountry: "Select country...",
networkRange: "Network Range",
pageDescription: "Define posture checks to ensure peers meet security requirements.",
saveChanges: "Save Changes",
updatePostureCheck: "Update Posture Check",
nameAndDescription: "Name & Description",
postureCheckName: "Name of the Posture Check",
postureCheckNameHelp: "Set an easily identifiable name for your posture check.",
postureCheckNamePlaceholder: "e.g., NetBird Version > 0.25.0",
postureCheckDescriptionHelp: "Write a short description to add more context to this policy.",
postureCheckDescriptionPlaceholder: "e.g., Check if the NetBird version is bigger than 0.25.0"
},
setupKeys: {
title: "Setup Keys",
description: "Manage setup keys to onboard peers automatically",
createSetupKey: "Create Setup Key",
editSetupKey: "Edit Setup Key",
deleteSetupKey: "Delete Setup Key",
confirmDelete: "Are you sure you want to delete this setup key?",
name: "Name",
namePlaceholder: "e.g., Production Key",
expires: "Expires",
usageLimit: "Usage Limit",
usageCount: "Usage Count",
unlimited: "Unlimited",
never: "Never",
ephemeral: "Ephemeral",
pinnedOwner: "Pin to Owner",
autoAssignGroups: "Auto Assign Groups",
revoked: "Revoked",
active: "Active",
expired: "Expired",
copyKey: "Copy Key",
keyCopied: "Setup key copied to clipboard",
key: "Key",
copyWarning: "This is the only time the key will be shown. Copy it now and store it in a safe place.",
created: "Setup key '{name}' successfully created",
updated: "Setup key '{name}' successfully updated",
deleted: "Setup key '{name}' successfully deleted",
noSetupKeys: "No setup keys available",
searchPlaceholder: "Search setup keys..."
},
activity: {
title: "Activity",
description: "View audit events and activity logs",
auditEvents: "Audit Events",
auditEventsDescription: "Audit configuration changes, access policy updates, and peer registration and login events across your network.",
noEvents: "No events available",
searchPlaceholder: "Search events...",
searchByAuditNameUserPeerMeta: "Search by audit name, user, peer, meta...",
actor: "Actor",
action: "Action",
target: "Target",
timestamp: "Timestamp",
ipAddress: "IP Address",
details: "Details"
},
controlCenter: {
title: "Control Center",
description: "Overview of your network",
totalPeers: "Total Peers",
activePeers: "Active Peers",
totalPolicies: "Total Policies",
totalGroups: "Total Groups",
totalUsers: "Total Users",
totalNetworks: "Total Networks",
networkOverview: "Network Overview"
}
};

872
src/i18n/messages/zh.ts Normal file
View File

@@ -0,0 +1,872 @@
export default {
common: {
loading: "加载中...",
error: "错误",
success: "成功",
cancel: "取消",
confirm: "确认",
save: "保存",
delete: "删除",
edit: "编辑",
create: "创建",
search: "搜索",
refresh: "刷新",
close: "关闭",
yes: "是",
no: "否",
back: "返回",
next: "下一步",
previous: "上一步",
actions: "操作",
status: "状态",
name: "名称",
description: "描述",
type: "类型",
enabled: "已启用",
disabled: "已禁用",
active: "活跃",
inactive: "非活跃",
connected: "已连接",
disconnected: "已断开",
online: "在线",
offline: "离线",
unknown: "未知",
all: "全部",
none: "无",
select: "选择",
selected: "已选择",
filter: "筛选",
filters: "筛选器",
reset: "重置",
apply: "应用",
export: "导出",
import: "导入",
download: "下载",
upload: "上传",
copy: "复制",
settings: "设置",
logout: "退出登录",
login: "登录",
profile: "个人资料",
allLocations: "所有位置",
selectCityOptional: "选择城市(可选)...",
searchCity: "搜索城市...",
selectCountry: "选择国家...",
searchCountry: "搜索国家...",
learnMore: "了解更多"
},
navigation: {
controlCenter: "控制中心",
peers: "节点",
userDevices: "用户设备",
servers: "服务器",
accessControl: "访问控制",
policies: "策略",
groups: "组",
postureChecks: "姿态检查",
networkRouting: "网络路由",
networks: "网络",
routes: "路由",
reverseProxy: "反向代理",
services: "服务",
customDomains: "自定义域名",
clusters: "集群",
accessLogs: "访问日志",
dns: "DNS",
nameservers: "名称服务器",
zones: "区域",
dnsSettings: "DNS 设置",
team: "团队",
users: "用户",
serviceUsers: "服务用户",
activity: "活动",
auditEvents: "审计事件",
settings: "设置",
documentation: "文档",
helpAndSupport: "帮助和支持"
},
table: {
search: "搜索...",
noResults: "无结果",
noResultsDescription: "未找到任何结果。请尝试不同的搜索词或更改筛选条件。",
rowsPerPage: "每页行数",
previous: "上一页",
next: "下一页",
resetFilters: "重置筛选和搜索",
loading: "加载中...",
error: "加载数据时出错",
empty: "暂无数据",
selected: "已选择 {count} 项",
total: "共 {total} 项",
showing: "显示",
to: "至",
of: "共",
cancel: "取消",
rows: "行",
selectAll: "全选",
selectRow: "选择行"
},
auth: {
login: "登录",
logout: "退出登录",
signIn: "登录",
signOut: "退出",
email: "邮箱",
password: "密码",
rememberMe: "记住我",
forgotPassword: "忘记密码?",
resetPassword: "重置密码",
createAccount: "创建账户",
accountCreated: "账户已创建",
invalidCredentials: "无效的凭据",
accountBlocked: "账户已被阻止",
accountPending: "账户待审批",
sessionExpired: "会话已过期",
loginRequired: "需要登录",
unauthorized: "未授权",
forbidden: "禁止访问",
accessDenied: "访问被拒绝",
tooManyRequests: "请求过多",
tryAgainLater: "请稍后再试",
contactAdmin: "请联系管理员"
},
errors: {
generic: "发生错误",
networkError: "网络错误",
timeout: "请求超时",
notFound: "未找到",
serverError: "服务器错误",
validationError: "验证错误",
permissionDenied: "权限被拒绝",
resourceNotFound: "资源未找到",
serviceUnavailable: "服务不可用"
},
peers: {
title: "节点",
name: "名称",
ip: "IP 地址",
status: "状态",
connected: "已连接",
disconnected: "已断开",
lastSeen: "最后上线",
os: "操作系统",
version: "版本",
address: "地址",
location: "位置",
groups: "组",
users: "用户",
routes: "路由",
ssh: "SSH",
rdp: "RDP",
actions: "操作",
approve: "批准",
reject: "拒绝",
delete: "删除",
edit: "编辑",
view: "查看",
viewDetails: "查看详情",
connect: "连接",
disconnect: "断开",
approvePeer: "批准节点",
rejectPeer: "拒绝节点",
deletePeer: "删除节点",
confirmDelete: "确定要删除此节点吗?",
confirmApprove: "批准节点 '{name}'",
confirmApproveDescription: "确定要批准此节点吗?",
approveSuccess: "节点 {name} 已批准",
approveSuccessDescription: "此节点已获批准,现在可以连接到其他节点。",
approveLoading: "正在批准节点...",
approved: "已批准",
pending: "待审批",
pendingApprovals: "待审批",
noPeers: "暂无节点",
noAccessiblePeersTitle: "此节点没有可访问的节点",
noAccessiblePeersDescription: "向您的网络添加更多节点,或检查访问控制策略。",
searchPlaceholder: "按名称或 IP 搜索节点...",
selectPeer: "选择一个节点...",
noPeersAvailable: "没有可选的节点。",
noPeersMatching: "没有匹配的节点。",
updateRequired: "请将 NetBird 更新到 v0.36.6 或更高版本,才能将此节点用作路由节点。",
serialNumber: "序列号",
loginExpiration: "会话过期",
enableLoginExpiration: "启用人会话过期",
disableLoginExpiration: "禁用人会话过期",
loginExpirationUpdated: "会话过期已{state}",
loginExpirationUpdateDescription: "节点 {name} 的会话过期已成功{state}。",
loginExpirationUpdating: "正在更新会话过期...",
enableSSH: "启用 SSH 访问",
disableSSH: "禁用 SSH 访问",
disableSSHConfirmation: "禁用 SSH 访问?",
disableSSHDescription: "从 NetBird v0.61.0 开始,一旦 SSH 访问被禁用,将无法再从控制台重新启用。您需要创建一个明确的访问控制策略并更新 NetBird 客户端以恢复 SSH 功能。",
sshLearnMore: "了解更多",
browserPeerTooltip: "显示由 NetBird 浏览器客户端创建的临时节点。这些节点是临时的,将在一段时间后自动删除。",
connectTooltipOffline: "只有节点在线时才能通过 SSH 或 RDP 连接。",
expirationDisabledTooltip: "通过 setup-key 添加的所有节点都会禁用过期。",
justNow: "刚刚",
searchByNameIpOwnerOrGroup: "按名称、IP、所有者或组搜索...",
selectedCount: "已选择 {count} 个节点",
assignGroups: "分配组",
deleteAll: "全部删除",
deleteAllConfirm: "删除 {count} 个{peerWord}",
deleteAllConfirmDescription: "确定要删除这些节点吗?此操作无法撤销。",
deleteAllConfirmText: "全部删除",
peersDeleted: "节点已成功删除",
peersDeleting: "正在删除所选节点...",
groupsAssigned: "组已成功分配给节点",
groupsAssigning: "正在更新所选节点的组...",
assigningGroups: "正在分配组...",
groupsAssignedSuccess: "组已成功分配",
assignGroupsDescription: "将以下组分配给所选节点。除非选择覆盖,否则将保留先前分配的组。",
overwriteGroups: "覆盖现有组",
overwriteGroupsHelp: "使用所选组覆盖节点的现有组。先前分配的组将被移除。",
overwrite: "覆盖",
overwriteGroupsConfirm: "覆盖现有组?",
overwriteGroupsConfirmDescription: "确定要覆盖所选 {count} 个节点的现有组吗?此操作无法撤销。",
addGroups: "添加组",
assignedGroups: "已分配的组",
groupsSaved: "节点的组已成功保存",
groupsSaving: "正在保存节点的组...",
assignedGroupsDescription: "使用组来控制此节点可以访问的内容",
peerWord: "节点",
peersWord: "节点",
createPeer: "创建节点",
addPeer: "添加节点",
server: "服务器",
servers: "服务器",
userDevice: "用户设备",
userDevices: "用户设备",
operatingSystem: {
linux: "Linux",
windows: "Windows",
macos: "macOS",
android: "Android",
ios: "iOS"
},
updateAvailable: "有新版本可用",
updateDescription: "NetBird 有新版本可用。请更新客户端以获取最新功能和错误修复。",
downloadChangelog: "下载与更新日志",
dnsLabelCopied: "DNS 标签已复制到剪贴板",
ipCopied: "IP 地址已复制到剪贴板",
viewDetailsOf: "查看节点详情",
userLabel: "用户:{id}",
netbirdIp: "NetBird IP",
netbirdIpv6: "NetBird IPv6",
netbirdIpCopied: "NetBird IP 已复制到剪贴板",
netbirdIpv6Copied: "NetBird IPv6 已复制到剪贴板",
publicIp: "公网 IP",
publicIpCopied: "公网 IP 已复制到剪贴板",
domain: "域名",
region: "地区",
regionCopied: "地区已复制到剪贴板",
peerNotFoundDescription: "您尝试访问的节点不存在。可能已被删除,或您没有查看权限。请验证 URL 或返回控制台。",
tabOverview: "概览",
tabNetworkRoutes: "网络路由",
tabAccessiblePeers: "可访问节点",
tabRemoteJobs: "远程任务",
peerSaved: "节点已成功保存",
peerSaving: "正在保存节点...",
remoteAccess: "远程访问",
remoteAccessDescription: "通过 SSH 或 RDP 直接连接到此节点。",
domainName: "域名",
hostname: "主机名",
operatingSystemLabel: "操作系统",
registeredOn: "注册时间",
agentVersion: "代理版本",
uiVersion: "UI 版本",
peerIpUpdated: "NetBird 节点 IP 已成功更新",
peerIpUpdating: "正在更新节点 IP...",
peerIpv6Updated: "NetBird 节点 IPv6 已成功更新",
peerIpv6Updating: "正在更新节点 IPv6...",
noServicesForPeer: "此节点未配置服务",
addServicesDescription: "将您的服务添加到此节点,并通过 NetBird 反向代理安全地暴露它们",
editPeerName: "编辑节点名称",
editPeerNameDescription: "为您的节点设置一个易于识别的名称。",
peerNamePlaceholder: "例如AWS 服务器",
domainNamePreview: "域名预览",
domainNamePreviewHelp: "如果域名已存在,我们会添加一个递增数字后缀。",
userDevicesDescription: "笔记本电脑、手机和其他由用户操作的私人设备,通常在用户使用 SSO 登录时添加。",
learnMore: "了解更多",
addNewDeviceTitle: "添加新设备到您的网络",
addNewDeviceDescription: "首先,安装 NetBird 并使用您的电子邮件账户登录。之后您应该已连接。如有其他问题,请查看我们的",
installationGuide: "安装指南",
serversDescription: "服务器、虚拟机、自治代理和其他无用户的无人值守机器,通常使用安装密钥注册。",
addNewServerTitle: "添加新服务器到您的网络",
addNewServerDescription: "首先,在服务器上安装 NetBird 并使用安装密钥注册。如有其他问题,请查看我们的",
saveGroups: "保存组",
getStarted: "开始使用 NetBird",
getStartedDescription: "看起来您还没有任何连接的设备。\n开始使用向您的网络中添加一台设备。",
learnMoreInOur: "在我们的",
gettingStartedGuide: "入门指南"
},
policies: {
title: "策略",
name: "名称",
description: "描述",
enabled: "已启用",
disabled: "已禁用",
rules: "规则",
sources: "源",
destinations: "目标",
actions: "操作",
create: "创建策略",
edit: "编辑策略",
delete: "删除策略",
enable: "启用",
disable: "禁用",
confirmDelete: "确定要删除此策略吗?",
noPolicies: "暂无策略",
searchPlaceholder: "搜索策略...",
policyName: "策略名称",
policyNameHelp: "为策略设置一个易于识别的名称",
policyNamePlaceholder: "例如:工程师访问",
enabledDescription: "启用或禁用此策略",
rule: "规则",
addRule: "添加规则",
removeRule: "删除规则",
source: "源",
destination: "目标",
protocol: "协议",
action: "动作",
port: "端口",
portRange: "端口范围",
direction: "方向",
bidirectional: "双向",
oneWay: "单向",
allow: "允许",
deny: "拒绝",
allProtocols: "所有协议",
tcp: "TCP",
udp: "UDP",
icmp: "ICMP",
any: "任意",
netbirdSsh: "NetBird SSH",
protoPorts: "协议与端口",
portsPlaceholder: "例如443",
addPolicy: "添加策略",
createNewPolicy: "创建新策略",
createNewPolicyDescription: "看起来您还没有任何策略。策略可以按特定协议和端口允许连接。",
noPoliciesForGroup: "此组尚未在任何策略中使用",
noPoliciesForGroupDescription: "将组作为策略中的源或目标分配,以在此处查看其列表。",
temporaryPoliciesTooltip: "显示由 NetBird 浏览器客户端创建的临时策略。这些策略是临时的,将在一段时间后自动删除。",
learnMoreAbout: "了解更多关于",
accessControls: "访问控制",
policyActions: "策略操作",
policyEnabledSuccess: "策略已成功启用",
policyDisabledSuccess: "策略已成功禁用",
confirmDeleteTitle: "删除 '{name}'",
confirmDeleteDescription: "确定要删除此访问控制策略吗?此操作无法撤销。",
updatePolicy: "更新访问控制策略",
modalDescription: "使用此策略限制对资源组的访问。",
tabPolicy: "策略",
tabNameDescription: "名称与描述",
protocolHelp: "仅允许指定的网络协议。要更改流量方向和端口,请选择 TCP 或 UDP 协议。",
selectProtocol: "选择协议...",
netbirdSshHelp: "为 SSH 专用策略选择 NetBird SSH 以进行细粒度访问控制,或使用端口 22 的 TCP 进行基本网络级 SSH 访问",
sourceHelp: "通常是一组用户设备(例如开发人员、市场人员)或点对点连接中将访问目标的单个设备。",
selectSource: "选择源...",
destinationHelp: "通常是一组节点或资源(例如服务器、数据库、内部服务),将由源访问。也可以是单个节点或资源。",
selectDestination: "选择目标...",
resourcesBidirectionalWarning: "某些目标组包含资源。资源仅支持入站流量,无法发起连接。",
sshResourceWarning: "SSH 访问仅适用于节点,不适用于路由资源。请确保您的目标组包含用于 SSH 连接的节点。",
sshAccess: "SSH 访问",
sshAccessHelp: "选择'完全访问'以允许任何本地用户进行 SSH或选择'受限访问'以指定每个组允许使用的本地用户。",
ports: "端口",
portsHelp: "仅允许对指定端口的网络流量和访问。选择 1 到 65535 之间的端口或端口范围。",
enablePolicy: "启用策略",
enablePolicyHelp: "使用此开关启用或禁用策略。",
ruleName: "规则名称",
ruleNameHelp: "为策略设置一个易于识别的名称。",
ruleNamePlaceholder: "例如:开发人员到服务器",
policyDescriptionLabel: "描述(可选)",
policyDescriptionHelp: "写一个简短的描述为此策略添加更多上下文。",
policyDescriptionPlaceholder: "例如:允许开发人员访问服务器,并允许服务器访问开发人员。",
accessControlDescription: "创建规则以管理网络中的访问,并定义节点可以连接的内容。",
policyCreated: "策略 '{name}' 创建成功",
policyUpdated: "策略 '{name}' 更新成功",
policyDeleted: "策略 '{name}' 已删除",
policyEnableLoading: "正在启用策略...",
policyDisableLoading: "正在禁用策略...",
policySaveLoading: "正在保存策略...",
policyDeleteLoading: "正在删除策略..."
},
groups: {
title: "组",
name: "名称",
peers: "节点",
users: "用户",
policies: "策略",
resources: "资源",
create: "创建组",
createDescription: "创建一个组来管理和组织网络中的访问权限",
createSuccess: "组 '{name}' 创建成功",
creating: "正在创建组...",
nameHelp: "为组设置一个易于识别的名称",
namePlaceholder: "例如:开发人员",
learnMore: "了解更多关于",
edit: "编辑组",
delete: "删除组",
confirmDelete: "确定要删除此组吗?",
noGroups: "暂无组",
searchPlaceholder: "搜索组...",
cancel: "取消",
addPeerToGroup: "添加节点",
addUserToGroup: "添加用户",
editName: "编辑名称",
groupName: "组名称",
groupUpdated: "组 '{name}' 更新成功",
groupUpdating: "正在更新组...",
assignedPeers: "已分配的节点",
assignedUsers: "已分配的用户",
assignedResources: "已分配的资源",
assignPeersDescription: "使用节点来控制此组可以访问的内容",
assignUsersDescription: "使用用户来控制此组可以访问的内容",
assignResourcesDescription: "使用资源来控制此组可以访问的内容",
addPeerToGroupTitle: "将节点添加到组",
addUserToGroupTitle: "将用户添加到组",
addResourcesToGroupTitle: "将资源添加到组",
searchPeer: "搜索节点...",
searchUser: "搜索用户...",
searchResource: "搜索资源...",
used: "已使用",
unused: "未使用",
usage: "使用情况",
inUse: "正在使用",
nameservers: "名称服务器",
zones: "区域",
routes: "路由",
setupKeys: "安装密钥",
viewDetails: "查看详情",
rename: "重命名",
groupsDescription: "将节点、用户和资源组织到组中以管理访问。",
allGroups: "所有组",
nGroups: "{n} 个组",
noGroupsMatching: "没有匹配的组。请尝试其他搜索词。",
noGroupsAvailable: "似乎您还没有任何组。"
},
users: {
title: "用户",
name: "名称",
email: "邮箱",
role: "角色",
status: "状态",
lastLogin: "最后登录",
actions: "操作",
invite: "邀请用户",
edit: "编辑用户",
delete: "删除用户",
block: "阻止",
unblock: "取消阻止",
approve: "批准",
reject: "拒绝",
resendInvite: "重新发送邀请",
confirmDelete: "确定要删除此用户吗?",
noUsers: "暂无用户",
searchPlaceholder: "搜索用户...",
owner: "所有者",
user: "用户",
admin: "管理员",
blocked: "已阻止",
invited: "已邀请",
active: "活跃",
inactive: "非活跃",
roleOwner: "所有者",
roleAdmin: "管理员",
roleUser: "用户",
roleServiceUser: "服务用户",
inviteUser: "邀请用户",
inviteUserDescription: "邀请用户加入您的 NetBird 网络",
userEmail: "用户邮箱",
userEmailPlaceholder: "user@example.com",
userEmailHelp: "输入要邀请的用户的电子邮件地址",
group: "组",
selectGroup: "选择组...",
selectRole: "选择角色...",
autoSSODescription: "此用户将在首次登录时通过 SSO 自动添加。",
inviteSuccess: "用户 {email} 已收到邀请",
inviting: "正在邀请用户...",
userBlocked: "用户 {name} 已被阻止",
userUnblocked: "用户 {name} 已取消阻止",
blocking: "正在阻止用户...",
unblocking: "正在取消阻止...",
userDeleted: "用户 {name} 已删除",
deleting: "正在删除用户...",
inviteResent: "邀请已重新发送",
resending: "正在重新发送邀请...",
userApproved: "用户 {name} 已批准",
approving: "正在批准用户...",
userRejected: "用户 {name} 已拒绝",
rejecting: "正在拒绝用户...",
copyUserId: "复制用户 ID",
copyUserIdSuccess: "用户 ID 已复制到剪贴板",
networkAdmin: "网络管理员",
billingAdmin: "账单管理员",
auditor: "审计员",
pending: "待审批",
lastLoginOn: "最后登录于",
showInvites: "显示邀请",
addNewUsers: "添加新用户",
addNewUsersDescription: "看起来您还没有任何用户。开始使用,邀请用户加入您的账户。",
addUser: "添加用户",
localAuthDisabled: "本地身份验证已禁用。请使用您的 IdP 进行身份验证。",
team: "团队",
usersPageDescription: "管理用户及其权限。同域名电子邮件用户在首次登录时会自动添加。"
},
serviceUsers: {
title: "服务用户",
name: "名称",
userId: "用户 ID",
createdAt: "创建时间",
actions: "操作",
create: "创建服务用户",
edit: "编辑服务用户",
delete: "删除服务用户",
noServiceUsers: "暂无服务用户",
searchPlaceholder: "搜索服务用户...",
confirmDelete: "确定要删除此服务用户吗?",
serviceUserName: "服务用户名称",
serviceUserNamePlaceholder: "例如CI/CD 流水线",
serviceUserNameHelp: "为此服务用户设置一个描述性名称",
copyUserId: "复制用户 ID",
autoGroups: "自动分配组",
autoGroupsDescription: "自动将这些组分配给使用此服务用户的节点",
copySuccess: "已复制到剪贴板",
userCreated: "服务用户 '{name}' 创建成功",
userUpdated: "服务用户 '{name}' 更新成功",
userDeleted: "服务用户 '{name}' 已删除",
createLoading: "正在创建服务用户...",
updateLoading: "正在更新服务用户...",
deleteLoading: "正在删除服务用户...",
serviceUsersDescription: "使用服务用户创建 API 令牌,避免丢失自动化访问。",
serviceUsersEmptyDescription: "看起来您还没有任何服务用户。开始使用,创建一个服务用户。",
blocked: "已阻止"
},
settings: {
title: "设置",
general: "常规",
account: "账户",
security: "安全",
notifications: "通知",
appearance: "外观",
language: "语言",
theme: "主题",
darkMode: "深色模式",
lightMode: "浅色模式",
systemDefault: "跟随系统",
save: "保存设置",
cancel: "取消",
reset: "重置为默认",
saved: "设置已保存",
saving: "正在保存设置...",
profile: "个人资料",
changePassword: "修改密码",
currentPassword: "当前密码",
newPassword: "新密码",
confirmPassword: "确认密码",
twoFactor: "两步验证",
enable2FA: "启用两步验证",
disable2FA: "禁用两步验证",
authentication: "身份验证",
setupKeys: "安装密钥",
identityProviders: "身份提供者",
groupsTab: "组",
permissions: "权限",
networksTab: "网络",
clients: "客户端",
dangerZone: "危险区域"
},
reverseProxy: {
title: "反向代理",
description: "配置反向代理服务和域名",
services: "服务",
customDomains: "自定义域名",
clusters: "集群",
accessLogs: "访问日志",
createService: "创建服务",
editService: "编辑服务",
deleteService: "删除服务",
confirmDeleteService: "确定要删除此服务吗?",
serviceName: "服务名称",
serviceNamePlaceholder: "例如:我的 Web 应用",
source: "源节点",
destination: "目标",
port: "端口",
protocol: "协议",
enabled: "已启用",
noServices: "暂无服务",
searchServices: "搜索服务...",
domain: "域名",
path: "路径",
target: "目标",
selectSourcePeer: "选择源节点...",
selectTargetPeer: "选择目标节点...",
selectPort: "选择端口...",
customDomainsDescription: "为您的服务配置自定义域名",
noCustomDomains: "暂无自定义域名",
createDomain: "创建自定义域名",
editDomain: "编辑自定义域名",
domainName: "域名",
domainNamePlaceholder: "example.com",
clustersDescription: "为高可用性配置集群",
noClusters: "暂无集群",
createCluster: "创建集群",
editCluster: "编辑集群",
clusterName: "集群名称",
clusterNamePlaceholder: "例如:生产集群",
clusterDescription: "描述",
accessLogsDescription: "查看反向代理服务的访问日志",
noAccessLogs: "暂无访问日志",
servicesDescription: "通过 NetBird 的反向代理安全地暴露服务。",
betaNoticeCloud: "NetBird 的反向代理目前处于测试阶段,在此期间免费使用。功能、特性和定价可能在上线时发生变化。",
betaNoticeSelfHosted: "NetBird 的反向代理目前处于测试阶段。功能、特性和定价可能在上线时发生变化。",
saveChanges: "保存更改",
addServiceBtn: "添加服务",
editServiceBtn: "编辑服务",
cancel: "取消",
back: "返回",
continue: "继续",
service: "服务",
authentication: "身份验证",
accessControl: "访问控制",
advancedSettings: "高级设置"
},
dns: {
title: "DNS",
description: "管理网络的 DNS 名称服务器和区域",
nameservers: "名称服务器",
zones: "区域",
settings: "DNS 设置",
createNameserver: "创建名称服务器",
editNameserver: "编辑名称服务器",
deleteNameserver: "删除名称服务器",
confirmDeleteNameserver: "确定要删除此名称服务器吗?",
nameserverName: "名称服务器名称",
nameserverNamePlaceholder: "例如:内部 DNS",
nameserverDescription: "描述",
nameserverDomains: "域名",
noNameservers: "暂无名称服务器",
searchNameservers: "搜索名称服务器...",
createZone: "创建区域",
editZone: "编辑区域",
deleteZone: "删除区域",
confirmDeleteZone: "确定要删除此区域吗?",
zoneName: "区域名称",
zoneNamePlaceholder: "例如example.com",
zoneDomains: "域名",
noZones: "暂无区域",
searchZones: "搜索区域...",
dnsSettings: "DNS 设置",
dnsSettingsDescription: "管理您账户的 DNS 设置。",
disabledManagementGroup: "对这些组禁用 DNS 管理",
disabledManagementGroupHelp: "这些组中的节点将需要进行手动域名解析",
settingsSaved: "设置已成功保存。",
settingsSaving: "正在保存设置...",
saveChanges: "保存更改",
nameserversDescription: "在 NetBird 网络中为域名解析添加名称服务器。",
zonesDescription: "为您的网络配置 DNS 区域。",
tabNameserver: "名称服务器",
tabDomains: "域名",
tabNameDescription: "名称与描述",
addDomain: "添加域名",
distributionGroups: "分配组",
matchDomains: "匹配域名",
enableNameserver: "启用名称服务器",
enableSearchDomainsNS: "将匹配域名标记为搜索域名",
nameserverModalDescription: "使用名称服务器解析网络中的域名。",
DNSName: "DNS 名称",
enterNameserverName: "为此名称服务器输入名称。",
nameserverNameInputPlaceholder: "例如:公共 DNS",
descriptionOptional: "描述(可选)",
writeDescription: "写一个简短的描述为此名称服务器添加更多上下文。",
descriptionPlaceholder: "例如:柏林办公室远程开发人员的解析器",
advertiseToGroups: "将此名称服务器广播给属于以下组的节点。",
enableDisableNameserver: "使用此开关启用或禁用名称服务器。",
addDomainHelp: "如果要让特定域名由此名称服务器解析,请添加域。",
searchDomainHelp: "例如,'peer.example.com' 将可通过'peer'访问",
updateZoneDescription: "使用区域控制网络的域名解析。",
domainLabel: "域名",
domainHelp: "为此区域输入域名(例如 company.internal, intra.example.com",
domainPlaceholder: "例如company.internal",
distributionGroupsLabel: "分配组",
zoneGroupsHelp: "将此区域及其记录广播给属于以下组的节点。",
enableSearchDomains: "启用搜索域名",
searchDomainHelpZone: "例如,'server.company.internal' 将可通过'server'访问",
enableDNSZone: "启用 DNS 区域",
enableDisableDNSZone: "使用此开关启用或禁用 DNS 区域。",
addDNSZone: "添加 DNS 区域",
updateDNSZone: "更新 DNS 区域",
addDNSRecord: "添加 DNS 记录",
updateDNSRecord: "更新 DNS 记录",
recordType: "记录类型",
recordTypeHelp: "选择要添加的记录类型",
hostname: "主机名",
hostnameHelp: "输入子域名、通配符或留空以使用主域名。",
hostnamePlaceholder: "例如dev, * 或留空使用主域名",
ipv4Address: "IPv4 地址",
ipv6Address: "IPv6 地址",
targetDomain: "目标域名",
ttl: "TTL生存时间",
sec: "秒",
min: "分钟",
hour: "小时",
day: "天",
learnMoreAbout: "了解更多关于",
dnsRecords: "DNS 记录",
dnsZones: "DNS 区域",
dns: "DNS"
},
networks: {
title: "网络",
description: "管理组织的网络和路由",
pageDescription: "无需在每台机器上安装 NetBird即可访问 LAN 和 VPC 中的内部资源。",
networkName: "网络名称",
networkNamePlaceholder: "例如:工程网络",
createNetwork: "创建网络",
editNetwork: "编辑网络",
deleteNetwork: "删除网络",
confirmDeleteNetwork: "确定要删除此网络吗?",
noNetworks: "暂无网络",
searchNetworks: "搜索网络...",
searchByNameOrDescription: "按网络名称或描述搜索...",
resources: "资源",
routingPeers: "路由节点",
activePoliciesCount: "{count} 个活跃策略",
noActivePolicies: "无活跃策略",
goToPolicies: "前往策略",
createResource: "创建资源",
editResource: "编辑资源",
deleteResource: "删除资源",
confirmDeleteResource: "确定要删除此资源吗?",
resourceName: "资源名称",
resourceAddress: "地址",
resourceDescription: "描述",
noResources: "暂无资源",
addRoutingPeer: "添加路由节点",
removeRoutingPeer: "移除路由节点",
networkRoutes: "网络路由",
routesDescription: "访问其他网络,如局域网和 VPC无需在每个资源上安装 NetBird。",
learnMoreAbout: "了解更多关于",
newNetworksRecommendation: "我们建议使用新的网络概念来更轻松地可视化和管理对资源的访问。",
goToNetworks: "前往网络",
createRoute: "创建路由",
editRoute: "编辑路由",
deleteRoute: "删除路由",
confirmDeleteRoute: "确定要删除此路由吗?",
routeName: "路由名称",
routeDescription: "描述",
routeNetwork: "网络",
noRoutes: "暂无路由",
saveChanges: "保存更改",
addNetwork: "添加网络",
updateNetwork: "更新网络",
networkNameLabel: "网络名称",
networkNameHelp: "为网络提供唯一名称。",
networkNameModalPlaceholder: "例如:办公室网络",
networkDescriptionLabel: "描述(可选)",
networkDescriptionHelp: "写一个简短的描述为此网络添加更多上下文。",
networkDescriptionPlaceholder: "例如柏林Münzstraße 12",
addResource: "添加资源",
editResourceBtn: "编辑资源",
resourceNameLabel: "名称",
resourceNameHelp: "为资源设置一个易于识别的名称",
resourceNamePlaceholder: "例如Postgres 数据库",
resourceDescriptionLabel: "描述",
resourceDescriptionHelp: "写一个简短的描述为此资源添加更多上下文。",
resourceDescriptionPlaceholder: "例如:生产环境、开发环境",
resourceGroupsLabel: "资源组",
resourceGroupsHelp: "将此资源添加到组例如数据库、Web 服务器)中,并在访问策略中引用该组以简化管理。",
resourceGroupsPlaceholder: "添加或选择资源组...",
accessControl: "访问控制",
resourceTab: "资源",
optionalSettings: "可选设置"
},
postureChecks: {
title: "姿态检查",
description: "定义姿态检查以确保节点满足安全要求",
createPostureCheck: "创建姿态检查",
editPostureCheck: "编辑姿态检查",
deletePostureCheck: "删除姿态检查",
confirmDelete: "确定要删除此姿态检查吗?",
name: "名称",
namePlaceholder: "例如:操作系统检查",
checkDescription: "描述",
checks: "检查项",
addCheck: "添加检查",
removeCheck: "删除检查",
noPostureChecks: "暂无姿态检查",
searchPlaceholder: "搜索姿态检查...",
operatingSystem: "操作系统",
geoLocation: "地理位置",
peerNetworkRange: "节点网络范围",
osVersion: "操作系统版本",
minVersion: "最低版本",
maxVersion: "最高版本",
country: "国家",
selectCountry: "选择国家...",
networkRange: "网络范围",
pageDescription: "定义姿态检查以确保节点满足安全要求。",
saveChanges: "保存更改",
updatePostureCheck: "更新姿态检查",
nameAndDescription: "名称与描述",
postureCheckName: "姿态检查名称",
postureCheckNameHelp: "为姿态检查设置一个易于识别的名称。",
postureCheckNamePlaceholder: "例如NetBird 版本 > 0.25.0",
postureCheckDescriptionHelp: "写一个简短的描述为此策略添加更多上下文。",
postureCheckDescriptionPlaceholder: "例如:检查 NetBird 版本是否大于 0.25.0"
},
setupKeys: {
title: "安装密钥",
description: "管理安装密钥以自动加入节点",
createSetupKey: "创建安装密钥",
editSetupKey: "编辑安装密钥",
deleteSetupKey: "删除安装密钥",
confirmDelete: "确定要删除此安装密钥吗?",
name: "名称",
namePlaceholder: "例如:生产密钥",
expires: "过期时间",
usageLimit: "使用次数限制",
usageCount: "已使用次数",
unlimited: "无限制",
never: "永不过期",
ephemeral: "临时",
pinnedOwner: "绑定所有者",
autoAssignGroups: "自动分配组",
revoked: "已撤销",
active: "活跃",
expired: "已过期",
copyKey: "复制密钥",
keyCopied: "安装密钥已复制到剪贴板",
key: "密钥",
copyWarning: "这是密钥唯一会显示的时间。请立即复制并妥善保管。",
created: "安装密钥 '{name}' 创建成功",
updated: "安装密钥 '{name}' 更新成功",
deleted: "安装密钥 '{name}' 已删除",
noSetupKeys: "暂无安装密钥",
searchPlaceholder: "搜索安装密钥..."
},
activity: {
title: "活动",
description: "查看审计事件和活动日志",
auditEvents: "审计事件",
auditEventsDescription: "审计整个网络中的配置变更、访问策略更新以及节点注册和登录事件。",
noEvents: "暂无事件",
searchPlaceholder: "搜索事件...",
searchByAuditNameUserPeerMeta: "按审计名称、用户、节点、元数据搜索...",
actor: "操作者",
action: "操作",
target: "目标",
timestamp: "时间戳",
ipAddress: "IP 地址",
details: "详情"
},
controlCenter: {
title: "控制中心",
description: "网络概览",
totalPeers: "节点总数",
activePeers: "活跃节点",
totalPolicies: "策略总数",
totalGroups: "组总数",
totalUsers: "用户总数",
totalNetworks: "网络总数",
networkOverview: "网络概览"
}
};

10
src/i18n/navigation.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createNavigation } from 'next-intl/navigation';
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh'],
defaultLocale: 'zh'
});
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);

13
src/i18n/request.ts Normal file
View File

@@ -0,0 +1,13 @@
import { getRequestConfig } from 'next-intl/server';
import en from './messages/en';
import zh from './messages/zh';
const messages = { en, zh };
export default getRequestConfig(async ({ locale }) => {
const resolvedLocale = locale || 'zh';
return {
locale: resolvedLocale,
messages: messages[resolvedLocale as keyof typeof messages] || messages.zh
};
});

6
src/i18n/routing.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh'],
defaultLocale: 'zh'
});

View File

@@ -10,6 +10,8 @@ export interface Account {
user_approval_required: boolean;
};
peer_login_expiration_enabled: boolean;
peer_expose_enabled?: boolean;
peer_expose_groups?: string[];
peer_login_expiration: number;
peer_inactivity_expiration_enabled: boolean;
peer_inactivity_expiration: number;
@@ -24,7 +26,11 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
};
onboarding?: AccountOnboarding;
}

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