Compare commits

...

473 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Five fixes from the first cut:

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

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

3. Trimmed wordy Direct upstream help text.

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

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

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

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

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

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

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

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

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

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

Two display bugs for private services:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* disable button when no group were selected

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

* Feature/private service expose update (#651)

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

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

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

---------

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

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

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

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

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

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

* update reverse proxy documentation links to latest anchors

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

---------

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

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

* fix visibility check

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

* Fix isNetBirdHosted

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

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

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

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

* Fix coderabbit comments

* Fix coderabbit comments

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

* Fix initialResource fallback and UDP session_idle_timeout

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

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

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

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

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

* Remove redundant listen port from L4 target cell

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

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

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

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

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

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

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

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

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

* Update src/modules/settings/ClientSettingsTab.tsx

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

---------

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

* Fix coderabbit comment

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

* Adjust resource modal and add tooltips

* Prevent nextjs navigation trigger on tab change

* Update wording

* add acl into resource

* Refactor resource policies

* Add prop to hide group edit and disable redirect

* Add skeleton loader to network page

* Create policy for new resources

* Show existing policies if groups are matching

* Add confirm dialog after creating resource without policy

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

* Add callout when selecting resource groups containing policies

* Add dialog if deleting policies containing resources

* Fix stale policies and new group creation in resource modal

* Remove whitespace

* Fix sort

* Cleanup

* Address coderabbit comments

* Fix policy alignment

* Fix initial resource

* disable selector if user did not select  resource groups

* Consider current resource when editing / deleting policy

* Remove unused mutate

* Fix dot position

* Remove ask for policy

* Fix policy index

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

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

* add reverse proxy activities

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

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

* prevent false positive group report

* add docs link

* allow save when groups are added to the setting

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

* mv expose settings from extra settings

---------

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

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

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

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

* docs: spell error fixes in readme

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

* Update Node in workflow

* Fix rabbit comments

* Fix types

* Add engines field
2026-02-02 15:34:23 +01:00
Misha Bragin
ea148545e8 Disable local users when LocalAuthDisabled = true (#546)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-02-01 14:31:57 +01:00
Misha Bragin
d2febbf27b Fix version comparison (#544)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 14:13:27 +01:00
Misha Bragin
615b4487ad Point to the right upgrade doc (#543)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-27 12:23:56 +01:00
Misha Bragin
a7c7800916 Add invite notification count badge (#542) 2026-01-27 10:44:39 +01:00
Eduard Gert
3d51e0893e Update announcement (#538)
* Update announcement

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

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

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

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

* update job ui

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

* update job event description

---------

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

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

* Fix bad state while redirect

* Prevent redirect to /setup if already on /setup

* Fix loading state
2026-01-08 15:01:45 +01:00
Eduard Gert
52fd984912 Add user view to control center (#525)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-01-07 17:53:55 +01:00
Misha Bragin
83e3159ee4 Configure Identity Providers in the UI (#523)
* Add user creation with password copy

* Add initial identity provider view

* Add IdP logos

* Add IdP id to user

* Add IdP logo to user obj

* Fix okta icon

* Return callback URL when creating an IdP

* Create user for self-hosted

* Clear up password from the state

* Show IdPs and create user when enabled

* Fetch IdPs only when embedded idp is enabled

* Update src/app/(dashboard)/settings/page.tsx

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

* Update src/app/(dashboard)/settings/page.tsx

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Update src/modules/settings/IdentityProvidersTab.tsx

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

* Update src/modules/settings/IdentityProviderModal.tsx

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

* Rename IdentityProvider to SSOIdentityProvider

* Fix build and extract icons

* Fix initial onboarding

* Add icons

* Move name to the top

* Fix setup wizard background color

* Update instance setup ui

* Update instance setup ui

* Use input component

* Move idp label and icons

* Fix setup wizard width

* Add authentik and keycloak

* Add idp hints

* Handle idp permissions

* Consider selfhosted instances when checking if netbird is hosted

* Update redirect

* Add max retries to redirect

* Require new secret when clientid changed

* Add callback URL on the idp creation step

* Add idp activity events

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-01-07 14:43:30 +01:00
Eduard Gert
bf81aeb02d Add fine-grained ssh policy (#522)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add fine-grained ssh policy

* Update version text

* Fix coderabbit comment
2025-12-30 09:27:17 +01:00
Eduard Gert
b058e66e32 Add auto update setting (#519)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-29 12:38:50 +01:00
Eduard Gert
8d6b617cbd Update NextJS to 14.2.35 (#518)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-12-22 11:02:29 +01:00
Eduard Gert
47db655e9f Update eslint and tailwind (#515)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-27 17:38:18 +01:00
dependabot[bot]
0661cbf9f4 Bump js-yaml from 4.1.0 to 4.1.1 (#509)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-27 15:25:50 +01:00
Eduard Gert
240a96fa8b Add onboarding for new accounts (#514) 2025-11-27 14:49:58 +01:00
Eduard Gert
43bc069a49 Increase ssh detection timeout (#512)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-21 10:32:50 +01:00
Eduard Gert
936de0f4f3 Add ssh policy info for peers (#511)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-20 14:29:14 +01:00
Eduard Gert
d81b75a946 Bump browser ssh versions for ssh rewrite (#510)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Bump browser ssh versions for ssh rewrite

* Remove cypress temporary
2025-11-18 17:07:58 +01:00
Eduard Gert
a632eeeef0 Remove dns0eu (#508) 2025-11-10 14:21:58 +01:00
Eduard Gert
e2219aeea0 Add group update activity event (#504)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-11-10 10:50:04 +01:00
Eduard Gert
63f4c69eb4 Fix native ssh detection (#505) 2025-11-07 09:33:58 +01:00
Eduard Gert
b1af256296 Add wasm client version (#503) 2025-11-06 10:59:41 +01:00
Eduard Gert
4027894a2e Feature/groups page (#498)
* move our group membership from the settings menu, into the Team menu

* add action to the table and new group page

* update group page and return group settings to settings menu

* new update

* fix bug

* group action: add peer to group

* group action: add user to group

* Update wording, redirect to group page after creation

* Add better table loading skeleton

* Adjust group name cell

* Update wording

* Update sort order

* Refactor

* Merge main

* Fix button height

* Fix resources table

* Adjust table loading skeleton

* Adjust table loading skeleton

* Add loading to tab triggers

* Update meta

* Update group location

* Fix rename

* Refactor group details

* Fix linked peers

* Fix group usage

* Fix incrementing peer count

* Prevent renaming to already existing group

* Fix group name click

* Update group nav

* Make group table cells clickable

* Fix breadcrumbs

* Update wording

* Add confirmation before removing users from group

* Add permissions

* Add initial group for network routes

* Add acl and routing peer groups

---------

Co-authored-by: aliamerj <aliamer19ali@gmail.com>
2025-11-05 12:08:49 +01:00
Yanis64
af90792595 Add multi-group support for JWT allow groups with tag system (#500)
* fix: add multi-group support for JWT allow groups with tag system

* Update src/modules/settings/GroupsTab.tsx to use the Badge component

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

* chore(GroupsTab): import Badge components

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-11-03 16:09:15 +01:00
Eduard Gert
9a401733b3 Fix toggle for p2p policies (#501)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-31 13:21:23 +01:00
Eduard Gert
07b6895380 Sync SSH & RDP changes (#495)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-16 14:44:26 +02:00
Eduard Gert
9e2e38764e Add control center (#494)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
Maycon Santos
d9fb379abf Enable connect buttons (#493)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-06 16:23:00 -03:00
Eduard Gert
831673d0d6 Sync with cloud (#491)
Some checks failed
build and push / build_n_push (push) Has been cancelled
implements a "Sync with cloud" functionality that includes various UI improvements, code refactoring, and component extractions. The changes focus on enhancing the user interface, improving code organization, and adding new features for remote access and activity tracking.

- Refactors inline components into reusable shared components
- Adds new activity tracking for group operations
- Updates remote access configuration and UI components
- Enhances styling and layout for better user experience
2025-10-03 14:37:11 +02:00
Maycon Santos
bc4aac10aa Add browser client support (#490)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Sync wasm rdp and ssh client

* sync package-lock

* remove msp ref

* add ephemeral info
2025-10-02 00:41:08 +02:00
Eduard Gert
38e14a6c64 Allow delete groups issued by jwt (#487)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-09-10 14:41:19 +02:00
Maycon Santos
b79c6615b4 Add user approval feature (#486)
Some checks failed
build and push / build_n_push (push) Has been cancelled
implements a user approval feature that allows administrators to manually approve new users before they can access the system. The feature adds approval workflow controls and error handling for blocked/pending users.

Adds user approval toggle in authentication settings
Implements approve/reject actions for pending users in the users table
Creates error page for blocked/pending approval scenarios
2025-09-02 15:25:30 +02:00
hakansa
5d4e491611 Add skip_auto_apply feature to exit nodes and update related components (#484)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-08-20 13:11:45 +02:00
Eduard Gert
9b1f920863 Update dependencies (#483) 2025-08-19 17:32:22 +02:00
Eduard Gert
7c7f0a0f10 Add network range (#482)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-08-13 14:18:59 +02:00
braginini
76541c701c Update LICENSE 2025-08-05 11:19:13 +02:00
Eduard Gert
d2046fee21 Add resource search, unidirectional policies for all/icmp and dns0 template (#479)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-07-30 17:01:49 +02:00
Eduard Gert
8e2cbe1d2a Add support for port ranges (#475)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-06-20 10:26:53 +02:00
Eduard Gert
8a08583225 Do not redirect on same page (#471)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-06-05 19:30:25 +02:00
Eduard Gert
1defac4e34 Update wording for dns domain, macOS and Windows install steps (#470)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update wording for dns domain, macOS and Windows install steps

* Update src/modules/settings/NetworkSettingsTab.tsx

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>

---------

Co-authored-by: Viktor Liu <17948409+lixmal@users.noreply.github.com>
2025-06-05 13:16:42 +02:00
Eduard Gert
fa68f98cd0 Remove permission for add peer button (#469) 2025-06-05 13:12:38 +02:00
Eduard Gert
3f6e4c4e4f Add lazy connection setting (#465) 2025-06-04 11:54:18 +02:00
Eduard Gert
0e2661caea Merge cloud changes to public (#462)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add changes from dashboard cloud

* Add changes from dashboard cloud

* Update next.js version

* Small formatting changes

* remove unknown permission check

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2025-05-05 15:30:28 +02:00
Eduard Gert
d7c5f7e183 Hide update available for mobile devices (#106) (#460)
Some checks failed
build and push / build_n_push (push) Has been cancelled
(cherry picked from commit 7f248ae060385acb1245591bd46e2bb6d53ed908)
2025-04-28 11:58:26 +02:00
Eduard Gert
ebbe865ce0 Add custom dns domain (#458)
* Update domain validator

* Add custom dns domain
2025-04-28 11:58:16 +02:00
Eduard Gert
6c0ab88488 Update domain validator (#459) 2025-04-28 11:53:44 +02:00
Eduard Gert
a50576d851 Fix nameserver port input for Safari (#456) 2025-04-28 11:21:25 +02:00
Eduard Gert
676250266c Fix browse posture checks table filters (#448) 2025-04-07 10:23:07 +02:00
Vladislav Tropnikov
042c65a652 Add display of ID if user does not have email (#450)
* Add display of ID if user does not have email

* Update PeerNameCell.tsx

* Add more possible id parameters

* Hide user if there is nothing

* change id order

* Keep default behavior
2025-03-27 17:30:26 +01:00
Misha Bragin
96f2d39e54 Add CLA 2025-03-18 15:58:05 +01:00
Eduard Gert
61e11d3740 Apply recent cloud changes (#447)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add resource description, add single resource for acl, add icons for group badges, add inactivity expiration

* Add extra dns labels, remove routing restriction
2025-02-21 15:53:40 +01:00
Edouard Vanbelle
c8e3b50f1b Display serial number on peer information and on peers table (#444)
* display serial number on peer information and on peers table

  * add serial on peers list (included in OS information to minimize informations)
  * permit a lookup via serial number
  * add serial on peer information

* Update os icon to match existing one and hide serial if it does not exist

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2025-01-30 13:14:38 +01:00
Eduard Gert
25be69e7bb Add improvements to new networks features (#439)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Fix wrong ui state for routing peer modal in networks

* Add confirmation dialog when blocking users

* Keep peer sort order when switching pages

* Update sidebar navigation order and remove deprecation notice

* Fix issue when hovering over truncated text in a group badge closes the multiple groups popover

* Update group text in network resource modal

* Update networks page text

* Fix line height

* Add search to resource table

* Switch networks flow to create first resources and then add routers

* Add enabled toggle to routing peers

* Add enabled toggle to network resources

* Add resource group modal and adjust tables

* Clarify networks

* Fix not properly aligned horizontal scroll bar

* Add option to install netbird after creating a setup key

* Fix text for install netbird modal

* Show resources count in group settings

* Fix "no results" and "no routing peers" text showing at the same time

* Fix wording

* Fix resource policy count

* Hide resource count when selection source groups

* Extend networks routing peer modal with option to create a setup key and install netbird

* Add option for horizontal stepper

* Generate setup key when installing netbird from routing peer modal

* Add confirm dialog to let the user know a one-off setup-key will be created. This avoids accidental clicking and later confusion on the setup keys page

---------

Co-authored-by: Misha Bragin <bangvalo@gmail.com>
2025-01-20 16:18:21 +01:00
Eduard Gert
43e5d5cf53 Fix activity search and allow searching for meta fields (#440) 2025-01-15 16:41:55 +01:00
Eduard Gert
18819d6fdf Add confirmation dialog when blocking users (#437) 2025-01-15 16:29:05 +01:00
Eduard Gert
158804c1ac Fix wrong ui state for routing peer modal in networks (#436) 2025-01-15 16:28:50 +01:00
Misha Bragin
14d2d68819 Update links to networks doc (#435)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-27 21:48:36 +01:00
Pascal Fischer
40902b3629 add resources to groups update operation (#434)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-27 14:20:11 +01:00
Pascal Fischer
fa9bcea4ab Update links for networks concept (#433)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-12-23 18:08:06 +01:00
Eduard Gert
3ba7acdecf Add new networks feature (#427) 2024-12-23 13:20:01 +03:00
Eduard Gert
c7775ade8c Hide groups for regular users (#423)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-11-20 17:50:28 +01:00
Maycon Santos
cd3e75b640 Add setup-key improvements (#420)
Some checks failed
build and push / build_n_push (push) Has been cancelled
- Add support to key deletion
- Add custom and unlimited expiration
2024-11-01 16:04:43 +01:00
Jon "The Nice Guy" Spriggs
f8281c8057 Typo (#418)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Protocol appears to include the : delimiter
2024-10-22 11:19:16 +02:00
Eduard Gert
c1fcadaefe Fix resetting acl groups on switching active toggle (#417)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-10-07 17:31:42 +02:00
Jon "The Nice Guy" Spriggs
a0c4520f4b Add admin-url to the add-peer dialogue (#416)
* Add admin-url to the add-peer dialogue

* Missed "let" from defining the variable

* Update netbird.ts

Fix isNetBirdHosted check

---------

Co-authored-by: Eduard Gert <eduard@netbird.io>
2024-10-07 17:25:03 +02:00
Eduard Gert
76ef50a886 Add Access Control Groups & various UI / UX improvements (#415)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Update codespell

* Add access control group, add various ui / ux improvements
2024-10-04 19:54:49 +02:00
Maycon Santos
58cec8fcd1 ignore mappin spelling (#408)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-08-13 15:59:38 +02:00
Eduard Gert
d34ae9beb2 Sync changes with netbird cloud (#407)
* Update axa oidc library and package.json

* Update ACL port state to show correct value

* Filter user groups by unique groups only

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations

* Add peer multiselect, optimize dropdown performance for peer selection, remove 'all' group from some dropdowns, various ui / ux optimizations
2024-08-13 15:51:22 +02:00
Eduard Gert
650496f670 Include all settings in put request to prevent overwrite (#405)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-31 18:48:59 +02:00
Tom Hubrecht
121778c4a6 Fix package-lock.json (#401) 2024-07-12 10:35:31 +02:00
juliaroesschen
d4102c5d04 fix typo in route update modal (#397)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-07-04 15:05:57 +02:00
pascal-fischer
e78c35bdbe Fix DNS modal to allow one char domains (#393)
* update regex to allow one char domains in DNS routing modal

* update regex
2024-07-04 10:50:37 +02:00
juliaroesschen
6ebee98695 Fix typo in Network Routes dialogue (#395) 2024-07-04 10:48:49 +02:00
juliaroesschen
f4b28d5f40 Fix typo in routes modal 2024-06-28 11:38:39 +02:00
Eduard Gert
b4b6d9295b Add DNS routes (#390)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-06-17 09:32:55 +02:00
Maycon Santos
4898742ee9 Fix http://localhost:3000/ url validation case (#388)
* Fix http://localhost:3000/ url validation case

* adjust min regex occurrences
2024-06-12 18:18:14 +02:00
Eduard Gert
79164e9dd5 Add process posture check (#378)
* Add process posture check

* Add support for separate linux and mac paths
2024-06-12 16:32:10 +02:00
Eduard Gert
5caeab118b UX changes for modals and refactoring (#380) 2024-05-08 14:42:04 +02:00
Eduard Gert
3f943bb7d4 Use next/font/local instead of next/font/google (#376)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-04-19 17:12:56 +02:00
Eduard Gert
96b939e6cc Add changes from cloud repo to public one (#377)
* Remove unused files

* Update activity descriptions

* Update SelectDropdown

* Update redirect logic for / page

* Update HelpText.tsx

* Update wording for exit nodes
2024-04-19 17:12:37 +02:00
Eduard Gert
5e13548b81 Add better input validation for setup-keys, nameserver and routes (#373)
* Return the correct promise for errors

* Update icon

* Add better validation for routes

* Add better validation for DNS

* Add better validation for setup keys

* Merge exit nodes to input validation
2024-04-17 15:27:21 +02:00
Eduard Gert
2272a1d2a4 Add Exit Nodes (#374)
* Add exit node feature

* Fix spelling

* Hide masquerade for exit nodes

* Add exit node information to peers list

* Change exit node button, add indicator to peers table

* Add steps to route modal

* Add hook to check if peer has exit nodes

* Hide exit node indicator for regular users

* Add documentation links
2024-04-17 13:11:38 +02:00
Eduard Gert
fc3da50346 Add fallbacks for setup key name & setup key group names (#370)
* Add try catch block for global search

* Add fallback for group name

* Add fallback for setup key name

* Do not load setup key modal if it's not open

* Check if auto_groups actually exists for the setup keys

* Add fallback for group names in setup keys table

* Add fallback for group names in peers table
2024-04-11 16:42:27 +02:00
Eduard Gert
6d4716cdad Remove integrations from public repo and sync changes (#369)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Change icon size

* Remove integrations

* Add no cache header

* Add analytics event tracking

* Add small announcement improvements

* Remove peer approval setting

* Do not load countries when user has no permission

* Add tab query params to settings

* Decrease navigation font size

* Change order of providers

* Increase padding for modals

* Show page only when user is fully loaded and found

* Remove unused state

* Remove integrations page
2024-04-02 14:06:38 +02:00
amplitudes
859916b1df fix: user deletion notification (#367) 2024-04-02 12:26:45 +02:00
Eduard Gert
80ce7d21b0 Fix issue where the first users cache is not populated (#366) 2024-03-28 11:27:00 +01:00
Eduard Gert
06fdbd8ec4 Hide profile settings and announcements for blocked dashboard view (#365) 2024-03-28 10:25:21 +01:00
Eduard Gert
973cceff79 Add setting to change dashboard view for regular users (#362) 2024-03-27 16:09:58 +01:00
Eduard Gert
f4a2d6fae8 Add Okta SCIM integration (#361)
* Add Okta integration (wip)

* Update okta setup dialog

* Add okta integration images

* Add error handling for 500 status codes

* Add okta integration

* Fix lint warnings

* Update azures last sync time

* Remove 'on' from step, disable copy for HTTP Header

* Update text for custom IDP
2024-03-27 15:55:56 +01:00
Eduard Gert
cb922b46b7 Add 'Offline' filter to peers table (#364) 2024-03-26 20:03:24 +01:00
Eduard Gert
4c56ae704c Show peers for regular users but hide / disable actions (delete, enable ssh etc.) (#360)
* Show peers for regular users but hide / disable actions (delete, enable ssh etc.)

* Do not load countries for regular users
2024-03-21 14:21:26 +01:00
Eduard Gert
fe6d8c9bd5 Add support for decimal expiration time and switch to days if interval exceeds 48h (#357)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add helper function to check for integer

* Add support for decimal expiration time and switch to days if interval exceeds 48h
2024-03-15 15:54:06 +01:00
Eduard Gert
121976d101 Add option to copy peer details (ip, public ip, hostname, domain name) in detailed peer view (#356) 2024-03-15 13:46:27 +01:00
Eduard Gert
f7071e00b6 Add reset filter button (#355) 2024-03-15 13:43:00 +01:00
Eduard Gert
6b73ccf102 Fix search resetting when selecting a group (#354) 2024-03-15 13:35:25 +01:00
Eduard Gert
87dcd00264 Fix peer groups occasionally not refreshing (#351)
* Trigger groups refresh when visiting peers page

* Disable exhaustive-deps linter

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-03-15 13:34:47 +01:00
Eduard Gert
99f1bcc375 Reduce information visible to regular users (non-adminstrators) (#353)
reducing visibility to display only add peer information
2024-03-15 13:25:40 +01:00
Eduard Gert
bf34c55110 Fix JWT group sync checkbox using wrong variable (#352) 2024-03-12 17:23:42 +01:00
Eduard Gert
1dfc6e2d75 Add announcement banner to show updates or important information (#350)
* Add contrast color

* Add crypto-js for md5 hash

* Add announcement banner
2024-03-11 15:31:52 +01:00
Eduard Gert
b7860a8786 Filter peers by id instead of name in peer dropdown selector (#347) 2024-03-09 18:07:45 +01:00
Eduard Gert
c9172e3a5f Show full netbird logo on desktop and netbird logomark on mobile (#348) 2024-03-09 18:07:26 +01:00
Eduard Gert
78d75134f9 Add better description for posture check activity events (#349) 2024-03-09 17:14:41 +01:00
Eduard Gert
071feb02f9 Fix SSO expiration dropdown to reflect the actual "Hours" or "Days" (#345) 2024-03-01 17:01:26 +01:00
Eduard Gert
8e7bcc0c22 Extend posture checks with peer network range check (#344)
Some checks failed
build and push / build_n_push (push) Has been cancelled
add support to peer network checks
2024-02-27 16:15:47 +01:00
Eduard Gert
02a0b71e46 Fix setup key modal closing on first time creation (#342) 2024-02-26 18:02:56 +01:00
Eduard Gert
a8b66d935f Show loading indicator for peer detail view as groups are loading (#343) 2024-02-26 18:02:28 +01:00
Eduard Gert
f74f9cf812 Add region and public ip to peer table and detailed peer view (#340)
* Fix group badge icon size

* Fix copy icon size

* Add region information to peer table and single peer view

* Push to docker

* Change login expired icon size

* Fix country flag in single peer view

* Change country flag size in peer table

* Disable revalidation for countries

* Fix icon size on peer detail view

* Rollback workflow

* Revert login expiration

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-02-23 15:52:33 +01:00
Maycon Santos
7578595f05 Update posture checks documentation links (#339)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-22 21:46:55 +01:00
Eduard Gert
a5fc05ca3a Add posture checks to further restrict network access (#338)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-22 13:58:41 +01:00
Eduard Gert
8ffdb442f1 Allow adding 3 nameserver addresses (#337) 2024-02-19 14:29:33 +01:00
Eduard Gert
a04e3afccb Show "Never" when a user never logged in instead of a date (#335)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-16 12:15:32 +01:00
Eduard Gert
bca327e4cf Add better search for network-routes by group name (#336) 2024-02-16 12:15:14 +01:00
Maycon Santos
6c74506316 Add templates for bugs and for feature request (#333) 2024-02-14 13:43:27 +01:00
Eduard Gert
663d7ea58c Add check to call initial users only once in dev mode (#332)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-13 15:11:37 +01:00
Eduard Gert
b701783dca Update ephemeral_peers to ephemeral (#331)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-13 14:12:31 +01:00
Eduard Gert
fc9a9dfa3e Block application and show loading until users are fetched (#330)
* Add option to ignore errors

* Block application and show loading until users are fetched
2024-02-13 14:08:43 +01:00
Eduard Gert
093efc08b3 Fix an issue of creating duplicate groups in the access control and network routes modal when group does not exist (#328)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-02-12 14:12:57 +01:00
Eduard Gert
dfa41a48e3 Hide the user invite button for selfhosted users (#327) 2024-02-12 14:08:10 +01:00
Eduard Gert
2cf366a5f8 Fix access control to show the correct modal (#326)
* Rename Access Control "Rule" to Access Control "Policy"

* Show the correct modal for Access Control
2024-02-12 14:07:53 +01:00
Eduard Gert
f91788faef Fix iOS detection and modal scrolling on Safari mobile (#325)
* Add better iOS detection

* Fix scrolling for Safari browser
2024-02-12 14:07:31 +01:00
Eduard Gert
ec7bb76f1e Fix closing of tab when creating setup-key (#324) 2024-02-12 14:06:59 +01:00
Eduard Gert
15bab2cef4 Merge pull request #322
* Add unique key for nameservers
2024-02-12 14:05:26 +01:00
Eduard Gert
4fa3482c74 Merge pull request #318
* Fix redirect link to event streaming docs
2024-02-12 14:04:42 +01:00
Eduard Gert
f5059f485c Fix invalid token error message (#321) 2024-02-09 16:07:09 +01:00
Eduard Gert
3c60de4169 Add a fallback in case the user has no name (#320)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Fix redirect link to event streaming docs

* Fallback to a user id in case user has no name
2024-02-05 16:48:25 +01:00
Eduard Gert
2267cecf46 Change fetch() to support idToken source (#319)
* Fix redirect link to event streaming docs

* Change fetch logic to support okta oidc
2024-02-05 11:08:36 +01:00
Eduard Gert
2b222e082a Init Dashboard V2 (#316)
* Init Dashboard V2

* Update README.md

* use dedicated vars and prevent docker push on PRs

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-01-30 13:34:42 +01:00
Eduard Gert
4612f6c49a Add "New dashboard" Banner (#315)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Add new dashboard Banner.tsx

* display on localdev and add todo note

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2024-01-24 18:36:20 +01:00
Maycon Santos
a3a0e6315f Use Approve for approve peer modal (#312)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2024-01-02 20:07:10 +01:00
Misha Bragin
fa7bc205ba Add reference to the Apple Store on the Add Peer window (#311)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-29 18:03:50 +01:00
pascal-fischer
87ff65f1a7 adding peers approval flag to non user bound devices (#310)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-25 13:01:54 +01:00
Bethuel Mmbaga
748596f710 Add JWT allow groups support in account settings (#309)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-18 12:14:03 +01:00
Misha Bragin
b06cb0ec3d Add a settings page for custom IdP and MFA (#308)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-12 13:42:14 +01:00
Misha Bragin
0c924f7ded Correct peer approval tooltip (#307) 2023-12-12 13:33:11 +01:00
Maycon Santos
f29c915fc2 fix settings update blank page
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-11 18:07:05 +01:00
Maycon Santos
5f8579bfda Fix peer expiration interval init on validation (#306)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-11 17:46:32 +01:00
pascal-fischer
a71389aa29 not sending extra settings on selfhosted deployments (#305)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-11 16:52:29 +01:00
Maycon Santos
c3ba026452 Init domains array for custom nameserver group (#304)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-12-08 11:07:10 +01:00
Maycon Santos
193f8a7bdf Fix missing text update from #302 (#303) 2023-12-07 18:05:44 +01:00
Fabio Fantoni
f9814e1169 add codespell github workflow and fix some typo (#296)
* add codespell github workflow

* fix some typo
2023-12-07 18:05:31 +01:00
Maycon Santos
2f800bf912 Add delete account support (#302)
* Add delete account support

* add peer approval form item

* block menu for non owners
2023-12-07 15:28:07 +01:00
pascal-fischer
0199ea81f3 Add support for peer approval (#299)
* add support for peer approval

* use gold color for tag

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2023-12-05 13:17:40 +01:00
Maycon Santos
a20894092b Add owner role support (#300)
Update checks for admin to include owner role

Updated role list to include role for users

Updated activity with new event type handler
2023-12-01 16:57:18 +01:00
Maycon Santos
f4d6ce5770 Fix Windows server version lowercase
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-11-24 18:29:43 +01:00
Maycon Santos
dd67ab6dcb Fix Windows server version (#298)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-11-24 17:42:31 +01:00
pascal-fischer
2613948cdf Fix OS display format for iOS (#297)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* fix OS format function to properly show iOS

* bring back windows check
2023-11-13 22:30:50 +08:00
pascal-fischer
dc95d8bfd1 disallow routing through ios devices (#295) 2023-11-11 13:20:39 +01:00
Bethuel Mmbaga
f49c28f550 Add integration activity events target to Activity view as system setting (#293) 2023-11-08 13:51:57 +03:00
Maycon Santos
2fc7b73ea0 Fix/blank page on empty policy groups (#292)
validate null sources and destinations fields
2023-11-01 11:56:12 +01:00
pascal-fischer
a9354d3c87 removed disabled option from setup key filter switch (#291) 2023-10-27 20:06:42 +02:00
Sarooj bukhari
e0ae7d068a add search domains enabled to namespace (#288)
Some checks failed
build and push / build_n_push (push) Has been cancelled
---------

Co-authored-by: braginini <bangvalo@gmail.com>
2023-10-20 09:33:44 +02:00
Sarooj bukhari
ddd812e9a0 Add a refresh button to the table views (#287)
Co-authored-by: braginini <bangvalo@gmail.com>
2023-10-15 19:04:05 +02:00
Sarooj bukhari
2d55d0736f Add referesh button to the peers table (#286)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Co-authored-by: braginini <bangvalo@gmail.com>
2023-10-12 18:38:59 +02:00
Sarooj bukhari
8febc26f1f fix update distribution groups bug (#284)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-10-04 20:07:02 +02:00
Sarooj bukhari
3f854b01a0 Add support to network routes with peer group (#275)
Some checks failed
build and push / build_n_push (push) Has been cancelled
support to routes with peer group
updated the view logic to support the new type
updated peer view as well
for now, we are supporting a single group, but that can be extended
2023-10-04 11:37:23 +02:00
Misha Bragin
303d51eff8 Display service username in the activity (#282) 2023-10-03 16:06:50 +02:00
braginini
21e69e642a Disable user deletion since the API isn't ready
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-09-29 16:05:26 +02:00
pascal-fischer
835bb37ab9 Update jwt group sync visibility and update description (#281)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-09-29 13:58:46 +02:00
dependabot[bot]
a944dc8ab0 Bump @adobe/css-tools from 4.0.1 to 4.3.1 (#265)
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.0.1 to 4.3.1.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-29 00:39:19 +05:00
Sarooj bukhari
b2c51533fb remove invalid key check (#280) 2023-09-28 21:26:11 +02:00
pascal-fischer
fd24536926 add checks to hide last login for non netbird hosted deployments (#277) 2023-09-27 18:00:33 +02:00
Misha Bragin
8e8484cd45 Fix packages (#276)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-09-27 12:25:55 +02:00
Sarooj bukhari
6c87f53195 Update the /Activity view to use email addresses from the /api/events response (#273)
Some checks failed
build and push / build_n_push (push) Has been cancelled
uses the events api response information to map user name and email
2023-09-23 10:45:27 +02:00
pascal-fischer
9bbbff7dc0 Fix last login for regular entries in users overview (#264)
* add last login to user overview for regular entries without tag

* add backward compatability to properly show with older version of management

* Merge main and update delete user popup messages

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2023-09-23 10:40:22 +02:00
Sarooj bukhari
04a20fa31f add new fields on setting pag (#258) 2023-09-22 16:32:40 +02:00
Misha Bragin
3797db93f0 Hide network routes card for non-linux peers (#269) 2023-09-20 10:58:26 +02:00
Sarooj bukhari
2e81765e85 add delete user functionality (#272) 2023-09-19 15:13:31 +02:00
Yoann N
cb9f76c0fc typo in ac creation (#266) 2023-09-18 23:22:52 +02:00
Sarooj bukhari
54accb665c setup ui (#268)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* setup ui

* capital letter

---------

Co-authored-by: Maycon Santos <mlsmaycon@gmail.com>
2023-09-07 20:26:34 +02:00
Maycon Santos
cfea3bd489 Add ephemeral peers views and subviews (#267)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-09-06 13:36:23 +02:00
Zoltan Papp
a44a1c5424 Feature/ephemeral peers (#263)
* Add ephemeral peer switch for SetupKeyNew component

* Add "sys" handling in activity view
2023-09-04 13:49:11 +02:00
Sarooj bukhari
c2c044421f Peer search bug (#262) 2023-08-25 12:25:58 +02:00
Yulia
e8d57c3445 Fix flaky Access control test (#261)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Fixed flaky test by changing the way to access 
Access Control Page and add some test ids of Add Rules buttons.

Temporary removed tests for Firefox and webkit(safari)
2023-08-22 11:38:11 +02:00
pascal-fischer
0b892c0056 Add User last login and new events
Add last login to users overview list.
Additionally added new events for dashboard login, peer expiration and peer login.
2023-08-18 19:23:56 +02:00
Misha Bragin
14d9b80029 Limit UI view for the "user" role (#259)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-08-15 22:36:21 +02:00
B. Baumgartl
dedbe55308 Add windows/macos/android instructions for self-hosted (#254)
* Add windows/macos instructions for self-hosted

* Add android instructions for self-hosted
2023-08-14 18:54:44 +03:00
Yulia
796a06cf27 Add end-to-end tests using playwright (#257)
Add tests with playwright for:

- add peer modal on first access
- add peer modal on empty peer list
- test install buttons and instructions for Linux, 
Docker, macOS, Windows and Android
- check default ACL

The tests are using a modified version of the getting 
started scripts to run a local environment of 
management services and run the dashboard from the current version

Todo:

- run tests before create docker container
- add more tests
2023-08-12 23:11:32 +02:00
Sarooj bukhari
2443c6332d Apply quick group on all application (#253)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-08-10 15:34:21 +02:00
braginini
3b5193ae4e Fix protocol ALL value 2023-08-05 11:42:06 +02:00
Sarooj bukhari
cf42dd52fc fix expiration validation (#252)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-08-04 23:30:08 +02:00
Sarooj bukhari
bc6842e5b5 Add quick update of group on peer (#250) 2023-08-04 12:03:35 +02:00
Sarooj bukhari
a8ed755dda Sidebar menu for Settings (#249)
Replace tab view in the Settings panel with the left-handed
sidebar menu.
2023-08-02 16:14:13 +02:00
Sarooj bukhari
a87c06ef52 Groups management (#246) 2023-08-01 21:05:42 +02:00
Sarooj bukhari
c0130d265c fix domain validation (#247)
* fix domain validation

* handle on update

* move logic to utils
2023-08-01 10:48:10 +02:00
Misha Bragin
63ced3088a Add public installation page (#248)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-31 20:21:15 +02:00
Misha Bragin
42b7a15466 Drag custom query params to auth layer (e.g., utm_source) (#244)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-31 15:16:51 +02:00
Sarooj bukhari
c88bfa6476 Add filter by groups in peer (#243)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-24 17:45:53 +02:00
Maycon Santos
c4138a8c45 fix one-off key creation
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-21 20:18:59 +02:00
Sarooj bukhari
3f5418bebc Bug/remove group duplication on select view (#241)
* remove duplicate group on select view to change the logic from name to id

* fix group bug and add grouping on DNS

* fix setup key issue
2023-07-21 19:46:22 +02:00
Maycon Santos
f60605e5e3 Allow unset audience (#240)
by setting the AUTH_AUDIENCE=none we will unset the audience value,

not passing that to the requests

This is required for the jumpcloud support
2023-07-21 19:45:37 +02:00
Sarooj bukhari
c1b2ededa7 remove duplicate group on select view to change the logic from name to id (#238)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-20 10:08:26 +02:00
Sarooj bukhari
31eaa4a241 add all filter state on persist on page refresh (#236)
* add all filter state on persist on page refresh

* clear state on logout

* fix issues while refresh
2023-07-20 09:22:01 +02:00
dependabot[bot]
ba365336ff Bump word-wrap from 1.2.3 to 1.2.4 (#237)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-19 22:02:03 +05:00
Sarooj bukhari
06c238b013 Fix peer mobile view (#233) 2023-07-18 08:50:58 +02:00
dependabot[bot]
ed56c240f3 Bump semver from 6.3.0 to 6.3.1 (#230)
Bumps [semver](https://github.com/npm/node-semver) from 6.3.0 to 6.3.1.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v6.3.1/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v6.3.0...v6.3.1)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-15 01:01:59 +05:00
dependabot[bot]
1f3c7d87d7 Bump tough-cookie from 4.1.2 to 4.1.3 (#223)
Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.1.2 to 4.1.3.
- [Release notes](https://github.com/salesforce/tough-cookie/releases)
- [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md)
- [Commits](https://github.com/salesforce/tough-cookie/compare/v4.1.2...v4.1.3)

---
updated-dependencies:
- dependency-name: tough-cookie
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-15 01:01:37 +05:00
Zoltan Papp
9de2906fb2 Extend activity view with group delete (#232) 2023-07-14 10:11:32 +02:00
Sarooj bukhari
359b443326 Update remaining pages layout (#231) 2023-07-14 10:09:08 +02:00
braginini
ecae39d94b Adjust DNS view
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-13 12:55:04 +02:00
Sarooj bukhari
30b858c1bc Make all modals UI consistent (#229)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-12 17:35:55 +02:00
Sarooj bukhari
e82b269ff5 fix network routes pages (#228) 2023-07-11 09:06:08 +02:00
braginini
23a4b79f01 Fix disabled links 2023-07-10 18:13:08 +02:00
Sarooj bukhari
cc5a9b1033 Fix table loading glitch (#227) 2023-07-10 16:49:52 +02:00
Sarooj bukhari
09ae157be3 Re-work DNS layout (#222) 2023-07-10 09:03:27 +02:00
pascal-fischer
cb89eeb921 Fix default value for expiration for setup keys (#221)
* fix initial value for setup keys so default works

* fix initial value for setup keys so default works
2023-07-07 12:40:51 +02:00
pascal-fischer
79446c0e77 remove formatted expiry logic and fix sent expires_in (#220)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-06 17:16:10 +02:00
Zoltan Papp
1f258e4e2c Update copyright title at the bottom of the page (#219) 2023-07-06 16:38:02 +02:00
braginini
bae95d2e11 Fix peer groups control in the peer update view
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-07-01 20:40:26 +02:00
Sarooj bukhari
3d95a826e7 Improve ACL Ui layout 2023-07-01 20:06:46 +02:00
Sarooj bukhari
d8d13aff01 Add copy on add peer code (#217)
Co-authored-by: Sarooj Bukhari <120650489+saroojbukhari2022@users.noreply.github.com>
2023-06-27 15:01:59 +02:00
Sarooj bukhari
695b571a50 table route layout (#213)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Co-authored-by: Sarooj Bukhari <120650489+saroojbukhari2022@users.noreply.github.com>
Co-authored-by: braginini <misha@netbird.io>
2023-06-27 11:08:49 +03:00
Sarooj bukhari
ddfb6a6179 Add copy button on add peer code box and remove check to shoe default rule
Co-authored-by: Sarooj Bukhari <120650489+saroojbukhari2022@users.noreply.github.com>
2023-06-27 07:06:21 +02:00
pascal-fischer
8c94119c6a extend gitignore to ignore all config files (#212) 2023-06-19 17:16:57 +02:00
Sarooj bukhari
439e803ef2 Fix group change loader (#211)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-06-19 15:51:08 +02:00
braginini
440c51a35c Fix empty tables handling on all tabs 2023-06-18 18:15:34 +02:00
braginini
800e94de85 Make offline peers appear "grey" in the peer's details 2023-06-18 15:32:01 +02:00
braginini
ba7d138156 Fix needs login color palette 2023-06-18 15:29:53 +02:00
braginini
a66fb3bf8f Show offline peers with a grey indicator 2023-06-18 15:05:11 +02:00
Sarooj bukhari
fcc51243e0 Update site fonts (#209) 2023-06-18 15:03:40 +02:00
Sarooj bukhari
312c60dd45 Update site fonts (#208) 2023-06-16 21:00:54 +02:00
Sarooj bukhari
09e6de74ee Update route and add on update peer (#205) 2023-06-15 10:28:55 +02:00
pascal-fischer
addd348456 Fix isAdmin check to not depend on oidc (#207)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* remove admin check depending on oidc user on autogroups field

* fix admin check on peers view
2023-06-14 17:19:45 +02:00
braginini
a8190bfe5b Fix Rule ports placeholder 2023-06-13 16:39:01 +02:00
braginini
9e3d9f245d Add disabled filter option to access controls
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-06-13 14:11:27 +02:00
Misha Bragin
e7a7a75906 Align peers count and font in the popover groups (#206) 2023-06-13 13:17:48 +02:00
braginini
67efd47f22 Minor changes to access control layout 2023-06-13 11:03:30 +02:00
Sarooj bukhari
813cd851ca Update access control Add New layout (#202) 2023-06-12 09:40:04 +02:00
pascal-fischer
f44ccf3ef7 remove keydown handler from PAT popup (#203) 2023-06-09 12:09:07 +02:00
Bethuel
899f56acdc fix: remove any trailing / in auth authority (#201)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-06-07 15:35:20 +02:00
braginini
dc2760d5ff Fix elements alignment for the ACLs view 2023-06-06 15:07:51 +02:00
Sarooj bukhari
5ae4fd31f1 Udpate acceess control table layout (#199)
Co-authored-by: Sarooj Bukhari <120650489+saroojbukhari2022@users.noreply.github.com>
2023-06-06 14:31:18 +02:00
braginini
54d9d7c768 Remove peer view header 2023-06-05 18:44:18 +02:00
braginini
3a73781fca Merge remote-tracking branch 'origin/main' 2023-06-05 18:10:21 +02:00
braginini
f3c5d4df6a Fix color scheme for disabled fields 2023-06-05 18:07:28 +02:00
Bethuel
b4c9135c58 support authentication with client_secret (#198) 2023-06-05 18:02:43 +02:00
Sarooj bukhari
a280d9c67c Update peers layout and add routes component on peers edit (#196) 2023-06-05 15:22:03 +02:00
Misha Bragin
5caf06b086 Decrease length of the hidden setup key prefix (#195)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-06-02 11:06:05 +02:00
braginini
95af1c4e32 lower case of the Create key button 2023-06-02 10:53:47 +02:00
Sarooj bukhari
d247e7f3ed Change edit key on group from popup to page (#194) 2023-06-01 16:51:51 +02:00
Sarooj bukhari
391e534253 Show setupkey once on creation (#191) 2023-06-01 12:08:58 +02:00
Sarooj bukhari
a7f64d4a15 Update setup key edit layout (#190)
https://github.com/netbirdio/dashboard/issues/187
2023-06-01 09:49:10 +02:00
Givi Khojanashvili
53ed514803 ACL to firewall rules (#163)
ACL based on the firewall rules
2023-06-01 10:06:08 +04:00
pascal-fischer
6cadce1598 Add pkg installer to MacOS popup (#188) 2023-05-30 19:05:29 +02:00
pascal-fischer
c03d4b8a4b Fix peers views in safari 2023-05-30 13:42:29 +02:00
dependabot[bot]
bc1053fbb4 Bump json5 from 1.0.1 to 1.0.2 (#124) 2023-05-29 13:58:20 +02:00
dependabot[bot]
d154bfb799 Bump webpack from 5.74.0 to 5.76.1 (#148) 2023-05-29 13:57:48 +02:00
pascal-fischer
67fd2fcb2e Update links to docs (#182)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-05-24 17:31:57 +02:00
Misha Bragin
f4933e45ee Add peer on Android (#185)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-05-19 21:42:13 +02:00
Misha Bragin
331dd2b429 Make it more visible that user can be blocked (#184)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-05-19 12:40:08 +02:00
Misha Bragin
3a8106c1e7 Track block/unblock user activity (#181)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-05-17 09:53:46 +02:00
Misha Bragin
b595e0d6a8 Switch to activate/deactivate a user (#179)
Some checks failed
build and push / build_n_push (push) Has been cancelled
This PR adds a switch to deactivate (or block) a user.
Only admins can block/unblock users.
Users can't block themselves.
2023-05-15 16:57:35 +02:00
pascal-fischer
33621cae5d remove href- from breadcrumbs (#180) 2023-05-15 14:28:14 +02:00
Maycon Santos
360d807008 Enable creating service user for all domains (#178)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Removed unnecessary check from create button.
2023-05-14 11:56:14 +02:00
Misha Bragin
6f8897ffa5 Make breadcrumbs look like links (#176) 2023-05-10 18:48:11 +02:00
pascal-fischer
75d5f804c5 fix dropdown button in lists to only show ellipsis (#175) 2023-05-10 16:45:47 +02:00
pascal-fischer
f7cac02a2d reduce api calls on user edit (#174) 2023-05-10 16:44:43 +02:00
Misha Bragin
ec40730cb2 Fix setup key layout (#172)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-05-08 17:49:27 +02:00
Misha Bragin
7f3648861b Fix popups layout (#171) 2023-05-08 12:30:53 +02:00
Crusadero
b50464db43 Setup keys screen (#167) 2023-05-07 18:39:28 +02:00
pascal-fischer
1eb5ccc131 displaying proper groups and name on route peer update (#169) 2023-05-05 15:50:39 +02:00
pascal-fischer
ac42a17b11 remove unnecessary oidc user check from users page (#166)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-04-27 13:28:50 +02:00
pascal-fischer
77ca3c6fde Adding service user support and new user overview (#164)
Changes:
user tab was split in service users and regular users
user edit view was reworked:
shows PAT box for service users and self (hides for rest)
hides email and groups for service users as no usage
reverted settings tab to only contain account settings + hide for normal users
Use navbar avatar dropdown to link to users list and open user edit page for self
extend api-client to handle requests with query parameters
use popup form for PAT creation, user invite and service user creation
validate all form fields before trying to send API call and show faulty fields

Additional fixes:
groups popup was only visible after 2nd hover after tab switch on every view, fixed to also show on first hover
fix setup keys page throwing errors from time to time and not loading
peers view was sending getRoute requests that are only allowed for admins which was throwing errors (only console) for normal users -> disabled requests for non-admin users
2023-04-22 12:57:17 +02:00
pascal-fischer
20e24b4ede Fix Routes view when updating routes (#165)
* fix filter

* filter also for android

* fix masquerade Traffic button

* remove log
2023-04-21 13:07:54 +02:00
Misha Bragin
7a29dac01c Add single line installer command for Mac and Linux (#162)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-04-04 14:20:02 +02:00
pascal-fischer
444e9ec44a fetch userID from user list and not oidc by default (#161) 2023-04-03 16:52:42 +02:00
pascal-fischer
dff0313f82 Feature/pat support (#157)
* Add working UI + API calls [missing token popup]

* use popup view to add new token

* show userID if user name not available

* switch from description to name

* show "Me" instead of own name

* removed created_by column

* update add token explanation

* use object instead of plain text for token create response

* some style changes

* disable information button for tokens

* last_used can contain nil

* fix delete popups

* lower case letters for dates

* add activity and fix visibility

* show settings tab for non admins

* remove spaces on top of setting tabs

* fix copy button size and position

* fix list footers

* continue merge changes to new files
2023-04-03 12:29:40 +02:00
Maycon Santos
11fbfb336a Clean last estimated name (#160) 2023-04-03 12:16:12 +02:00
Maycon Santos
4a0ae8f27d Add missing config change (#159) 2023-04-01 21:58:46 +02:00
Maycon Santos
9a72d8b0c4 Allow defining API token source (#158)
On many IDP providers, the access token
 is used to access the IDP's own API

 With these changes, we allow users to define the proper token to be used for
 management API calls
2023-04-01 19:44:28 +02:00
Misha Bragin
8e038cf242 Make table headers font-weight normal (#156) 2023-03-27 15:59:18 +02:00
Misha Bragin
5bd94eff56 Fix add peer popup tab layout on mobile (#154)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-03-23 12:10:27 +01:00
Misha Bragin
77f065b093 Update theme border radius (#152) 2023-03-22 16:56:36 +01:00
Misha Bragin
a4d55cfb90 Fix settings modal styles (#151) 2023-03-22 16:14:49 +01:00
Misha Bragin
cfd4c9075b Add onboarding steps (#150) 2023-03-22 15:21:52 +01:00
braginini
962180030a Fix activity docs link 2023-03-15 14:20:48 +01:00
Misha Bragin
485e1e8d79 Add more references to docs (#149) 2023-03-15 14:18:22 +01:00
Givi Khojanashvili
b11007b29f Add policy add activities (#147)
Related changes for netbirdio/netbird#700
2023-03-13 10:58:34 +01:00
Maycon Santos
bce75c1ca9 Disable banner (#146)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-03-02 15:55:31 +01:00
Maycon Santos
0c09992b38 Use user.is_current (#145)
prevent update for own user

use the is_current for label

removed unused imports
2023-03-01 22:50:26 +01:00
Maycon Santos
86b12f30d2 Use new windows release URL (#144)
removed latestVersion logic and environment support
2023-03-01 15:00:36 +01:00
Maycon Santos
d3e34d8448 Use single page size helper with new larger values (#143)
Reduce duplicated code with single helper for pagination

On larger deployments we should allow for larger pages

fix popover key issue and vertical spacing

update deprecated keys
2023-02-28 16:32:12 +01:00
Misha Bragin
76083168f6 Log error when activity event is not handled (#142) 2023-02-28 11:22:40 +01:00
Misha Bragin
a54b3687ae Display user in the user role update event (#141)
Handling of the "User role updated" event (user.role.update) 
was missing in the Activity tab.
2023-02-27 15:27:00 +01:00
Zoltan Papp
25f154dc83 Capitalize first letter of OS (#140) 2023-02-27 14:54:39 +01:00
pascal-fischer
f3c7d877f8 Add additional step in the Mac installation instructions to start the daemon (#138) 2023-02-22 20:55:36 +01:00
Misha Bragin
7cea7e7f54 Format individual peer login expiration event (#139)
Events peer.login.expiration.disable and peer.login.expiration.enable
are handled in the Activity tab now and properly displayed.
2023-02-22 20:54:56 +01:00
Misha Bragin
aaa351635f Add peer expiration setting confirmation modal (#137)
Add a confirmation dialog to notify a user of possible
consequences of the peer login expiration enabling/disabling.
2023-02-21 08:47:14 +01:00
Misha Bragin
379ff5486e Account settings view (#136)
New Settings tab added to the dashboard.
It is possible to enable or disable peer login expiration globally for an account.
As well as defining the expiration time period.
2023-02-20 08:57:24 +01:00
Misha Bragin
8bcd9918e2 Fix expires in input filed of the setup key (#135) 2023-02-17 14:30:22 +01:00
Misha Bragin
044ccd0ce6 Display login expiration activities (#134) 2023-02-16 15:36:45 +01:00
braginini
ab09ca3697 Display API error in peers view 2023-02-16 13:03:39 +01:00
Misha Bragin
1644ed5dce Add peer login expiration tooltip (#133) 2023-02-16 12:47:35 +01:00
Misha Bragin
cea459792f Add peer login expiration (#132)
Display if peer login has expired in the Peers table
Enable/Disable peer login expiration
2023-02-16 12:18:15 +01:00
Maycon Santos
a402680816 Disallow all crawlers and dashboard indexing (#131)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-02-10 18:45:11 +01:00
Givi Khojanashvili
e57e5b726d Feat add custom id claim (#129)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* Fix management API endpoint ENV var. Format README.

* Add and use id_current user flag

* Use mix of the new and old methods to detect current user.
2023-02-03 21:48:03 +01:00
Misha Bragin
2c4ada0ad8 Use peerID in the Routes view (#130)
Relates to the netbirdio/netbird PR
Use Peer.ID instead of Peer.Key as peer identifier (#664)
2023-02-03 10:34:43 +01:00
Misha Bragin
8195587c85 Handle additional activity events (#128)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-01-31 13:56:46 +01:00
Maycon Santos
adf6e1e71f Use ID token payload when oidcUser is nil (#127)
With some IDPs like MS Azure the
oidcUser is not being set, so we can fallback to id token
to validate UI and current user
2023-01-24 09:10:14 +01:00
Maycon Santos
b733a186ae Remove console.log in peers page (#125)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2023-01-19 14:55:42 +01:00
Maycon Santos
5d901470c2 Feature/dns settings (#126)
Add DNS settings view to the DNS tab.

Split the page into sub-tabs for Nameservers and DNS settings

Added API calls to the new DNS settings API
2023-01-18 18:12:29 +01:00
Misha Bragin
29ab28847d Add Events view (#119) 2023-01-02 17:29:11 +01:00
Maycon Santos
0361825e04 Add peer's last seem time on popover (#122)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-16 19:37:10 +01:00
Moath Qasim
2fa33ec06a Fix MacOS custom netbird up command (#121)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-11 18:54:48 +01:00
Maycon Santos
c677eeaae4 Add distribution groups to Network routes (#118)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Users can add distribution groups to network routes

Groups can be added to individual network routes or to all routes in the group

Adding a new group in the modal is restricted to individual network route operations
2022-12-08 17:24:34 +01:00
Maycon Santos
7fb4b0b145 Update actions versions (#120)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-12-08 13:22:07 +01:00
dependabot[bot]
57f60a2fbf Bump loader-utils from 2.0.2 to 2.0.4 (#109)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 12:06:54 +01:00
dependabot[bot]
ec949da416 Bump minimatch and recursive-readdir (#106)
Bumps [minimatch](https://github.com/isaacs/minimatch) and [recursive-readdir](https://github.com/jergason/recursive-readdir). These dependencies needed to be updated together.

Updates `minimatch` from 3.0.4 to 3.1.2
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

Updates `recursive-readdir` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/jergason/recursive-readdir/releases)
- [Changelog](https://github.com/jergason/recursive-readdir/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jergason/recursive-readdir/commits/v2.2.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
- dependency-name: recursive-readdir
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 12:06:28 +01:00
Misha Bragin
43710b8ada Add SetupKey usage limit (#117) 2022-12-05 13:10:21 +01:00
Maycon Santos
247665a846 Call users API on empty state (#116)
Validate if the users state is empty and issue a GET call to /api/users

We check if the call was issued and if not on tabs that already does such a call
2022-11-27 16:33:27 +01:00
Maycon Santos
010657c594 Fix DNS validations (#115)
Check if domains is not empty when primary is false

Validate domain and primary fields before submit

Display validation errors as message too

Reload groups on failure too

Display API call error

Added call to action message when nameserver is empty
2022-11-27 13:15:55 +01:00
Misha Bragin
2d911af97f Add DNS feature announcement (#114) 2022-11-25 18:37:55 +01:00
Misha Bragin
afdfed0160 Filter peers by groups (#113) 2022-11-25 18:26:00 +01:00
Misha Bragin
6b86da3716 Copy IP or DNS on the peers tab (#112)
Copy Peer IP or DNS in the Address column in Peers table
2022-11-25 17:23:35 +01:00
Maycon Santos
425fac8e9c DNS (#101)
Added DNS tab for managing Nameservers.
Users will be able to add multiple nameservers 
and set distribution groups that dictate to which peers the settings will be applied.
With this PR we also got a set of group handlers that can be reused.
2022-11-25 15:55:26 +01:00
Misha Bragin
fa2413f937 Add FQDN of a peer to the peers table (#111) 2022-11-21 11:27:13 +01:00
Misha Bragin
feec057933 Don't show admin content to users with a "user" role (#108) 2022-11-15 17:58:06 +01:00
Enrico Renna
9127686df7 fix: apt-key deprecation warning/errors (#107) 2022-11-15 09:12:13 +01:00
Maycon Santos
479911ded8 Check for change in any field (#104)
* Check for change in any field

Name should not be required, specially for self-hosted

* proper email change validation
2022-11-12 15:29:16 +01:00
Misha Bragin
69dcd6fadd Handle Management API errors (#103) 2022-11-10 16:30:16 +01:00
Misha Bragin
0b7b34b490 Handle Forbidden Errors coming from management (#100) 2022-11-05 08:41:55 +01:00
braginini
fff93a3820 Improve spacing on email verification screen. 2022-10-27 09:50:58 +02:00
braginini
da21784c73 Improve email verification window messaging 2022-10-27 09:40:57 +02:00
braginini
2e03a39b3e Add user invites banner 2022-10-20 12:01:52 +02:00
Maycon Santos
8e626cdd96 Revert "bump @axa-fr/react-oidc to 6.9 to fix atob padding bug (#97)" (#99)
This reverts commit 957ff98cec.
2022-10-18 17:48:30 +02:00
braginini
472704ad59 Merge remote-tracking branch 'origin/main' 2022-10-18 11:49:53 +02:00
braginini
94c7288016 Enable invite logic 2022-10-18 11:49:43 +02:00
Jens L
957ff98cec bump @axa-fr/react-oidc to 6.9 to fix atob padding bug (#97)
* bump @axa-fr/react-oidc to 6.9 to fix atob padding bug

* add issuer to auth0AuthorityConfig
2022-10-17 23:54:21 +02:00
Jens L
80178f66c3 correctly handle JWTs without base64-padding (#96) 2022-10-15 17:34:40 +02:00
braginini
37324cbcfc Hide user invites feature 2022-10-14 11:42:21 +02:00
braginini
9dd362a8a4 Add wiretrustee.com domain to "hosted" variants 2022-10-13 18:27:52 +02:00
braginini
90605a2067 Enable invites only for the local or hosted version 2022-10-13 18:11:30 +02:00
Misha Bragin
18cfddbbe7 Support User Invites (#86)
This PR brings an "Invite User" button to the Users view
and a view to creating (inviting) a new user to the account.
This function calls the Management API POST /users
endpoint that creates a new user.
2022-10-13 18:01:32 +02:00
Maycon Santos
17e671200e Don't display announcement after user close it (#95)
We store the banner text as md5
and the state if user closed already
2022-10-13 17:45:51 +02:00
Maycon Santos
bb94342cc8 Support custom redirect callback URIS (#92) 2022-10-12 12:25:55 +02:00
Misha Bragin
b86cf8b99f Add setup key expiration property when creating new key (#90) 2022-10-06 17:03:09 +02:00
Maycon Santos
f472c06cbf skip analytics on monitoring 2022-10-03 12:27:51 +05:00
Maycon Santos
c58834309b Add hotjar integration (#89)
hotjar is only enabled if NETBIRD_HOTJAR_TRACK_ID is passed
2022-10-02 18:01:06 +05:00
Maycon Santos
75fdd3e17f Add new peer button and user message (#87) 2022-09-30 19:59:55 +02:00
braginini
568c5eccda Change tables key fields styling 2022-09-29 11:00:56 +02:00
braginini
363f226a1c Add me tag to the user view to identify the current user 2022-09-29 10:37:04 +02:00
braginini
bf447b1ada Fix user role display when clicking view 2022-09-29 10:26:02 +02:00
Maycon Santos
90cb05bd2d Parse access token for validation and use latestToke global (#85) 2022-09-27 12:33:55 +02:00
Misha Bragin
a98d6d9ce1 Display additional peer info (#84) 2022-09-26 18:40:06 +02:00
braginini
f83e39d734 Fix deprecated fields warning 2022-09-23 15:05:34 +02:00
braginini
52c1909229 Fix package json 2022-09-23 15:02:23 +02:00
Misha Bragin
f0d893c689 Support user role update (#82) 2022-09-23 14:21:59 +02:00
Misha Bragin
c8339c4be1 Peers auto-tagging with users and predefined groups (#77) 2022-09-22 10:19:53 +02:00
Misha Bragin
954d697b5f Add Access Token hook to return valid token (#81) 2022-09-19 13:11:07 +02:00
Maycon Santos
ace2bb61ef Fix popover for groups (#78)
* Fix popover for groups

Fixed all popover overlay when opening
update modal

also fixed group input size problem
 with peers update

* remove style display flex from form tittle
2022-09-15 22:24:20 +05:00
Misha Bragin
f389862931 Support Auto Groups when updating setup keys (#75)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2022-09-13 17:51:05 +02:00
braginini
521df658ad Shorten the banner text 2022-09-13 11:01:19 +02:00
Maycon Santos
4db17c119a enable new route update 2022-09-08 10:51:36 +02:00
Maycon Santos
230a4cb05e Group network routes by identifier and range (#73)
* fix missing peer when disabling route

and passing peer to modal view

* group rows by HA routes

* Adjust high availability and show fields disabled

* add groupedRoutes to GroupedDataTable

* remove unused fields

* filter by grouped routes

* group routes by network id and network

* use better check for save button
2022-09-08 09:26:38 +02:00
braginini
06316239de Fix routes docs link 2022-09-05 19:45:14 +02:00
braginini
59eff85339 Fix routes docs link 2022-09-05 19:44:49 +02:00
braginini
ffabdf8a1a Add new release banner 2022-09-05 19:09:43 +02:00
Maycon Santos
0fe5aa13b1 Rename Network range and enable masquerade by default (#72) 2022-09-05 16:13:01 +02:00
Maycon Santos
7166eb6e2f Network Routes page (#71)
Add Network routes page to interact with the routes API endpoint of our management
2022-09-05 09:08:51 +02:00
Misha Bragin
c9f1955d6a Fix ACL modal groups input fields width (#69) 2022-08-22 15:27:52 +02:00
Misha Bragin
c3236d05a1 Fix Auth0 OIDC integration 2022-08-17 20:38:52 +02:00
Misha Bragin
25e8a52465 Refactor to generic OIDC config (#66)
Cleans up config.json providing generic OIDC properties.
This allows for using other IDP providers besides Auth0.
The change is backward compatible with the previous versions.
2022-08-15 19:32:01 +02:00
Maycon Santos
2b26198741 As user to refresh page on 401 (#65)
Some checks failed
build and push / build_n_push (push) Has been cancelled
* As user to refresh page on 401

* replace status code string
2022-08-04 20:42:16 +02:00
Maycon Santos
2b359b2140 Update dependencies to resolve issue with mobile (#64)
* Update dependencies to resolve issue with mobile

* ensure min rc-overflow fixes the issue with mobile
2022-08-03 16:48:24 +02:00
Maycon Santos
d71d8214e7 Disable service worker (#63)
Service has caused some bugs where
the trust domains were ignored
and api calls were done without proper credentials
2022-08-03 09:44:46 +02:00
braginini
321e4d8311 Remove banner 2022-08-01 13:32:28 +02:00
Maycon Santos
224a889de3 Parse API URL when using default 443 and 80 ports (#62)
Init scripts will remove ports for replacement when the user
provided default HTTP and HTTPS ports.

This was causing issues with trusted domain logic from the OIDC library.
2022-07-27 11:09:45 +02:00
Maycon Santos
b59865ae05 Replace Auth0 SDK with axa-fr/react-oidc (#60)
Replacing Auth0's SDK with a more generic implementation of an OIDC client.
This will allow us to use other IDP providers that follow the OIDC standards.
2022-07-26 16:13:08 +02:00
1753 changed files with 168698 additions and 17156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
.eslintrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}

View File

@@ -0,0 +1,44 @@
---
name: Bug/Issue report
about: Create a report to help us improve
title: ''
labels: ['needs-triage']
assignees: ''
---
**Describe the problem**
A clear and concise description of what the problem is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Are you using NetBird Cloud?**
Please specify whether you use NetBird Cloud or self-host NetBird's control plane.
**NetBird version**
`netbird version`
**NetBird status -d output:**
If applicable, add the `netbird status -d' command output.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ['feature-request','needs-triage']
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

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

@@ -0,0 +1,19 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
## E2E tests
Optional: override the image tags used by the Playwright e2e workflow.
Defaults to `main` when omitted.
management-cloud-tag: main
reverse-proxy-tag: main

View File

@@ -5,51 +5,122 @@ on:
- main
tags:
- "**"
pull_request:
# Cancel in-progress runs on the same ref (PR or branch) when a new commit
# arrives, so we don't waste CI building superseded commits.
concurrency:
group: build-and-push-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOCKERHUB_IMAGE: netbirdio/dashboard
GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud
jobs:
build_n_push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v5
- name: setup-node
uses: actions/setup-node@v2
uses: actions/setup-node@v5
with:
node-version: '16'
node-version: '20'
cache: 'npm'
- name: Install dependecies
- name: Install dependencies
run: npm install
- name: Build
# skiping fail on warning for now
run: CI=false npm run build
-
- run: echo '{}' > .local-config.json
- name: Download IronRDP release TS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release JS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release WASM file
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Get version from tag
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "version=development" >> $GITHUB_OUTPUT
fi
- name: Build
run: npm run build
env:
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
uses: docker/metadata-action@v5
with:
images: wiretrustee/dashboard
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
-
name: Docker build and push
uses: docker/build-push-action@v2
username: ${{ secrets.NB_DOCKER_USER }}
password: ${{ secrets.NB_DOCKER_TOKEN }}
- name: Log in to the GitHub Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
- run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY

16
.github/workflows/codespell.yml vendored Normal file
View File

@@ -0,0 +1,16 @@
name: Codespell
on: [pull_request]
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: codespell
uses: codespell-project/actions-codespell@v2
with:
only_warn: 1
skip: package-lock.json,*.svg
ignore_words_list: mappin, allTime

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

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

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

@@ -0,0 +1,162 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
# `edited` is included so that updating the PR description (e.g. to set
# `management-cloud-tag: <tag>` or `reverse-proxy-tag: <tag>`) re-triggers
# the e2e run with the new tag.
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
inputs:
management-cloud-tag:
description: 'Management Cloud image tag'
required: true
type: string
default: 'main'
reverse-proxy-tag:
description: 'Reverse Proxy image tag'
required: true
type: string
default: 'main'
env:
REGISTRY: ghcr.io
jobs:
playwright-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
fetch-depth: 0
- name: setup-node
uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.CI_DOCKER_PULL_GITHUB_TOKEN }}
- run: echo '{}' > .local-config.json
- name: Install jq
run: sudo apt-get install jq
- name: Resolve management-cloud image tag
id: management_tag
env:
INPUT_TAG: ${{ inputs.management-cloud-tag }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
# Use workflow_dispatch input if provided, otherwise parse PR body.
# Falls back to `main` when not specified.
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(printf '%s' "$PR_BODY" \
| grep -iE '^[[:space:]]*management-cloud-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \
| head -n1 \
| sed -E 's/^[[:space:]]*[Mm]anagement-cloud-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \
|| true)
if [ -z "$TAG" ]; then
TAG="main"
fi
fi
echo "Using management-cloud tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Resolve reverse-proxy image tag
id: reverse_proxy_tag
env:
INPUT_TAG: ${{ inputs.reverse-proxy-tag }}
PR_BODY: ${{ github.event.pull_request.body }}
run: |
if [ -n "$INPUT_TAG" ]; then
TAG="$INPUT_TAG"
else
TAG=$(printf '%s' "$PR_BODY" \
| grep -iE '^[[:space:]]*reverse-proxy-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \
| head -n1 \
| sed -E 's/^[[:space:]]*[Rr]everse-proxy-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \
|| true)
if [ -z "$TAG" ]; then
TAG="main"
fi
fi
echo "Using reverse-proxy tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Setup test environment
env:
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
run: cd ./e2e/environment && bash create-test-env.sh
- name: Run Playwright tests
id: playwright
run: |
set -o pipefail
npm run test:ci 2>&1 | tee playwright-output.log
- name: Append Playwright summary to job summary
if: always() && hashFiles('e2e/test-results/results.json') != ''
run: |
if [ -f e2e/test-results/results.json ]; then
passed=$(jq '.stats.expected // 0' e2e/test-results/results.json)
failed=$(jq '.stats.unexpected // 0' e2e/test-results/results.json)
skipped=$(jq '.stats.skipped // 0' e2e/test-results/results.json)
duration=$(jq '.stats.duration // 0' e2e/test-results/results.json)
{
echo '### Playwright results'
echo ''
echo "| Passed | Failed | Skipped | Duration |"
echo "|--------|--------|---------|----------|"
echo "| $passed | $failed | $skipped | ${duration}ms |"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Collect container logs
if: failure()
run: |
cd e2e/environment
docker compose logs management --tail=500 --no-color > management.log 2>&1 || true
docker compose logs reverse-proxy --tail=500 --no-color > reverse-proxy.log 2>&1 || true
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: e2e/playwright-report/
- uses: actions/upload-artifact@v7
if: ${{ failure() }}
with:
name: playwright-traces
path: e2e/test-results/
- uses: actions/upload-artifact@v7
if: ${{ failure() }}
with:
name: management-logs
path: e2e/environment/management.log
- uses: actions/upload-artifact@v7
if: ${{ failure() }}
with:
name: reverse-proxy-logs
path: e2e/environment/reverse-proxy.log

41
.gitignore vendored
View File

@@ -8,21 +8,48 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/out
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
src/auth_config.json
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# config
.local*config*.json
.test-config.json
e2e/playwright.env.json
e2e/fixtures/auth/*.json
e2e/test-results/
e2e/playwright-report/
/test-results/
/playwright-report/
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json
.configs
/public/ironrdp-pkg/
/public/netbird.wasm
.idea
.eslintcache
src/.local-config.json
src/.local-config*

View File

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

View File

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

377
AGENTS.md Normal file
View File

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

View File

@@ -1,2 +1,3 @@
Mikhail Bragin (https://github.com/braginini)
Maycon Santos (https://github.com/mlsmaycon)
NetBird GmbH

43
CLAUDE.md Normal file
View File

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

View File

@@ -0,0 +1,64 @@
## Contributor License Agreement
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
of the terms and conditions outlined below. The Contributor further represents that they are authorized to
complete this process as described herein.
## 1 Preamble
In order to clarify the IP Rights situation with regard to Contributions from any person or entity, NetBird
must have a contributor license agreement on file to be signed by each Contributor, containing the license
terms below. This license serves as protection for both the Contributor as well as NetBird and its software users;
it does not change Contributors rights to use his/her own Contributions for any other purpose.
## 2 Definitions
2.1 “IP Rights” shall mean all industrial and intellectual property rights, whether registered or not registered, whether created by Contributor or acquired by Contributor from third parties, and similar rights, including (but not limited to) semiconductor property rights, design rights, copyrights (including in the form of database rights and rights to software), all neighbouring rights (Leistungsschutzrechte), trademarks, service marks, titles, internet domain names, trade names and other labelling rights, rights deriving from corresponding applications and registrations of such rights as well as any licenses (Nutzungsrechte) under and entitlements to any such intellectual and industrial property rights.
2.2 "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is or previously has been intentionally Submitted by Contributor to NetBird for inclusion in, or documentation of any Work.
2.3 "Contributor" shall mean the copyright owner or legal entity authorized by the copyright owner that is concluding this Agreement with NetBird. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
2.4 "Submitted" shall mean any form of electronic, verbal, or written communication sent to NetBird or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, NetBird for the purpose of discussing and improving the Work, but excluding communication that is marked or otherwise designated in writing by Contributor as "Not a Contribution".
2.5 "Work" means any of the products owned or managed by NetBird, in particular, but not exclusively, software.
## 3 Licenses
3.1 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce by any means and in any form, in whole or in part, permanently or temporarily, the Contributions (including loading, displaying, executing, transmitting or storing works for the purpose of executing and processing data or transferring them to video, audio and other data carriers), including the right to distribute, display and present such Contributions and make them available to the public (e.g. via the internet) and to transmit and display such Contributions by any means. The license also includes the right to modify, translate, adapt, edit and otherwise alter the Contributions and to use these results in the same manner as the original Contributions and derivative works. Except for licenses in patents acc. to Sec. 3, such license refers to any IP Rights in the Contributions and derivative works. The Contributor acknowledges that NetBird is not required to credit them by name for their Contribution and agrees to waive any moral rights associated with their Contribution in relation to NetBird or its sublicensees.
3.2 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license in the Contributions to make, have made, use, sell, offer to sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by the Contributor which are necessarily infringed by Contributors Contribution(s) alone or by combination of Contributors Contribution(s) with the Work to which such Contribution(s) was Submitted.
3.3 NetBird hereby accepts such licenses.
## 4 Contributors Representations
4.1 Contributor represents that Contributor is legally entitled to grant the above license. If Contributors employer has IP Rights to Contributors Contributions, Contributor represent that he/she has received permission to make Contributions on behalf of such employer, that such employer has waived such IP Rights to the Contributions of Contributor to NetBird, or that such employer has executed a separate contributor license agreement with NetBird.
4.2 Contributor represents that any Contribution is his/her original creation.
4.3 Contributor represents to his/her best knowledge that any Contribution does not violate any third party IP Rights.
4.4 Contributor represents that any Contribution submission includes complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which Contributor is personally aware and which are associated with any part of the Contribution.
4.5 The Contributor represents that their Contribution does not include any work distributed under a copyleft license.
## 5 Information obligation
Contributor agrees to notify NetBird of any facts or circumstances of which Contributor become aware that would make these representations inaccurate in any respect.
## 6 Submission of Third-Party works
Should Contributor wish to submit work that is not Contributors original creation, Contributor may submit it to NetBird separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which Contributor are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
## 7 No Consideration
Unless compensation is mandatory under statutory law, no compensation for any license under this agreement shall be payable.
## 8 Final Provisions
8.1 Laws. This Agreement is governed by the laws of the Federal Republic of Germany.
8.2 Venue. Place of jurisdiction shall, to the extent legally permissible, be Berlin, Germany.
8.3 Severability. If any provision in this agreement is unlawful, invalid or ineffective, it shall not affect the enforceability or effectiveness of the remainder of this agreement. The parties agree to replace any unlawful, invalid or ineffective provision with a provision that comes as close as possible to the commercial intent and purpose of the original provision. This section also applies accordingly to any gaps in the contract.
8.4 Variations. Any variations, amendments or supplements to this Agreement must be in writing. This also applies to any variation of this Section 8.4.

662
LICENSE
View File

@@ -1,13 +1,661 @@
BSD 3-Clause License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2021 Wiretrustee AUTHORS
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Preamble
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,4 +1,4 @@
# NetBird dashboard
# NetBird Dashboard
This project is the UI for NetBird's Management service.
@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
The dashboard makes it possible to:
- track the status of your peers
- remove peers
- manage Setup Keys (to authenticate new peers)
@@ -17,43 +18,80 @@ The dashboard makes it possible to:
- define access controls
## Some Screenshots
<img src="./media/auth.png" alt="auth"/>
<img src="./media/peers.png" alt="peers"/>
<img src="./media/add-peer.png" alt="add-peer"/>
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
## Technologies Used
- NextJS
- ReactJS
- AntD UI framework
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker
- Let's Encrypt
## How to run
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
1. install [Docker](https://docs.docker.com/get-docker/)
2. register [Auth0](https://auth0.com/) account
3. running Wiretrustee UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
1. Install [Docker](https://docs.docker.com/get-docker/)
2. Register [Auth0](https://auth0.com/) account
3. Running NetBird UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
```AUTH0_DOMAIN``` ```AUTH0_CLIENT_ID``` ```AUTH0_AUDIENCE```
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins"
4. Wiretrustee UI Dashboard uses Wiretrustee Management Service HTTP API, so setting ```WIRETRUSTEE_MGMT_API_ENDPOINT``` is required. Most likely it will be ```http://localhost:33071``` if you are hosting Management API on the same server.
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
5. Run docker container without SSL (Let's Encrypt):
```docker run -d --name wiretrustee-dashboard --rm -p 80:80 -p 443:443 -e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> -e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> -e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> -e WIRETRUSTEE_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> wiretrustee/dashboard:main```
```shell
docker run -d --name netbird-dashboard \
--rm -p 80:80 -p 443:443 \
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
6. Run docker container with SSL (Let's Encrypt):
```docker run -d --name wiretrustee-dashboard --rm -p 80:80 -p 443:443 -e NGINX_SSL_PORT=443 -e LETSENCRYPT_DOMAIN=<YOUR PUBLIC DOMAIN> -e LETSENCRYPT_EMAIL=<YOUR EMAIL> -e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> -e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> -e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> -e WIRETRUSTEE_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> wiretrustee/dashboard:main```
```shell
docker run -d --name netbird-dashboard \
--rm -p 80:80 -p 443:443 \
-e NGINX_SSL_PORT=443 \
-e LETSENCRYPT_DOMAIN=<YOUR PUBLIC DOMAIN> \
-e LETSENCRYPT_EMAIL=<YOUR EMAIL> \
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
netbirdio/dashboard:main
```
## How to run local development
1. Install node 16
2. create and update the src/.local-config.json file. This file should contain values to be replaced from src/config.json
3. run `npm install`
4. run `npm run start dev`
1. Install [Node](https://nodejs.org/)
2. Create and update the `.local-config.json` file. This file should contain values to be replaced from `config.json`
3. Run `npm install` to install dependencies
4. Run `npm run dev` to start the development server
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing by modifying the code inside `src/..`
The page auto-updates as you edit the file.
## How to migrate from old dashboard (v1)
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
1. Stop the dashboard container `docker compose down dashboard`
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`

12
announcements.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"tag": "Preview",
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
"link": "https://github.com/netbirdio/netbird/discussions/6483",
"linkText": "Learn more",
"variant": "important",
"isExternal": true,
"closeable": true,
"isCloudOnly": false
}
]

103
batch-i18n.py Normal file
View File

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

16
components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": false
},
"aliases": {
"components": "@/components",
"utils": "@/utils/helpers"
}
}

26
config.json Normal file
View File

@@ -0,0 +1,26 @@
{
"auth0Auth": "$USE_AUTH0",
"authAuthority": "$AUTH_AUTHORITY",
"authClientId": "$AUTH_CLIENT_ID",
"authClientSecret": "$AUTH_CLIENT_SECRET",
"authScopesSupported": "$AUTH_SUPPORTED_SCOPES",
"authAudience": "$AUTH_AUDIENCE",
"apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT",
"grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT",
"redirectURI": "$AUTH_REDIRECT_URI",
"silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI",
"tokenSource": "$NETBIRD_TOKEN_SOURCE",
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL",
"wasmPath": "$NETBIRD_WASM_PATH",
"licensed": "$NETBIRD_LICENSED",
"cloud": "$NETBIRD_CLOUD",
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
}

View File

@@ -1,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 build/ /usr/share/nginx/html/
# Copy build files
COPY out/ /usr/share/nginx/html/
# Copy server script
COPY docker/server.js /server.js
EXPOSE 80
CMD ["node", "/server.js"]

View File

@@ -1,20 +1,20 @@
# Wiretrustee Dashboard
Wiretrustee Dashboard is a the Wiretrustee Managemenet server UI. It allow users to signin, view setup keys and manage peers. This image is **not ready** for production use.
# NetBird Dashboard
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
## Tags
```latest``` ```vX.X.X``` not available yet.
```main``` builded on every PR being merged to the repository
```main``` built on every PR being merged to the repository
## How to use this image
HTTP run:
```shell
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
```
Using SSL certificate from Let's Encript®:
Using SSL certificate from Let's Encrypt®:
```shell
docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
wiretrustee/dashboard:main
netbirdio/dashboard:main
```
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
## Environment variables

View File

@@ -1,16 +1,37 @@
# simple server configuration to replace nginx's default
server {
listen 80 default_server;
listen [::]:80 default_server;
root /usr/share/nginx/html;
location = /netbird.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri /index.html;
}
# You may need this to prevent return 404 recursion.
location = /404.html {
internal;
}
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
add_header Last-Modified "";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
add_header Last-Modified "";
expires off;
}
}

View File

@@ -1,19 +1,43 @@
#!/bin/bash
set -e
if [[ -z "${AUTH0_DOMAIN}" ]]; then
echo "AUTH0_DOMAIN environment variable must be set"
exit 1
if [[ -z "${AUTH_AUTHORITY}" ]]; then
if [[ -z "${AUTH0_DOMAIN}" ]]; then
echo "AUTH_AUTHORITY or AUTH0_DOMAIN environment variable must be set"
exit 1
fi
fi
if [[ -z "${AUTH0_CLIENT_ID}" ]]; then
echo "AUTH0_CLIENT_ID environment variable must be set"
exit 1
if [[ -z "${AUTH_CLIENT_ID}" ]]; then
if [[ -z "${AUTH0_CLIENT_ID}" ]]; then
echo "AUTH_CLIENT_ID or AUTH0_CLIENT_ID environment variable must be set"
exit 1
fi
fi
if [[ -z "${AUTH0_AUDIENCE}" ]]; then
echo "AUTH0_AUDIENCE environment variable must be set"
exit 1
if [[ -z "${AUTH_AUDIENCE}" ]]; then
if [[ -z "${AUTH0_AUDIENCE}" ]]; then
echo "AUTH_AUDIENCE or AUTH0_AUDIENCE environment variable must be set"
exit 1
fi
fi
if [[ "${AUTH_AUDIENCE}" == "none" ]]; then
unset AUTH_AUDIENCE
fi
if [[ -z "${AUTH_SUPPORTED_SCOPES}" ]]; then
if [[ -z "${AUTH0_DOMAIN}" ]]; then
echo "AUTH_SUPPORTED_SCOPES environment variable must be set"
exit 1
fi
fi
if [[ -z "${USE_AUTH0}" ]]; then
if [[ -z "${AUTH0_DOMAIN}" ]]; then
echo "USE_AUTH0 environment variable must be set"
exit 1
fi
fi
if [[ -z "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
@@ -21,23 +45,116 @@ if [[ -z "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
exit 1
fi
AUTH0_DOMAIN=${AUTH0_DOMAIN}
AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID}
AUTH0_AUDIENCE=${AUTH0_AUDIENCE}
NETBIRD_MGMT_API_ENDPOINT=${NETBIRD_MGMT_API_ENDPOINT}
NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
export AUTH_AUTHORITY=${AUTH_AUTHORITY:-https://$AUTH0_DOMAIN}
export AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-$AUTH0_CLIENT_ID}
export AUTH_CLIENT_SECRET=${AUTH_CLIENT_SECRET}
export AUTH_AUDIENCE=${AUTH_AUDIENCE:-$AUTH0_AUDIENCE}
export AUTH_REDIRECT_URI=${AUTH_REDIRECT_URI}
export AUTH_SILENT_REDIRECT_URI=${AUTH_SILENT_REDIRECT_URI}
export USE_AUTH0=${USE_AUTH0:-true}
export AUTH_SUPPORTED_SCOPES=${AUTH_SUPPORTED_SCOPES:-openid profile email api offline_access email_verified}
export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(:80|:443)$//')
export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_AUTH_SERVICE_URL=${NETBIRD_AUTH_SERVICE_URL}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
export NETBIRD_CSP=${NETBIRD_CSP}
export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false}
export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false}
export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID}
export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
REPO="https://github.com/netbirdio/netbird/"
# this command will fetch the latest release e.g. v0.6.3
export NETBIRD_LATEST_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} ${REPO}releases/latest))
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# Build CSP
FIRST_PARTY_CSP="pkgs.netbird.io"
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
CSP_DOMAINS=""
CSP_DOMAINS_CONNECT_SRC=""
if [[ -n "${NETBIRD_CSP}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP"
fi
# Add AUTH_AUTHORITY to CSP
if [[ -n "${AUTH_AUTHORITY}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
fi
# Add AUTH_AUDIENCE to CSP
if [[ -n "${AUTH_AUDIENCE}" && ("${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"*) ]]; then
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE"
fi
# Add NETBIRD_AUTH_SERVICE_URL to CSP
if [[ -n "${NETBIRD_AUTH_SERVICE_URL}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_AUTH_SERVICE_URL"
fi
# Add NETBIRD_MGMT_API_ENDPOINT to CSP
if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
MGMT_DOMAIN=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1)
if [[ -n "$MGMT_DOMAIN" ]]; then
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_DOMAIN"
elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_DOMAIN"
fi
fi
fi
# Add LETSENCRYPT_DOMAIN to CSP
if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then
if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then
CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN"
else
CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN"
fi
fi
CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC"
CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP"
CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC"
# Remove duplicates
CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
# Update CSP in nginx config
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
echo "CSP header: $CSP_HEADER"
# Replace CSP header in nginx config
sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || {
echo "Failed to replace CSP header"
}
# replace ENVs in the config
ENV_STR="\$\$AUTH0_DOMAIN \$\$AUTH0_CLIENT_ID \$\$AUTH0_AUDIENCE \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_LATEST_VERSION"
MAIN_JS=$(find /usr/share/nginx/html/static/js/main.*js)
cp "$MAIN_JS" "$MAIN_JS".copy
envsubst "$ENV_STR" < "$MAIN_JS".copy > "$MAIN_JS"
rm "$MAIN_JS".copy
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
cp "$f" "$f".copy
envsubst "$ENV_STR" < "$f".copy > "$f"
rm "$f".copy
done

View File

@@ -101,6 +101,7 @@ http {
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/wasm
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml

138
docker/server.js Normal file
View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

233
e2e/CLAUDE.md Normal file
View File

@@ -0,0 +1,233 @@
# Playwright E2E Testing Guide
Complete reference for writing, running, and debugging Playwright E2E tests in the NetBird Dashboard.
## Philosophy
Tests simulate real user behavior: navigate via sidebar, click buttons, type into inputs, verify outcomes on screen. Use `{ force: true }` for Radix modal pointer-events issues.
## Setup & Running
```bash
npm run test:setup # Create docker-based test environment with Zitadel
npm run test:dev # Start app in test mode on http://localhost:1337
npm run test # Run all e2e tests headless
npm run test:ui # Open Playwright interactive UI
npx playwright test --config=e2e/playwright.config.ts tests/networks.spec.ts # Single spec
npm run test:clean # Tear down test environment
```
Config: `e2e/playwright.config.ts` (baseURL: `http://localhost:1337`). Auth: `e2e/playwright.env.json` (gitignored).
### Config Details
- `fullyParallel: false` — tests run sequentially within each spec
- Workers: 2 in CI, 4 locally
- Retries: 1
- Viewport: 1920x1080
- Timeouts: action 10s, navigation 15s
- On failure: screenshot, trace, video retained
## File Structure
```
e2e/
playwright.config.ts
helpers/
fixtures.ts # dashboardAsOwner / dashboardAsUser fixtures
auth.ts # loginToApp(), navigateTo()
navigation.ts # visitByNavigation()
utils.ts # generateRandomName(), clearScrollLock()
api.ts # Direct REST API helpers (list/delete for all entities)
reverse-proxy-l4.ts # Shared L4 reverse proxy helpers
fixtures/auth/ # Generated storageState files (gitignored)
environment/ # Docker compose, setup/teardown scripts
tests/
login.spec.ts # Auth setup (login both users, save storageState)
*.spec.ts # Test specs
```
## Architecture
Auth is handled by `login.spec.ts`, which runs as a separate Playwright project (`"login"`) that all other tests depend on via `dependencies: ["login"]` in the config. It logs in both users and saves Zitadel session cookies to `fixtures/auth/`. If auth files already exist, login is skipped. Each test file that modifies shared state (e.g., user roles) must restore it before finishing.
## Authentication
Two test users authenticated via the `login` project, saved as `storageState`:
| User | File | Role | Usage |
|------|------|------|-------|
| owner | `fixtures/auth/owner.json` | Owner | Default for all tests |
| user | `fixtures/auth/user.json` | User (changeable) | Role-based testing |
### Custom Fixtures (`helpers/fixtures.ts`)
Tests use custom fixtures instead of raw `page`:
```typescript
import { test, expect } from "../helpers/fixtures";
test("example", async ({ dashboardAsOwner: page }) => {
// Pre-authenticated as owner, reused across worker
});
test("multi-user", async ({ dashboardAsUser: page }) => {
// Pre-authenticated as user
});
```
- `dashboardAsOwner` — Pre-authenticated Page for the owner user (worker-scoped, reused across tests)
- `dashboardAsUser` — Pre-authenticated Page for the user user (worker-scoped)
For multi-context scenarios (e.g., approval/billing tests), create a new browser context directly:
```typescript
const context = await browser.newContext({ storageState: "e2e/fixtures/auth/user.json" });
const page = await context.newPage();
```
## Helpers Reference
### `auth.ts`
- **`loginToApp(page, user?)`** — Full Zitadel OIDC login flow. Handles app ready, setup modal, approval pending, onboarding, account selection, and login form states.
- **`navigateTo(page, path)`** — `page.goto(path)` + dismisses setup modal if present + clears scroll-lock.
### `navigation.ts`
- **`visitByNavigation(page, navText)`** — Clicks sidebar items by exact text via `left-navigation-item` testid.
### `utils.ts`
- **`generateRandomName(prefix?)`** — Returns `prefix` + 7 random alphanumeric chars.
- **`clearScrollLock(page)`** — Removes Radix artifacts: `data-scroll-locked`, `pointer-events: none`, stale overlay divs.
### `api.ts`
Direct REST API helpers that extract Bearer tokens from intercepted responses. Used for cleanup (deleting test artifacts by prefix). Covers: groups, networks, policies, routes, setup keys, DNS zones, nameserver groups, notification channels, reverse proxy services, users.
Pattern: `listX(page)` / `deleteXById(page, id)` / `deleteXByPrefix(page, prefix)`
### `reverse-proxy-l4.ts`
Shared helpers for TCP/TLS/UDP reverse proxy service tests:
- **`createNetwork(page)`** — Creates network, returns name
- **`addResource(page, networkName, address)`** — Adds resource to a network
- **`selectL4Resource(page, resourceName)`** — Selects resource in L4 target dropdown
- **`addAccessControlRules(page)`** / **`removeAllAccessControlRules(page)`** — Manages standard test rules
- **`resetServiceFilters(page)`** — Clicks "Reset Filters & Search" button if visible
- **`openServiceEdit(page, subdomain)`** — Navigates to services, resets filters, opens edit modal
- **`deleteService(page, subdomain)`** — Deletes service via action dropdown
- **`saveServiceEdit(page)`** — Saves with "No Protection" confirmation handling
- **`deleteNetwork(page, networkName)`** — Navigates to networks and deletes by name
## Writing Tests
### Standard Structure
```typescript
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
test.describe.serial("Feature Name", () => {
test("Should create an item", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/feature-page");
const name = generateRandomName("prefix-");
// ... create item
});
test("Should delete the item", async ({ dashboardAsOwner: page }) => {
// ... cleanup
});
});
```
### Key Patterns
**Selectors** — Always use `data-testid` via `page.getByTestId()`:
```typescript
page.getByTestId("group-name-input") // [data-testid="group-name-input"]
page.getByTestId("confirmation.confirm") // Confirmation dialogs
```
**Text matching:**
```typescript
page.getByText("Some text")
page.locator("tr").filter({ hasText: name })
```
**Assertions:**
```typescript
await expect(locator).toBeVisible()
await expect(locator).not.toBeVisible()
await expect(locator).toHaveAttribute("data-state", "checked")
await expect(locator).toContainText("text")
```
**Form inputs:**
```typescript
await input.fill("text") // Clears and types
await input.press("Enter")
await input.press("Escape")
```
**Radix modal workaround:**
```typescript
await button.click({ force: true }); // Force click, bypasses pointer-events checks
```
**Waiting for API responses:**
```typescript
const responsePromise = page.waitForResponse(
resp => resp.url().includes("/api/...") && resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit").click();
const response = await responsePromise;
expect([200, 201]).toContain(response.status());
```
**Cleanup with API helpers:**
```typescript
import { deleteGroupsByPrefix, deleteServicesByPrefix } from "../helpers/api";
// At the start of a test or in cleanup
await deleteServicesByPrefix(page, "my-prefix-");
await deleteGroupsByPrefix(page, "my-prefix-");
```
### Sidebar Navigation
```typescript
await visitByNavigation(page, "Access Control"); // Expand parent
await visitByNavigation(page, "Policies"); // Click child
```
| Parent | Children |
|--------|----------|
| Access Control | Policies, Groups, Posture Checks |
| Team | Users, Service Users |
| DNS | Nameservers, Zones, DNS Settings |
| Reverse Proxy | Custom Domains, Services |
## Test Coverage
| Area | Spec Files | Tag |
|------|-----------|-----|
| Access Control | `access-control.spec.ts`, `access-control-groups.spec.ts` | `@access-control` |
| DNS | `dns-zones.spec.ts`, `dns-nameservers.spec.ts`, `dns-settings.spec.ts` | `@dns` |
| Networks | `networks.spec.ts`, `network-routes.spec.ts` | `@network` |
| Reverse Proxy | `reverse-proxy-services-https.spec.ts`, `reverse-proxy-services-tcp.spec.ts`, `reverse-proxy-services-tls.spec.ts`, `reverse-proxy-services-udp.spec.ts`, `reverse-proxy-custom-domains.spec.ts` | `@reverse-proxy` |
| Settings | `settings-authentication.spec.ts`, `settings-clients.spec.ts`, `settings-groups.spec.ts`, `settings-networks.spec.ts`, `settings-permissions.spec.ts` | `@settings` |
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
## Debugging
1. `e2e/test-results/` — traces and screenshots on failure
2. `npx playwright show-report` — open the HTML report
3. `npm run test:ui` — interactive mode with step-by-step execution
4. `npx playwright test --config=e2e/playwright.config.ts --debug tests/<file>` — debugger mode
## `data-testid` Conventions
- Use `data-testid` selectors throughout. Add new ones to React components as needed.
- Kebab-case naming: `feature-field-input`, `action-feature`, `feature-actions`.
- Always use `data-testid` — both on native HTML elements and custom components. Custom components declare `"data-testid"?: string` in their props interface and place it on the appropriate internal DOM element.

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

@@ -0,0 +1,12 @@
# Ignore zitadel environment
.env
Caddyfile
management.json
turnserver.conf
zitadel.env
proxy.env
proxy-no-ports.env
proxy-certs/
proxy-certs-no-ports/
docker-compose.yml
/machinekey

View File

@@ -0,0 +1,25 @@
#!/bin/bash
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
echo "docker-compose"
return
fi
if docker compose --help &> /dev/null
then
echo "docker compose"
return
fi
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
exit 1
}
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json

View File

@@ -0,0 +1,927 @@
#!/bin/bash
set -e
# Tag of the management-cloud image to pull. Override via env var to pin the
# tests to a specific management-cloud build (e.g., a feature branch image).
MANAGEMENT_IMAGE_TAG="${MANAGEMENT_IMAGE_TAG:-main}"
echo "Using ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}"
# Tag of the reverse-proxy image to pull. Override via env var to pin the
# tests to a specific reverse-proxy build (e.g., a feature branch image).
REVERSE_PROXY_IMAGE_TAG="${REVERSE_PROXY_IMAGE_TAG:-main}"
echo "Using ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}"
handle_request_command_status() {
PARSED_RESPONSE=$1
FUNCTION_NAME=$2
RESPONSE=$3
if [[ $PARSED_RESPONSE -ne 0 ]]; then
echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr
exit 1
fi
}
handle_zitadel_request_response() {
PARSED_RESPONSE=$1
FUNCTION_NAME=$2
RESPONSE=$3
if [[ $PARSED_RESPONSE == "null" ]]; then
echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr
exit 1
fi
sleep 1
}
check_docker_compose() {
if command -v docker-compose &> /dev/null
then
echo "docker-compose"
return
fi
if docker compose --help &> /dev/null
then
echo "docker compose"
return
fi
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
exit 1
}
check_jq() {
if ! command -v jq &> /dev/null
then
echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr
exit 1
fi
}
wait_proxy_cluster() {
SERVICE_NAME=${1:-reverse-proxy}
echo -n "Waiting for $SERVICE_NAME to register with management "
set +e
local attempts=60
local i
for ((i = 1; i <= attempts; i++)); do
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
echo " done"
set -e
return
fi
echo -n " ."
sleep 2
done
echo ""
echo "ERROR: $SERVICE_NAME did not register with management after $((attempts * 2))s"
echo "--- $SERVICE_NAME logs ---"
$DOCKER_COMPOSE_COMMAND logs --tail=50 "$SERVICE_NAME" || true
exit 1
}
wait_crdb() {
set +e
while true; do
if $DOCKER_COMPOSE_COMMAND exec -T crdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then
break
fi
echo -n " ."
sleep 5
done
echo " done"
set -e
}
init_crdb() {
echo -e "\nInitializing Zitadel's CockroachDB\n\n"
$DOCKER_COMPOSE_COMMAND up -d crdb
echo ""
# shellcheck disable=SC2028
echo -n "Waiting cockroachDB to become ready "
wait_crdb
$DOCKER_COMPOSE_COMMAND exec -T crdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/"
handle_request_command_status $? "init_crdb failed" ""
}
get_main_ip_address() {
if [[ "$OSTYPE" == "darwin"* ]]; then
interface=$(route -n get default | grep 'interface:' | awk '{print $2}')
ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}')
else
interface=$(ip route | grep default | awk '{print $5}' | head -n 1)
ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1)
fi
echo "$ip_address"
}
wait_pat() {
PAT_PATH=$1
set +e
while true; do
if [[ -f "$PAT_PATH" ]]; then
break
fi
echo -n " ."
sleep 1
done
echo " done"
set -e
}
wait_api() {
INSTANCE_URL=$1
PAT=$2
set +e
while true; do
curl -s --fail -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT"
if [[ $? -eq 0 ]]; then
break
fi
echo -n " ."
sleep 1
done
echo " done"
set -e
}
create_new_project() {
INSTANCE_URL=$1
PAT=$2
PROJECT_NAME="NETBIRD"
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name": "'"$PROJECT_NAME"'"}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id')
handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
create_new_application() {
INSTANCE_URL=$1
PAT=$2
APPLICATION_NAME=$3
BASE_REDIRECT_URL1=$4
BASE_REDIRECT_URL2=$5
LOGOUT_URL=$6
ZITADEL_DEV_MODE=$7
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"name": "'"$APPLICATION_NAME"'",
"redirectUris": [
"'"$BASE_REDIRECT_URL1"'",
"'"$BASE_REDIRECT_URL2"'"
],
"postLogoutRedirectUris": [
"'"$LOGOUT_URL"'"
],
"RESPONSETypes": [
"OIDC_RESPONSE_TYPE_CODE"
],
"grantTypes": [
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
"OIDC_GRANT_TYPE_REFRESH_TOKEN"
],
"appType": "OIDC_APP_TYPE_USER_AGENT",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"version": "OIDC_VERSION_1_0",
"devMode": '"$ZITADEL_DEV_MODE"',
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true,
"skipNativeAppSuccessPage": true
}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId')
handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
create_service_user() {
INSTANCE_URL=$1
PAT=$2
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"userName": "netbird-service-account",
"name": "Netbird Service Account",
"description": "Netbird Service Account for IDP management",
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId')
handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
create_service_user_secret() {
INSTANCE_URL=$1
PAT=$2
USER_ID=$3
RESPONSE=$(
curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{}'
)
SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId')
handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE"
SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret')
handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE"
}
add_organization_user_manager() {
INSTANCE_URL=$1
PAT=$2
USER_ID=$3
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"userId": "'"$USER_ID"'",
"roles": [
"ORG_USER_MANAGER"
]
}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate')
handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
create_admin_user() {
INSTANCE_URL=$1
PAT=$2
USERNAME=$3
PASSWORD=$4
FIRST_NAME=${5:-"Zitadel"}
LAST_NAME=${6:-"Admin"}
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"userName": "'"$USERNAME"'",
"profile": {
"firstName": "'"$FIRST_NAME"'",
"lastName": "'"$LAST_NAME"'"
},
"email": {
"email": "'"$USERNAME"'",
"isEmailVerified": true
},
"password": "'"$PASSWORD"'",
"passwordChangeRequired": false
}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId')
handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
add_instance_admin() {
INSTANCE_URL=$1
PAT=$2
USER_ID=$3
RESPONSE=$(
curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"userId": "'"$USER_ID"'",
"roles": [
"IAM_OWNER"
]
}'
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate')
handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
delete_auto_service_user() {
INSTANCE_URL=$1
PAT=$2
RESPONSE=$(
curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
)
USER_ID=$(echo "$RESPONSE" | jq -r '.user.id')
handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE"
RESPONSE=$(
curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate')
handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE"
RESPONSE=$(
curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
)
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate')
handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE"
echo "$PARSED_RESPONSE"
}
create_proxy_token() {
TOKEN_NAME=$1
echo "Creating proxy token '$TOKEN_NAME'..." >&2
local attempts=30
local delay=2
local i
local out=""
local tok=""
for ((i = 1; i <= attempts; i++)); do
out=$($DOCKER_COMPOSE_COMMAND exec -T management /go/bin/netbird-mgmt token create \
--name "$TOKEN_NAME" \
--config /etc/netbird/management.json \
--log-file console \
--log-level error 2>&1 || true)
tok=$(echo "$out" | grep "^Token:" | awk '{print $2}')
if [ -n "$tok" ]; then
break
fi
echo " attempt $i/$attempts: management not ready yet, retrying in ${delay}s..." >&2
sleep "$delay"
done
if [ -z "$tok" ]; then
echo "ERROR: Failed to create proxy token '$TOKEN_NAME' after $attempts attempts" >&2
echo "Last output from management:" >&2
echo "$out" >&2
echo "--- docker compose ps ---" >&2
$DOCKER_COMPOSE_COMMAND ps >&2 || true
echo "--- management logs ---" >&2
$DOCKER_COMPOSE_COMMAND logs --tail=200 management >&2 || true
exit 1
fi
echo "Proxy token '$TOKEN_NAME' created: ${tok:0:10}..." >&2
echo "$tok"
}
init_proxy_tokens() {
echo "Waiting for management container to become ready..."
# Default proxy (supports custom ports)
NB_PROXY_TOKEN=$(create_proxy_token "test-proxy")
cat > proxy.env <<PROXYEOF
NB_PROXY_TOKEN=$NB_PROXY_TOKEN
NB_PROXY_ALLOW_INSECURE=true
NB_PROXY_LOG_LEVEL=trace
PROXYEOF
# Secondary proxy (custom ports disabled)
NB_PROXY_TOKEN_NO_PORTS=$(create_proxy_token "test-proxy-no-ports")
cat > proxy-no-ports.env <<PROXYEOF
NB_PROXY_TOKEN=$NB_PROXY_TOKEN_NO_PORTS
NB_PROXY_ALLOW_INSECURE=true
PROXYEOF
}
init_zitadel() {
echo -e "\nInitializing Zitadel with NetBird's applications\n"
INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
TOKEN_PATH=./machinekey/zitadel-admin-sa.token
echo -n "Waiting for Zitadel's PAT to be created "
wait_pat "$TOKEN_PATH"
echo "Reading Zitadel PAT"
PAT=$(cat $TOKEN_PATH)
if [ "$PAT" = "null" ]; then
echo "Failed requesting getting Zitadel PAT"
exit 1
fi
echo -n "Waiting for Zitadel to become ready "
wait_api "$INSTANCE_URL" "$PAT"
# create the zitadel project
echo "Creating new zitadel project"
PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT")
ZITADEL_DEV_MODE=false
BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then
ZITADEL_DEV_MODE=true
fi
# create zitadel spa applications
echo "Creating new Zitadel SPA Dashboard application"
DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "http://localhost:1337/nb-auth" "http://localhost:1337/nb-silent-auth" "http://localhost:1337/" "true")
echo "Creating new Zitadel SPA Cli application"
CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true")
MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT")
SERVICE_USER_CLIENT_ID="null"
SERVICE_USER_CLIENT_SECRET="null"
create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID"
DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID")
ZITADEL_ADMIN_USERNAME="owner@localhost.test"
ZITADEL_ADMIN_PASSWORD="testMe123@"
HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD")
DATE="null"
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID")
# Create a second user for role-based testing (e.g., Billing Admin)
ZITADEL_SECOND_USERNAME="user@localhost.test"
ZITADEL_SECOND_PASSWORD="testMe123@"
SECOND_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_SECOND_USERNAME" "$ZITADEL_SECOND_PASSWORD" "Zitadel" "User")
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$SECOND_USER_ID")
DATE="null"
DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT")
if [ "$DATE" = "null" ]; then
echo "Failed deleting auto service user"
echo "Please remove it manually"
fi
export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID
export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID
export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID
export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET
export ZITADEL_ADMIN_USERNAME
export ZITADEL_ADMIN_PASSWORD
}
check_nb_domain() {
DOMAIN=$1
if [ "$DOMAIN-x" == "-x" ]; then
echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr
return 1
fi
if [ "$DOMAIN" == "netbird.example.com" ]; then
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
return 1
fi
return 0
}
read_nb_domain() {
READ_NETBIRD_DOMAIN=""
echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr
read -r READ_NETBIRD_DOMAIN < /dev/tty
if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then
read_nb_domain
fi
echo "$READ_NETBIRD_DOMAIN"
}
initEnvironment() {
CADDY_SECURE_DOMAIN=""
ZITADEL_EXTERNALSECURE="false"
ZITADEL_TLS_MODE="disabled"
ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)"
NETBIRD_PORT=33080
NETBIRD_HTTP_PROTOCOL="http"
TURN_USER="self"
TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g')
TURN_MIN_PORT=49152
TURN_MAX_PORT=65535
NETBIRD_DOMAIN=$(get_main_ip_address)
if [[ "$OSTYPE" == "darwin"* ]]; then
ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ")
else
ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ")
fi
check_jq
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
if [ -f zitadel.env ]; then
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
echo "" > proxy.env
echo "" > proxy-no-ports.env
mkdir -p machinekey
chmod 777 machinekey
init_crdb
echo -e "\nStarting Zidatel IDP for user management\n\n"
$DOCKER_COMPOSE_COMMAND up -d caddy zitadel
init_zitadel
echo -e "\nRendering NetBird files...\n"
renderTurnServerConf > turnserver.conf
renderManagementJson > management.json
renderDashboardEnv > "../../.test-config.json"
echo -e "\nRendering Playwright environment file...\n"
renderPlaywrightEnv > "../playwright.env.json"
echo -e "\nPulling latest images...\n"
docker pull "ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}"
docker pull "ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}"
# Pre-create the proxy cert directories BEFORE starting containers so that
# docker's bind-mounts (./proxy-certs and ./proxy-certs-no-ports) reuse our
# runner-owned dirs instead of creating root-owned ones, which would
# prevent openssl from writing the generated keys/certs below. Each proxy
# gets its own cert dir so it registers with a distinct identity (a shared
# cert collapses both proxies onto one proxy ID and management superseding
# flaps cluster registration).
mkdir -p proxy-certs proxy-certs-no-ports
echo -e "\nStarting NetBird services\n"
$DOCKER_COMPOSE_COMMAND up -d
echo -e "\nWaiting for management to be ready...\n"
sleep 5
echo -e "\nGenerating self-signed TLS certificates for reverse proxies...\n"
openssl req -x509 -newkey rsa:2048 -keyout proxy-certs/tls.key -out proxy-certs/tls.crt \
-days 365 -nodes -subj "/CN=example.com" \
-addext "subjectAltName=DNS:example.com,DNS:*.example.com,DNS:noports.example.com,DNS:*.noports.example.com"
chmod 644 proxy-certs/tls.key proxy-certs/tls.crt
openssl req -x509 -newkey rsa:2048 -keyout proxy-certs-no-ports/tls.key -out proxy-certs-no-ports/tls.crt \
-days 365 -nodes -subj "/CN=noports.example.com" \
-addext "subjectAltName=DNS:noports.example.com,DNS:*.noports.example.com"
chmod 644 proxy-certs-no-ports/tls.key proxy-certs-no-ports/tls.crt
echo -e "\nCreating proxy access tokens...\n"
init_proxy_tokens
echo -e "\nStarting reverse proxy services...\n"
$DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports
echo -e "\nWaiting for reverse proxies to register with management...\n"
wait_proxy_cluster reverse-proxy
wait_proxy_cluster reverse-proxy-no-ports
echo -e "\nDone!\n"
echo "Run 'npm run test:dev' to start the dashboard at http://localhost:1337"
echo "Management API is at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
echo "Login with the following credentials:"
echo "Username: $ZITADEL_ADMIN_USERNAME" | tee .env
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
renderCaddyfile() {
cat <<EOF
{
debug
servers :80,:443 {
protocols h1 h2c
}
}
:80${CADDY_SECURE_DOMAIN} {
# Signal
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
# Management
reverse_proxy /api/* management:80
reverse_proxy /management.ManagementService/* h2c://management:80
# Zitadel
reverse_proxy /zitadel.admin.v1.AdminService/* h2c://zitadel:8080
reverse_proxy /admin/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.auth.v1.AuthService/* h2c://zitadel:8080
reverse_proxy /auth/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.management.v1.ManagementService/* h2c://zitadel:8080
reverse_proxy /management/v1/* h2c://zitadel:8080
reverse_proxy /zitadel.system.v1.SystemService/* h2c://zitadel:8080
reverse_proxy /system/v1/* h2c://zitadel:8080
reverse_proxy /assets/v1/* h2c://zitadel:8080
reverse_proxy /ui/* h2c://zitadel:8080
reverse_proxy /oidc/v1/* h2c://zitadel:8080
reverse_proxy /saml/v2/* h2c://zitadel:8080
reverse_proxy /oauth/v2/* h2c://zitadel:8080
reverse_proxy /.well-known/openid-configuration h2c://zitadel:8080
reverse_proxy /openapi/* h2c://zitadel:8080
reverse_proxy /debug/* h2c://zitadel:8080
# Dashboard
reverse_proxy /* dashboard:80
}
EOF
}
renderTurnServerConf() {
cat <<EOF
listening-port=3478
tls-listening-port=5349
min-port=$TURN_MIN_PORT
max-port=$TURN_MAX_PORT
fingerprint
lt-cred-mech
user=$TURN_USER:$TURN_PASSWORD
realm=netbird.io
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/private/privkey.pem
log-file=stdout
no-software-attribute
pidfile="/var/tmp/turnserver.pid"
no-cli
EOF
}
renderManagementJson() {
cat <<EOF
{
"StoreConfig": {
"Engine": "postgres"
},
"Stuns": [
{
"Proto": "udp",
"URI": "stun:$NETBIRD_DOMAIN:3478"
}
],
"TURNConfig": {
"Turns": [
{
"Proto": "udp",
"URI": "turn:$NETBIRD_DOMAIN:3478",
"Username": "$TURN_USER",
"Password": "$TURN_PASSWORD"
}
],
"TimeBasedCredentials": false
},
"Signal": {
"Proto": "$NETBIRD_HTTP_PROTOCOL",
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
},
"HttpConfig": {
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"AuthAudience": "$NETBIRD_AUTH_CLIENT_ID",
"OIDCConfigEndpoint":"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/.well-known/openid-configuration"
},
"IdpManagerConfig": {
"ManagerType": "zitadel",
"ClientConfig": {
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/oauth/v2/token",
"ClientID": "$NETBIRD_IDP_MGMT_CLIENT_ID",
"ClientSecret": "$NETBIRD_IDP_MGMT_CLIENT_SECRET",
"GrantType": "client_credentials"
},
"ExtraConfig": {
"ManagementEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/management/v1"
}
},
"PKCEAuthorizationFlow": {
"ProviderConfig": {
"Audience": "$NETBIRD_AUTH_CLIENT_ID_CLI",
"ClientID": "$NETBIRD_AUTH_CLIENT_ID_CLI",
"Scope": "openid profile email offline_access",
"RedirectURLs": ["http://localhost:53000/","http://localhost:54000/"]
}
}
}
EOF
}
renderDashboardEnv() {
cat <<EOF
{
"auth0Auth": "false",
"authAuthority": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"authClientId": "$NETBIRD_AUTH_CLIENT_ID",
"authScopesSupported": "openid profile email offline_access",
"authAudience": "$NETBIRD_AUTH_CLIENT_ID",
"apiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"grpcApiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"redirectURI": "/nb-auth",
"silentRedirectURI": "/nb-silent-auth"
}
EOF
}
renderZitadelEnv() {
cat <<EOF
ZITADEL_LOG_LEVEL=debug
ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY
ZITADEL_DATABASE_COCKROACH_HOST=crdb
ZITADEL_DATABASE_COCKROACH_USER_USERNAME=zitadel_user
ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE=verify-full
ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT="/crdb-certs/ca.crt"
ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT="/crdb-certs/client.zitadel_user.crt"
ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY="/crdb-certs/client.zitadel_user.key"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_MODE=verify-full
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_ROOTCERT="/crdb-certs/ca.crt"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_CERT="/crdb-certs/client.root.crt"
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_KEY="/crdb-certs/client.root.key"
ZITADEL_EXTERNALSECURE=$ZITADEL_EXTERNALSECURE
ZITADEL_TLS_ENABLED="false"
ZITADEL_EXTERNALPORT=$NETBIRD_PORT
ZITADEL_EXTERNALDOMAIN=$NETBIRD_DOMAIN
ZITADEL_FIRSTINSTANCE_PATPATH=/machinekey/zitadel-admin-sa.token
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_SCOPES=openid
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DATE
EOF
}
renderDockerCompose() {
cat <<EOF
version: "3.4"
services:
# Caddy reverse proxy
caddy:
image: caddy
restart: unless-stopped
networks: [ netbird ]
ports:
- '33443:443'
- '33080:80'
- '33880:8080'
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
environment:
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
- NB_TRAFFIC_EVENT_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
- NETBIRD_STORE_CONFIG_ENGINE=postgres
- NB_TRAFFIC_EVENT_STORE_ENGINE=postgres
- NB_LICENSE_KEY=${NB_LICENSE_KEY}
- NB_TRAFFIC_FLOW_ADDRESS=http://127.0.0.1:8084
- NETBIRD_DATADIR=/var/lib/netbird/
- NETBIRD_ENCRYPTION_KEY=saFhCwIBtO+4QfRqMA19kKYqNPSrtXq7+TVWfHax+3I=
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
"--log-file", "console",
"--log-level", "trace",
"--disable-anonymous-metrics=false",
"--single-account-mode-domain=netbird.selfhosted",
"--dns-domain=netbird.selfhosted",
"--idp-sign-key-refresh-enabled",
]
depends_on:
postgres:
condition: 'service_healthy'
# PostgreSQL for management
postgres:
image: postgres:17
restart: unless-stopped
networks: [netbird]
environment:
- POSTGRES_USER=netbird
- POSTGRES_PASSWORD=netbird
- POSTGRES_DB=netbird
volumes:
- netbird_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U netbird"]
interval: 10s
timeout: 5s
retries: 5
# Zitadel - identity provider
zitadel:
restart: 'always'
networks: [netbird]
image: 'ghcr.io/zitadel/zitadel:v2.31.3'
command: 'start-from-init --masterkeyFromEnv --tlsMode $ZITADEL_TLS_MODE'
env_file:
- ./zitadel.env
depends_on:
crdb:
condition: 'service_healthy'
volumes:
- ./machinekey:/machinekey
- netbird_zitadel_certs:/crdb-certs:ro
# Reverse proxy (supports custom listen ports for UDP/TCP)
reverse-proxy:
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
env_file:
- ./proxy.env
volumes:
- ./proxy-certs:/certs:ro
command: [
"--mgmt", "http://management:80",
"--addr", "0.0.0.0:8443",
"--domain", "example.com",
"--cert-dir", "/certs",
"--debug-endpoint",
"--debug-endpoint-addr", "0.0.0.0:8444",
"--health-addr", "0.0.0.0:8080",
"--log-level", "debug",
]
depends_on:
- management
# Reverse proxy with custom ports disabled (auto-assigned listen ports only)
reverse-proxy-no-ports:
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
restart: unless-stopped
networks: [netbird]
env_file:
- ./proxy-no-ports.env
volumes:
# Distinct cert dir so this proxy has a distinct identity from the
# primary proxy; a shared cert makes both register under the same
# proxy ID and management superseding kicks one off in a loop, which
# makes cluster registration (and the reverse-proxy suite) flaky.
- ./proxy-certs-no-ports:/certs:ro
command: [
"--mgmt", "http://management:80",
"--addr", "0.0.0.0:9443",
"--domain", "noports.example.com",
"--cert-dir", "/certs",
"--debug-endpoint",
"--debug-endpoint-addr", "0.0.0.0:9444",
"--health-addr", "0.0.0.0:9080",
"--log-level", "debug",
"--supports-custom-ports=false",
]
depends_on:
- management
# CockroachDB for zitadel
crdb:
restart: 'always'
networks: [netbird]
image: 'cockroachdb/cockroach:v22.2.2'
command: 'start-single-node --advertise-addr crdb'
volumes:
- netbird_crdb_data:/cockroach/cockroach-data
- netbird_crdb_certs:/cockroach/certs
- netbird_zitadel_certs:/zitadel-certs
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
interval: '10s'
timeout: '30s'
retries: 5
start_period: '20s'
volumes:
netbird_management:
netbird_caddy_data:
netbird_crdb_data:
netbird_crdb_certs:
netbird_zitadel_certs:
netbird_postgres_data:
networks:
netbird:
EOF
}
renderPlaywrightEnv() {
cat <<EOF
{
"ZITADEL_URL": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
"BASE_URL": "http://localhost:1337"
}
EOF
}
initEnvironment

View File

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

@@ -0,0 +1,435 @@
/**
* Direct API helpers for fast CRUD operations in tests.
*
* The app uses OIDC service-worker auth, so page.request doesn't carry
* the Bearer token. We extract it from the browser context and pass it
* explicitly via page.evaluate + fetch.
*/
import type { Page } from "@playwright/test";
type Group = {
id: string;
name: string;
peers_count: number;
resources_count: number;
};
/**
* Capture the auth token and API origin by intercepting a real network
* response from the management API. We listen for any /api/ response
* and extract the request's Authorization header (injected by the OIDC
* service worker at the network level).
*/
const apiContextCache = new WeakMap<Page, { token: string; origin: string }>();
async function getApiContext(
page: Page,
): Promise<{ token: string; origin: string }> {
const cached = apiContextCache.get(page);
if (cached) return cached;
// Navigate to the users page to trigger an API call we can intercept.
// The predicate runs for EVERY response the page receives and returns
// whether it's the one we want: a successful GET to the management API.
// Non-matching responses (4xx/5xx, non-GET, non-API) are skipped — the
// wait keeps going until a match or the 10s timeout. Network-level
// request failures never produce a response, so they can't match either;
// if nothing succeeds, this throws a TimeoutError.
// Set E2E_DEBUG_API=1 to log every API response the predicate considers.
const debugApi = !!process.env.E2E_DEBUG_API;
const [response] = await Promise.all([
page.waitForResponse(
(resp) => {
const req = resp.request();
if (!resp.url().includes("/api/")) return false;
const isMatch = req.method() === "GET" && resp.status() === 200;
if (debugApi) {
// eslint-disable-next-line no-console
console.log(
`[api-context] ${req.method()} ${resp.status()} ${resp.url()} ${
isMatch ? "← MATCH" : "(skipped)"
}`,
);
}
return isMatch;
},
{ timeout: 10_000 },
),
page.goto("/team/users"),
]);
const request = response.request();
const authHeader =
(await request.allHeaders())["authorization"] || "";
const token = authHeader.replace("Bearer ", "");
const url = new URL(request.url());
const origin = `${url.protocol}//${url.host}`;
if (!token) {
throw new Error("Could not capture auth token from API response");
}
const ctx = { token, origin };
apiContextCache.set(page, ctx);
return ctx;
}
async function apiGet<T>(page: Page, path: string): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
return resp.json();
}
async function apiDelete(page: Page, path: string): Promise<void> {
const { token, origin } = await getApiContext(page);
await page.request.delete(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
}
/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
}
/** Delete all groups matching a prefix. */
export async function deleteGroupsByPrefix(page: Page, prefix: string) {
const groups = await listGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
await deleteGroup(page, g.id);
}
}
// ── Networks ────────────────────────────────────────────────────────────
type Network = {
id: string;
name: string;
};
/** List all networks. */
export async function listNetworks(page: Page): Promise<Network[]> {
return apiGet<Network[]>(page, "/networks");
}
/** Delete a network by ID. */
export async function deleteNetworkById(page: Page, networkId: string) {
await apiDelete(page, `/networks/${networkId}`);
}
/** Delete all networks matching a prefix. */
export async function deleteNetworksByPrefix(page: Page, prefix: string) {
const networks = await listNetworks(page);
const toDelete = networks.filter((n) => n.name.startsWith(prefix));
for (const n of toDelete) {
await deleteNetworkById(page, n.id);
}
}
// ── Policies ───────────────────────────────────────────────────────────
type Policy = {
id: string;
name: string;
description: string;
rules: { sources: string[]; destinations: string[] }[];
};
/** List all policies. */
export async function listPolicies(page: Page): Promise<Policy[]> {
return apiGet<Policy[]>(page, "/policies");
}
/** Delete a policy by ID. */
export async function deletePolicyById(page: Page, policyId: string) {
await apiDelete(page, `/policies/${policyId}`);
}
/** Delete all policies whose name or description contains a substring. */
export async function deletePoliciesBySubstring(page: Page, substring: string) {
const policies = await listPolicies(page);
const toDelete = policies.filter(
(p) => p.name?.includes(substring) || p.description?.includes(substring),
);
for (const p of toDelete) {
await deletePolicyById(page, p.id);
}
}
/** Delete all policies that reference a group name in sources or destinations. */
export async function deletePoliciesByGroupName(page: Page, groupName: string) {
const [policies, groups] = await Promise.all([
listPolicies(page),
listGroups(page),
]);
const groupId = groups.find((g) => g.name === groupName)?.id;
if (!groupId) return;
const toDelete = policies.filter((p) =>
p.rules.some(
(r) => r.sources?.includes(groupId) || r.destinations?.includes(groupId),
),
);
for (const p of toDelete) {
await deletePolicyById(page, p.id);
}
}
// ── Routes ─────────────────────────────────────────────────────────────
type Route = {
id: string;
network_id: string;
};
/** List all routes. */
export async function listRoutes(page: Page): Promise<Route[]> {
return apiGet<Route[]>(page, "/routes");
}
/** Delete a route by ID. */
export async function deleteRouteById(page: Page, routeId: string) {
await apiDelete(page, `/routes/${routeId}`);
}
/** Delete all routes matching a network_id prefix. */
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
const routes = await listRoutes(page);
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
for (const r of toDelete) {
await deleteRouteById(page, r.id);
}
}
// ── Setup Keys ─────────────────────────────────────────────────────────
type SetupKey = {
id: string;
name: string;
};
/** List all setup keys. */
export async function listSetupKeys(page: Page): Promise<SetupKey[]> {
return apiGet<SetupKey[]>(page, "/setup-keys");
}
/** Delete a setup key by ID. */
export async function deleteSetupKeyById(page: Page, keyId: string) {
await apiDelete(page, `/setup-keys/${keyId}`);
}
/** Delete all setup keys matching a name prefix. */
export async function deleteSetupKeysByPrefix(page: Page, prefix: string) {
const keys = await listSetupKeys(page);
const toDelete = keys.filter((k) => k.name.startsWith(prefix));
for (const k of toDelete) {
await deleteSetupKeyById(page, k.id);
}
}
// ── DNS Zones ──────────────────────────────────────────────────────────
type DnsZone = {
id: string;
domain: string;
};
/** List all DNS zones. */
export async function listDnsZones(page: Page): Promise<DnsZone[]> {
return apiGet<DnsZone[]>(page, "/dns/zones");
}
/** Delete a DNS zone by ID. */
export async function deleteDnsZoneById(page: Page, zoneId: string) {
await apiDelete(page, `/dns/zones/${zoneId}`);
}
/** Delete all DNS zones matching a domain prefix. */
export async function deleteDnsZonesByPrefix(page: Page, prefix: string) {
const zones = await listDnsZones(page);
const toDelete = zones.filter((z) => z.domain.startsWith(prefix));
for (const z of toDelete) {
await deleteDnsZoneById(page, z.id);
}
}
// ── Notification Channels ─────────────────────────────────────────────
type NotificationChannel = {
id: string;
type: string;
enabled: boolean;
};
/** List all notification channels. */
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
}
/** Delete a notification channel by ID. */
export async function deleteNotificationChannel(page: Page, channelId: string) {
await apiDelete(page, `/integrations/notifications/channels/${channelId}`);
}
/** Delete all notification channels. */
export async function deleteAllNotificationChannels(page: Page) {
const channels = await listNotificationChannels(page);
for (const c of channels) {
await deleteNotificationChannel(page, c.id);
}
}
/** Delete notification channels by type (e.g., "email", "slack", "webhook"). */
export async function deleteNotificationChannelsByType(page: Page, type: string) {
const channels = await listNotificationChannels(page);
const toDelete = channels.filter((c) => c.type === type);
for (const c of toDelete) {
await deleteNotificationChannel(page, c.id);
}
}
// ── Nameservers ───────────────────────────────────────────────────────
type NameserverGroup = {
id: string;
name: string;
};
/** List all nameserver groups. */
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
}
/** Delete a nameserver group by ID. */
export async function deleteNameserverGroupById(page: Page, id: string) {
await apiDelete(page, `/dns/nameservers/${id}`);
}
/** Delete all nameserver groups matching a name prefix. */
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
const groups = await listNameserverGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
await deleteNameserverGroupById(page, g.id);
}
}
// ── Reverse Proxy Services ────────────────────────────────────────────
type ReverseProxyService = {
id: string;
name: string;
};
/** List all reverse proxy services. */
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
}
/** Delete a reverse proxy service by ID. */
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
}
/** Delete all reverse proxy services matching a name prefix. */
export async function deleteServicesByPrefix(page: Page, prefix: string) {
const services = await listReverseProxyServices(page);
const toDelete = services.filter((s) => s.name.startsWith(prefix));
for (const s of toDelete) {
await deleteReverseProxyServiceById(page, s.id);
}
}
// ── Reverse Proxy Clusters ────────────────────────────────────────────
type ReverseProxyCluster = {
id?: string;
address: string;
online: boolean;
connected_proxies: number;
};
/** List all reverse proxy clusters. */
export async function listReverseProxyClusters(
page: Page,
): Promise<ReverseProxyCluster[]> {
return apiGet<ReverseProxyCluster[]>(page, "/reverse-proxies/clusters");
}
/**
* Poll the management API until every given cluster address is present and
* online with at least one connected proxy. The test reverse-proxy
* containers register asynchronously after `test:setup` returns, so the
* domain picker can be briefly empty; gating here keeps the reverse-proxy
* suite deterministic instead of flaking on a half-registered env.
*/
export async function waitForProxyClustersOnline(
page: Page,
addresses: string[],
timeoutMs = 120_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last: ReverseProxyCluster[] = [];
while (Date.now() < deadline) {
// Don't silently coerce errors to "no clusters" — a failed call (token
// capture timeout, 401, network) is a different problem than an empty
// list, and hiding it makes the gate undiagnosable.
last = await listReverseProxyClusters(page).catch((err) => {
// eslint-disable-next-line no-console
console.warn(
`[clusters-gate] list call failed: ${(err as Error).message}`,
);
return [];
});
const ready = addresses.every((addr) =>
last.some(
(c) => c.address === addr && c.online && c.connected_proxies > 0,
),
);
if (ready) return;
await page.waitForTimeout(3000);
}
throw new Error(
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
", ",
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
);
}
// ── Users ─────────────────────────────────────────────────────────────
type User = {
id: string;
email: string;
name: string;
role: string;
status: string;
is_current: boolean;
};
/** List all users. */
export async function listUsers(page: Page): Promise<User[]> {
return apiGet<User[]>(page, "/users");
}
/** Delete a user by ID. */
export async function deleteUserById(page: Page, userId: string) {
await apiDelete(page, `/users/${userId}`);
}
/** Delete a user by email (skip current user). */
export async function deleteUserByEmail(page: Page, email: string) {
const users = await listUsers(page);
const user = users.find((u) => u.email === email && !u.is_current);
if (user) {
await deleteUserById(page, user.id);
}
}

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

@@ -0,0 +1,117 @@
/**
* Login helper for Playwright tests.
*
* The OIDC library (@axa-fr/react-oidc) uses a service worker for token
* management, so storageState alone can't restore a session. Each test
* goes through the OIDC redirect flow. Zitadel session cookies from
* storageState make re-auth fast (account selection, no credentials).
*/
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { clearScrollLock } from "./utils";
export type TestUser = "owner" | "user";
const credentials: Record<TestUser, { username: string; password: string }> = {
owner: { username: "owner@localhost.test", password: "testMe123@" },
user: { username: "user@localhost.test", password: "testMe123@" },
};
/**
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
*/
export async function loginToApp(page: Page, user: TestUser = "owner") {
const { username, password } = credentials[user];
await page.goto("/");
// The app either loads directly or redirects to Zitadel.
// Use locators that match either outcome — Playwright auto-waits.
const appReady = page.getByTestId("left-navigation-item").first();
const setupModal = page.getByTestId("setup-netbird-modal");
const approvalPending = page.getByText("User Approval Pending");
const onboarding = page.getByText("Add new device to your network");
const selectAccount = page.getByText("Select account");
const loginInput = page.locator("input[id=loginName]");
const passwordInput = page.locator("input[id=password]");
// Wait for any of these outcomes
const which = await Promise.race([
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
passwordInput.waitFor({ timeout: 20_000 }).then(() => "password" as const),
]);
if (which === "app") {
return;
}
if (which === "modal") {
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
return;
}
if (which === "approval" || which === "onboarding") {
return;
}
// We're on Zitadel
if (which === "select") {
await page.getByText(username).click();
} else if (which === "login") {
await loginInput.fill(username);
await page.locator("button[id=submit-button]").click();
await passwordInput.waitFor({ state: "visible" });
await passwordInput.fill(password);
await page.locator("button[id=submit-button]").click();
} else {
// password form directly
await passwordInput.fill(password);
await page.locator("button[id=submit-button]").click();
}
// Handle 2FA skip if shown
const skipButton = page.locator("button[name=skip]");
if (await skipButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await skipButton.click();
}
// Wait for either nav or modal to appear
await Promise.race([
appReady.waitFor({ timeout: 15_000 }),
setupModal.waitFor({ timeout: 15_000 }),
approvalPending.waitFor({ timeout: 15_000 }),
onboarding.waitFor({ timeout: 15_000 }),
]);
// Dismiss setup modal if present
if (await setupModal.isVisible().catch(() => false)) {
await setupModal.getByTestId("modal-close").click();
await expect(setupModal).not.toBeVisible();
}
// Clear any stale Radix overlays
await clearScrollLock(page);
}
/**
* Navigate to a path within the app, dismissing the setup modal if it appears.
* Use this instead of page.goto() for in-app navigation after loginToApp().
*/
export async function navigateTo(page: Page, path: string) {
await page.goto(path, { waitUntil: "domcontentloaded" });
const modal = page.getByTestId("setup-netbird-modal");
try {
await modal.waitFor({ state: "visible", timeout: 3_000 });
await modal.getByTestId("modal-close").click();
await expect(modal).not.toBeVisible();
} catch {
// No modal — fine
}
await clearScrollLock(page);
}

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

@@ -0,0 +1,49 @@
/**
* Custom Playwright fixtures that provide pre-authenticated pages.
*
* Usage:
* import { test, expect } from "../helpers/fixtures";
* test.describe.serial("My Feature", () => {
* test("first test", async ({ dashboardAsOwner }) => { ... });
* });
*
* `dashboardAsOwner` logs in once (via OIDC redirect) and reuses the same
* browser page for every test in the worker — no per-test login overhead.
*/
import { test as base, type Page, type BrowserContext } from "@playwright/test";
import { loginToApp, type TestUser } from "./auth";
type Fixtures = {
dashboardAsOwner: Page;
dashboardAsUser: Page;
};
export const test = base.extend<{}, Fixtures>({
dashboardAsOwner: [
async ({ browser }, use) => {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
const page = await context.newPage();
await loginToApp(page, "owner");
await use(page);
await context.close();
},
{ scope: "worker" },
],
dashboardAsUser: [
async ({ browser }, use) => {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/user.json",
});
const page = await context.newPage();
await loginToApp(page, "user");
await use(page);
await context.close();
},
{ scope: "worker" },
],
});
export { expect } from "@playwright/test";

View File

@@ -0,0 +1,8 @@
import type { Page } from "@playwright/test";
export async function visitByNavigation(page: Page, navText: string) {
await page
.getByTestId("left-navigation-item")
.getByText(navText, { exact: true })
.click();
}

View File

@@ -0,0 +1,232 @@
/**
* Shared helpers for L4 reverse proxy tests (TLS, TCP, UDP).
* Keeps the individual spec files DRY.
*/
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { navigateTo } from "./auth";
import { generateRandomName, waitForApiCalls } from "./utils";
/** Create a network and return its name. */
export async function createNetwork(page: Page): Promise<string> {
// Networks now lives under the collapsible "Network Routing" sidebar
// group, so navigate by URL instead of clicking the (hidden) child item.
await navigateTo(page, "/networks");
const name = generateRandomName("rp-network-");
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page
.getByTestId("confirmation.cancel")
.click({ force: true });
// force: true because Radix dialog leaves data-scroll-locked on body
const searchInput = page.getByTestId("table-search-input");
await searchInput.fill(name, { force: true });
await expect(page.locator("tr").filter({ hasText: name })).toBeVisible();
return name;
}
/** Add a resource to an already-visible network row. */
export async function addResource(
page: Page,
networkName: string,
address: string,
): Promise<string> {
const name = generateRandomName("rp-resource-");
const searchInput = page.getByTestId("table-search-input");
await searchInput.fill(networkName, { force: true });
await page
.locator("tr")
.filter({ hasText: networkName })
.getByTestId("add-resource")
.click({ force: true });
await page.getByTestId("resource-name-input").fill(name);
await page.getByTestId("resource-address-input").fill(address);
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page
.getByTestId("confirmation.confirm")
.click({ force: true });
const response = await responsePromise;
expect([200, 201]).toContain(response.status());
await page
.getByTestId("confirmation.cancel")
.click({ force: true });
return name;
}
/** Domains advertised by the test reverse-proxy clusters. */
export const CUSTOM_PORTS_DOMAIN = "example.com";
export const NO_CUSTOM_PORTS_DOMAIN = "noports.example.com";
/** Pick a base domain (cluster) in the service modal — deterministic when multiple clusters exist. */
export async function selectProxyDomain(page: Page, domain: string) {
const trigger = page.getByTestId("proxy-domain-selector");
await trigger.click({ force: true });
// Find the option whose label span contains the exact ".<domain>" text,
// so ".example.com" doesn't also match ".noports.example.com".
const option = page
.locator('[role="option"]')
.filter({ has: page.getByText(`.${domain}`, { exact: true }) })
.first();
await option.click({ force: true });
// Wait for the trigger to reflect the new selection and the popover
// options to detach, so subsequent clicks aren't intercepted by Radix's
// outside-click handling during the close animation.
await expect(trigger.getByText(`.${domain}`, { exact: true })).toBeVisible();
await option.waitFor({ state: "detached", timeout: 5_000 }).catch(() => {});
}
/** Select a resource target in the L4 target selector. */
export async function selectL4Resource(page: Page, resourceName: string) {
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("group-selector-dropdown").click();
await page
.locator('[role="tab"]')
.filter({ hasText: "Resources" })
.click({ force: true });
const search = page.getByTestId("group-selector-dropdown-search");
await expect(search).toBeVisible({ timeout: 5_000 });
await search.fill(resourceName);
await page
.locator('[role="option"], [role="listbox"] >> text=' + resourceName)
.or(page.getByText(resourceName))
.first()
.click({ force: true, timeout: 15_000 });
}
/** Add the standard two access control rules (Allow Germany + Block IP). */
export async function addAccessControlRules(page: Page) {
// Rule 1: Allow Country (Germany)
await page.getByTestId("add-access-rule").click();
await page.getByTestId("access-rule-0").getByText("Select country...").click();
await page
.getByTestId("select-dropdown-search")
.fill("Germany");
await page.getByText("Germany (DE)").click({ force: true });
// Rule 2: Block IP Address
await page.getByTestId("add-access-rule").click();
await page
.getByTestId("access-rule-1")
.getByTestId("access-rule-action")
.click();
await page.getByText("Block Only").click({ force: true });
await page
.getByTestId("access-rule-1")
.getByTestId("access-rule-type")
.click();
await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true });
const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value");
await expect(ipInput).toBeVisible();
await ipInput.fill("85.203.15.42");
}
/** Remove all access control rules (expects exactly 2). */
export async function removeAllAccessControlRules(page: Page) {
await expect(page.getByTestId("remove-access-rule")).toHaveCount(2);
await page.getByTestId("remove-access-rule").last().click({ force: true });
await page.getByTestId("remove-access-rule").first().click({ force: true });
}
/** Reset any stale filters/search so all services are visible in the table. */
export async function resetServiceFilters(page: Page) {
const resetBtn = page.getByTestId("reset-filters-and-search");
if (await resetBtn.isVisible().catch(() => false)) {
await resetBtn.click();
}
}
/**
* Navigate to a reverse-proxy page and wait for every /api/reverse-prox*
* backend call triggered by the navigation to finish before proceeding,
* so the table/picker is fully populated when the test interacts with it.
*/
export async function gotoReverseProxyPage(
page: Page,
path = "/reverse-proxy/services",
) {
await waitForApiCalls(page, () => navigateTo(page, path));
}
/** Open the edit modal for a service row. */
export async function openServiceEdit(page: Page, subdomain: string) {
await gotoReverseProxyPage(page, "/reverse-proxy/services");
await resetServiceFilters(page);
await page
.locator("tr")
.filter({ hasText: subdomain })
.getByTestId("service-actions")
.click({ force: true });
await page.getByTestId("edit-service").click({ force: true });
// Wait for the edit modal to fully load
await expect(page.getByTestId("proxy-save")).toBeVisible({ timeout: 10_000 });
}
/** Delete a service via the action dropdown and confirm. */
export async function deleteService(page: Page, subdomain: string) {
await page
.locator("tr")
.filter({ hasText: subdomain })
.getByTestId("service-actions")
.click({ force: true });
await page.getByTestId("delete-service").click({ force: true });
await page
.getByTestId("confirmation.confirm")
.click({ force: true });
await expect(
page.locator("tr").filter({ hasText: subdomain }),
).not.toBeVisible({ timeout: 15_000 });
}
/** Save an edited service (handles the "No Protection" confirmation). */
export async function saveServiceEdit(page: Page) {
await page.getByTestId("proxy-save").click();
await page
.getByTestId("confirmation.confirm")
.click({ force: true });
}
/** Navigate to Networks, find the network by name, and delete it. */
export async function deleteNetwork(page: Page, networkName: string) {
await navigateTo(page, "/networks");
const searchInput = page.getByTestId("table-search-input");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await searchInput.fill(networkName, { force: true });
await expect(
page.locator("tr").filter({ hasText: networkName }),
).toBeVisible();
// Open the row's action menu (last button) and click Delete
await page
.locator("tr")
.filter({ hasText: networkName })
.locator("button")
.last()
.click({ force: true });
await page.getByText("Delete").click({ force: true });
await page
.getByTestId("confirmation.confirm")
.click({ force: true });
await expect(
page.locator("tr").filter({ hasText: networkName }),
).not.toBeVisible();
}

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

@@ -0,0 +1,103 @@
import type { Page, Request } from "@playwright/test";
export function generateRandomName(prefix?: string): string {
return (prefix || "") + Math.random().toString(36).substring(7);
}
/**
* Run an action (click, goto, ...) and wait until every API request whose
* URL contains `pattern` has finished (response received or failed), plus a
* short quiet window to catch request chains where one response triggers
* the next fetch.
*
* Use this to make navigation deterministic: e.g. when opening the services
* page, the table only renders fully after /api/reverse-proxies/* calls
* return, so asserting on rows right after the click races the backend.
*
* Returns whatever the action returns.
*/
export async function waitForApiCalls<T>(
page: Page,
action: () => Promise<T>,
{
pattern = "/api/reverse-prox",
quietMs = 500,
timeoutMs = 15_000,
}: { pattern?: string; quietMs?: number; timeoutMs?: number } = {},
): Promise<T> {
let inFlight = 0;
let sawRequest = false;
let lastActivity = Date.now();
const matches = (req: Request) => req.url().includes(pattern);
const onRequest = (req: Request) => {
if (!matches(req)) return;
inFlight++;
sawRequest = true;
lastActivity = Date.now();
};
const onSettled = (req: Request) => {
if (!matches(req)) return;
inFlight = Math.max(0, inFlight - 1);
lastActivity = Date.now();
};
page.on("request", onRequest);
page.on("requestfinished", onSettled);
page.on("requestfailed", onSettled);
try {
const result = await action();
const deadline = Date.now() + timeoutMs;
// Wait until: at least one matching request was seen (unless none ever
// fires), none are in flight, and the network has been quiet for quietMs.
while (Date.now() < deadline) {
const quietFor = Date.now() - lastActivity;
if (inFlight === 0 && quietFor >= quietMs) {
if (sawRequest || quietFor >= quietMs * 2) break;
}
await page.waitForTimeout(100);
}
return result;
} finally {
page.off("request", onRequest);
page.off("requestfinished", onSettled);
page.off("requestfailed", onSettled);
}
}
/**
* Apply a single-choice (radio) table filter via the new TableFilters UI:
* open the "Filters" popover, pick the filter by column id, then select the
* option by its visible label (e.g. "Active", "Inactive", "All").
*/
export async function applyRadioTableFilter(
page: Page,
filterId: string,
optionLabel: string,
) {
await page.getByTestId("table-filters-button").click();
await page.getByTestId(`table-filter-${filterId}`).click();
const optionId = `radio-option-${optionLabel
.replace(/\s+/g, "-")
.toLowerCase()}`;
await page.getByTestId(optionId).click();
}
/**
* Clear stale Radix scroll-lock and overlay from body.
* Some Radix modals leave `data-scroll-locked`, `pointer-events: none`,
* or a stale overlay div blocking the entire page.
*/
export async function clearScrollLock(page: Page) {
await page.evaluate(() => {
document.body.removeAttribute("data-scroll-locked");
document.body.style.removeProperty("pointer-events");
// Remove stale Radix dialog overlays that block pointer events
document
.querySelectorAll(
'div[data-state="open"].fixed[class*="backdrop-blur"]',
)
.forEach((el) => el.remove());
});
}

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

@@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
const envPath = path.resolve(__dirname, "playwright.env.json");
const env = fs.existsSync(envPath)
? JSON.parse(fs.readFileSync(envPath, "utf-8"))
: {};
export default defineConfig({
outputDir: "./test-results",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 1,
workers: process.env.CI ? 2 : 4,
reporter: process.env.CI
? [
["github"],
["html", { outputFolder: "./playwright-report", open: "never" }],
["json", { outputFile: "test-results/results.json" }],
]
: [
["list"],
["html", { outputFolder: "./playwright-report", open: "on-failure" }],
],
use: {
...devices["Desktop Chrome"],
baseURL: env.BASE_URL || "http://localhost:1337",
viewport: { width: 1920, height: 1080 },
screenshot: "only-on-failure",
trace: "retain-on-failure",
video: "retain-on-failure",
actionTimeout: 10_000,
navigationTimeout: 15_000,
},
testDir: "./tests",
webServer: {
command: "npx serve@latest out -p 1337 --no-request-logging",
port: 1337,
reuseExistingServer: true,
cwd: path.resolve(__dirname, ".."),
},
projects: [
{
name: "login",
testMatch: "login.spec.ts",
},
{
name: "e2e",
testIgnore: "login.spec.ts",
dependencies: ["login"],
},
],
});

View File

@@ -0,0 +1,116 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix } from "../helpers/api";
let createdGroupName = "";
const ALL_GROUP_TABS = [
"policies",
"resources",
"network-routes",
"nameservers",
"zones",
];
const REGULAR_GROUP_TABS = [
"users",
"peers",
...ALL_GROUP_TABS,
"setup-keys",
];
test.describe.serial("Groups @access-control", () => {
// ── List page tests (no navigation between these) ──────────────────
test('Should show the "All" group in the list', async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/groups");
await expect(
page.locator('[aria-label="View details of group All"]'),
).toBeVisible();
});
test('Should search for "All" group and still find it', async ({ dashboardAsOwner: page }) => {
const input = page.getByTestId("table-search-input");
await input.fill("All");
await expect(
page.locator('[aria-label="View details of group All"]'),
).toBeVisible();
await input.fill("");
});
test("Should create a new group", async ({ dashboardAsOwner: page }) => {
const name = generateRandomName("test-group-");
createdGroupName = name;
await page.getByTestId("open-create-group").click();
await page.getByTestId("group-name-input").fill(name);
await page.getByTestId("create-group").click();
await expect(page.getByText(name).first()).toBeVisible();
});
test("Should rename the group", async ({ dashboardAsOwner: page }) => {
// Go back to list via breadcrumb (client-side nav, faster than navigateTo)
await page.getByText("Groups").first().click();
const input = page.getByTestId("table-search-input");
await expect(input).toBeVisible();
await input.fill(createdGroupName);
await page
.locator("tr")
.filter({ hasText: createdGroupName })
.getByTestId("group-actions")
.click();
await page.getByTestId("rename-group").click();
const newName = generateRandomName("renamed-group-");
await page.getByTestId("group-name-input").fill(newName);
await page.getByTestId("save-group-name").click();
await expect(page.getByText(newName).first()).toBeVisible();
createdGroupName = newName;
});
// ── Detail page tests ──────────────────────────────────────────────
test('Should open "All" group page and show only All-group tabs', async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/groups");
const input = page.getByTestId("table-search-input");
await input.fill("");
await page.locator('[aria-label="View details of group All"]').click({ force: true });
for (const tab of ALL_GROUP_TABS) {
await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible();
}
for (const tab of ["users", "peers", "setup-keys"]) {
await expect(page.getByTestId(`group-tab-${tab}`)).not.toBeVisible();
}
for (const tab of ALL_GROUP_TABS) {
await page.getByTestId(`group-tab-${tab}`).click({ force: true });
await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active");
}
});
test("Should open the new group page and show all 8 tabs", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/groups");
const input = page.getByTestId("table-search-input");
await expect(input).toBeVisible();
await input.fill(createdGroupName);
await page.locator(`[aria-label="View details of group ${createdGroupName}"]`).click({ force: true });
for (const tab of REGULAR_GROUP_TABS) {
await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible();
}
for (const tab of REGULAR_GROUP_TABS) {
await page.getByTestId(`group-tab-${tab}`).click({ force: true });
await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active");
}
});
// ── Cleanup ────────────────────────────────────────────────────────
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
await deleteGroupsByPrefix(page, createdGroupName);
});
});

View File

@@ -0,0 +1,151 @@
import { test, expect } from "../helpers/fixtures";
import { generateRandomName } from "../helpers/utils";
import { navigateTo } from "../helpers/auth";
import { deleteGroupsByPrefix } from "../helpers/api";
let policies: string[] = [];
let createdGroups: string[] = [];
test.describe.serial("Access Controls @access-control", () => {
test("Should have default policy", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/access-control");
await expect(page.getByText("Default", { exact: true })).toBeVisible();
await expect(page.getByText("This is a default rule")).toBeVisible();
});
test("Should create new policy", async ({ dashboardAsOwner: page }) => {
const srcGroup = generateRandomName("ac-src-");
const dstGroup = generateRandomName("ac-dst-");
createdGroups.push(srcGroup, dstGroup);
const name = generateRandomName("Policy ");
await createPolicy(page, {
name,
source_groups: [srcGroup],
destination_groups: [dstGroup],
protocol: "TCP",
ports: ["80", "443"],
direction: "in",
description: "This is a test policy",
});
policies.push(name);
});
test("Should delete created policies", async ({ dashboardAsOwner: page }) => {
for (const policy of policies) {
await deletePolicy(page, policy);
}
policies = [];
});
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
for (const prefix of createdGroups) {
await deleteGroupsByPrefix(page, prefix);
}
createdGroups = [];
});
});
async function createPolicy(
page: import("@playwright/test").Page,
opts: {
protocol?: "ALL" | "TCP" | "UDP" | "ICMP";
source_groups: string[];
destination_groups: string[];
direction?: "bi" | "in";
ports?: string[];
name: string;
description?: string;
},
) {
await page.getByTestId("open-add-policy").click();
if (opts.protocol !== "ALL") {
await page.getByTestId("protocol-select-button").click();
await page
.getByTestId("protocol-selection")
.getByText(opts.protocol!)
.click();
}
if (opts.direction === "in") {
await page.getByTestId("policy-direction").click();
}
// Add source groups
if (opts.source_groups.length > 0) {
await page.getByTestId("source-group-selector").click();
for (const group of opts.source_groups) {
const search = page.getByTestId("source-group-selector-search");
await expect(search).toBeVisible();
await search.fill(group);
await search.press("Enter");
}
await page.getByTestId("source-group-selector-search").press("Escape");
await expect(
page.getByTestId("source-group-selector-search"),
).not.toBeVisible();
}
// Add destination groups
if (opts.destination_groups.length > 0) {
await page.getByTestId("destination-group-selector").click();
for (const group of opts.destination_groups) {
const search = page.getByTestId("destination-group-selector-search");
await expect(search).toBeVisible();
await search.fill(group);
await search.press("Enter");
}
await page
.getByTestId("destination-group-selector-search")
.press("Escape");
await expect(
page.getByTestId("destination-group-selector-search"),
).not.toBeVisible();
}
// Add ports
if (
opts.ports &&
(opts.protocol === "TCP" || opts.protocol === "UDP")
) {
await page.getByTestId("port-selector").click();
for (const port of opts.ports) {
const input = page.getByTestId("port-input");
await expect(input).toBeVisible();
await input.fill(port);
await input.press("Enter");
}
await page.getByTestId("port-input").press("Escape");
}
// Click Continue (policy → posture checks)
await page.getByTestId("policy-continue").click();
// Skip posture checks and continue (posture checks → general)
await page.getByTestId("policy-continue").click();
// Enter name
await page.getByTestId("policy-name").fill(opts.name);
if (opts.description) {
await page.getByTestId("policy-description").fill(opts.description);
}
// Create policy
await page.getByTestId("submit-policy").click();
await expect(page.getByTestId(opts.name)).toBeVisible();
}
async function deletePolicy(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("policy-actions")
.click({ force: true });
await page.getByTestId("delete-policy").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(page.getByTestId(name)).not.toBeVisible({ timeout: 10_000 });
}

View File

@@ -0,0 +1,168 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteNameserverGroupsByPrefix } from "../helpers/api";
let nsName = "";
let nsDomain = "";
let nsGroup1 = "";
let nsGroup2 = "";
test.describe.serial("DNS - Nameservers @dns", () => {
test("Should show all 4 DNS presets and create a custom nameserver", async ({
dashboardAsOwner: page,
}) => {
// Clean up stale nameservers and groups from previous runs
await deleteNameserverGroupsByPrefix(page, "test-ns-");
await deleteNameserverGroupsByPrefix(page, "renamed-ns-");
await deleteGroupsByPrefix(page, "ns-group-");
await deleteGroupsByPrefix(page, "ns-domain-");
await navigateTo(page, "/dns/nameservers");
await page.getByTestId("open-add-nameserver").click();
await expect(page.getByTestId("nameserver-preset-google")).toBeVisible();
await expect(page.getByTestId("nameserver-preset-cloudflare")).toBeVisible();
await expect(page.getByTestId("nameserver-preset-quad9")).toBeVisible();
await expect(page.getByTestId("nameserver-preset-custom")).toBeVisible();
// Create via Custom DNS
await page.getByTestId("nameserver-preset-custom").click();
await page.getByTestId("nameserver-ip-input").first().fill("10.0.0.1");
await page.getByTestId("add-nameserver-row").click();
await page.getByTestId("nameserver-ip-input").last().fill("10.0.0.2");
await page.getByTestId("nameserver-port-input").last().fill("5353");
const groupName = generateRandomName("ns-group-");
nsGroup1 = groupName;
await page.getByTestId("nameserver-groups-selector").click();
await page.getByTestId("nameserver-groups-selector-search").fill(groupName);
await page.getByTestId("nameserver-groups-selector-search").press("Enter");
await page.getByTestId("nameserver-groups-selector-search").press("Escape");
await page.getByTestId("nameserver-continue").click();
// Domains tab
const d = generateRandomName("ns-domain-");
nsDomain = `${d}.internal`;
await page.getByTestId("add-match-domain").click();
await page.getByTestId("domain-input").last().fill(nsDomain);
await page.getByTestId("nameserver-mark-search-domains").click();
await page.getByTestId("nameserver-continue").click();
// General tab
const name = generateRandomName("test-ns-");
nsName = name;
await page.getByTestId("nameserver-name-input").fill(name);
await page.getByTestId("nameserver-description-input").fill("Test nameserver");
await page.getByTestId("submit-nameserver").click();
});
test("Should verify the nameserver in the table", async ({ dashboardAsOwner: page }) => {
const row = page.locator("tr").filter({ hasText: nsName });
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row.getByText(nsDomain)).toBeVisible();
await expect(row.getByText("10.0.0.1")).toBeVisible();
await expect(row.getByText("10.0.0.2")).toBeVisible();
await expect(row.getByText(nsGroup1)).toBeVisible();
// Active state moved into the row action menu: a freshly-created
// nameserver is enabled, so the toggle item reads "Disable".
await row.getByTestId("nameserver-actions").click({ force: true });
await expect(page.getByTestId("nameserver-active-toggle")).toContainText(
"Disable",
);
await page.keyboard.press("Escape");
});
test("Should edit the nameserver", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-name-cell").click({ force: true });
// Nameserver tab — change IPs and add group
await page.getByTestId("nameserver-tab-nameserver").click({ force: true });
await expect(page.getByTestId("nameserver-ip-input").first()).toBeVisible();
await page.getByTestId("nameserver-ip-input").first().fill("192.168.1.1");
await page.getByTestId("nameserver-ip-input").last().fill("192.168.1.2");
const groupName = generateRandomName("ns-group-");
nsGroup2 = groupName;
await page.getByTestId("nameserver-groups-selector").click();
await page.getByTestId("nameserver-groups-selector-search").fill(groupName);
await page.getByTestId("nameserver-groups-selector-search").press("Enter");
await page.getByTestId("nameserver-groups-selector-search").press("Escape");
// Domains tab — remove domain
await page.getByTestId("nameserver-tab-domains").click({ force: true });
await page.getByTestId("domain-input-remove").click({ force: true });
// General tab — rename
await page.getByTestId("nameserver-tab-general").click({ force: true });
const newName = generateRandomName("renamed-ns-");
await page.getByTestId("nameserver-name-input").fill(newName);
await page.getByTestId("nameserver-description-input").fill("Updated");
await page.getByTestId("submit-nameserver").click();
await expect(page.getByText("successfully").first()).toBeVisible({ timeout: 10_000 });
// Verify the renamed nameserver appears in the table
await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 });
nsName = newName;
});
test("Should verify edits and toggle active state", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/dns/nameservers");
const row = page.locator("tr").filter({ hasText: nsName });
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row.getByText("192.168.1.1")).toBeVisible();
await expect(row.getByText("192.168.1.2")).toBeVisible();
// Distribution-groups cell now renders a count badge (2 groups after edit).
await expect(row.getByText("2 Groups")).toBeVisible();
// Toggle active off and back on via the row action menu.
// Two races to defend against on each toggle:
// 1. Radix leaves `pointer-events: none` on body briefly during the
// close transition — re-opening without `force: true` makes
// Playwright auto-wait for the body to accept pointer events.
// 2. The toast fires before SWR refetches `/dns/nameservers`, so the
// row's `ns.enabled` is stale and the re-opened menu shows the
// old label. Wait for the GET refetch before re-opening.
const actions = row.getByTestId("nameserver-actions");
const toggle = page.getByTestId("nameserver-active-toggle");
const waitForRefetch = () =>
page.waitForResponse(
(r) =>
r.url().includes("/api/dns/nameservers") &&
r.request().method() === "GET" &&
r.ok(),
{ timeout: 10_000 },
);
await actions.click({ force: true });
let refetch = waitForRefetch();
await toggle.click({ force: true });
await expect(page.getByText("successfully disabled").first()).toBeVisible();
await refetch;
await expect(toggle).toBeHidden();
await actions.click();
await expect(toggle).toContainText("Enable");
refetch = waitForRefetch();
await toggle.click({ force: true });
await expect(page.getByText("successfully enabled").first()).toBeVisible();
await refetch;
await expect(toggle).toBeHidden();
});
test("Should delete the nameserver and groups", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-actions").click({ force: true });
await page.getByTestId("delete-nameserver").click({ force: true });
await page.getByTestId("confirmation.confirm").click({ force: true });
await expect(page.locator("tr").filter({ hasText: nsName })).not.toBeVisible();
for (const group of [nsGroup1, nsGroup2]) {
if (!group) continue;
await deleteGroupsByPrefix(page, group);
}
});
});

View File

@@ -0,0 +1,89 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix } from "../helpers/api";
let dnsGroups: string[] = [];
test.describe.serial("DNS - Settings @dns", () => {
test("Should add groups to DNS disabled management", async ({ dashboardAsOwner: page }) => {
// Clean up stale groups from previous failed runs
await deleteGroupsByPrefix(page, "dns-group-");
await navigateTo(page, "/dns/settings");
const name1 = generateRandomName("dns-group-");
const name2 = generateRandomName("dns-group-");
dnsGroups = [name1, name2];
await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 });
// Remove any existing group badges before adding new ones
const existingBadges = page.getByTestId("group-badge");
const badgeCount = await existingBadges.count();
for (let i = 0; i < badgeCount; i++) {
await existingBadges.first().click();
}
if (badgeCount > 0) {
await page.getByTestId("save-changes").click();
await expect(page.getByText("successfully").first()).toBeVisible();
}
for (const group of dnsGroups) {
// Ensure dropdown is closed before reopening
const search = page.getByTestId("dns-groups-selector-search");
if (await search.isVisible().catch(() => false)) {
await page.keyboard.press("Escape");
await expect(search).not.toBeVisible({ timeout: 3_000 });
}
await page.getByTestId("dns-groups-selector-open-close").click({ force: true });
await expect(search).toBeVisible({ timeout: 5_000 });
await search.fill(group);
await search.press("Enter");
// Wait for the group badge to appear before continuing
await expect(page.getByText(group).first()).toBeVisible({ timeout: 5_000 });
}
// Close the dropdown if still open
await page.keyboard.press("Escape");
for (const group of dnsGroups) {
await expect(page.getByText(group).first()).toBeVisible();
}
const saveResponse = page.waitForResponse(
(resp) => resp.url().includes("/api/dns/settings") && resp.request().method() === "PUT",
{ timeout: 10_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
await expect(page.getByText("successfully").first()).toBeVisible();
});
test("Should persist groups after reload and then remove them", async ({
dashboardAsOwner: page,
}) => {
await page.reload();
await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 });
for (const group of dnsGroups) {
await expect(page.getByText(group).first()).toBeVisible({ timeout: 10_000 });
}
// Remove groups
for (const group of dnsGroups) {
await page.getByTestId("group-badge").filter({ hasText: group }).click();
}
await page.getByTestId("save-changes").click();
// Verify removed after reload
await page.reload();
for (const group of dnsGroups) {
await expect(page.getByTestId("group-badge").filter({ hasText: group })).toHaveCount(0);
}
});
test("Should delete the created groups", async ({ dashboardAsOwner: page }) => {
for (const group of dnsGroups) {
await deleteGroupsByPrefix(page, group);
}
});
});

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

@@ -0,0 +1,197 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { applyRadioTableFilter, generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteDnsZonesByPrefix } from "../helpers/api";
let zoneDomain = "";
let zoneGroup = "";
let zoneGroup2 = "";
test.describe.serial("DNS - Zones @dns", () => {
test("Should add a new zone with a distribution group", async ({ dashboardAsOwner: page }) => {
// Clean up leftover zones from previous runs
await deleteDnsZonesByPrefix(page, "dns-zone-");
await deleteGroupsByPrefix(page, "zone-group-");
await navigateTo(page, "/dns/zones");
const name = generateRandomName("dns-zone-");
zoneDomain = `${name}.test`;
await page.getByTestId("add-dns-zone").click();
await page.getByTestId("dns-zone-domain-input").fill(zoneDomain);
const groupName = generateRandomName("zone-group-");
zoneGroup = groupName;
await page.getByTestId("dns-zone-groups-selector").click();
await page.getByTestId("dns-zone-groups-selector-search").fill(groupName);
await page.getByTestId("dns-zone-groups-selector-search").press("Enter");
await page.getByTestId("dns-zone-groups-selector-search").press("Escape");
await page.getByTestId("dns-zone-search-domains").click();
await expect(page.getByTestId("dns-zone-enabled")).toHaveAttribute("data-state", "checked");
await page.getByTestId("submit-dns-zone").click();
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
});
test("Should add A, AAAA, and CNAME records", async ({ dashboardAsOwner: page }) => {
const zoneRow = page.locator("tr").filter({ hasText: zoneDomain });
// Dismiss or use the "Add Record" prompt from zone creation
const addRecordBtn = page.getByTestId("confirmation.confirm");
if (await addRecordBtn.isVisible().catch(() => false)) {
await addRecordBtn.click({ force: true });
} else {
await zoneRow.getByTestId("add-dns-record").click({ force: true });
}
await expect(page.getByTestId("dns-record-hostname-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("dns-record-hostname-input").fill("server1");
await page.getByTestId("dns-record-content-input").fill("10.0.0.10");
await page.getByTestId("dns-record-ttl-select").click();
await page.locator('[role="option"]').filter({ hasText: "1 Min." }).click({ force: true });
await page.getByTestId("submit-dns-record").click();
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("1");
// AAAA record
await zoneRow.getByTestId("add-dns-record").click({ force: true });
await page.getByTestId("dns-record-type-select").click();
await page.locator('[role="option"]').filter({ hasText: "AAAA" }).click({ force: true });
await page.getByTestId("dns-record-hostname-input").fill("server2");
await page.getByTestId("dns-record-content-input").fill("2001:db8::1");
await page.getByTestId("submit-dns-record").click();
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("2");
// CNAME record
await zoneRow.getByTestId("add-dns-record").click({ force: true });
await page.getByTestId("dns-record-type-select").click();
await page.locator('[role="option"]').filter({ hasText: "CNAME" }).click({ force: true });
await page.getByTestId("dns-record-hostname-input").fill("alias");
await page.getByTestId("dns-record-content-input").fill("server1.example.com");
await page.getByTestId("submit-dns-record").click();
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("3");
});
test("Should edit a record", async ({ dashboardAsOwner: page }) => {
await page.reload();
// Expand accordion to show records
await page.locator("tr").filter({ hasText: zoneDomain }).first().click({ force: true });
await expect(page.getByText("10.0.0.10")).toBeVisible({ timeout: 10_000 });
// Edit A record
await page.getByTestId("edit-dns-record").first().click({ force: true });
await page.getByTestId("dns-record-hostname-input").fill("web1");
await page.getByTestId("dns-record-content-input").fill("10.0.0.99");
await page.getByTestId("submit-dns-record").click();
await expect(page.getByText(`web1.${zoneDomain}`).first()).toBeVisible();
});
test("Should toggle active and search domain states", async ({ dashboardAsOwner: page }) => {
await page.reload();
const row = page.locator("tr").filter({ hasText: zoneDomain });
// Active state moved into the row action menu (Enable/Disable item).
// Zone starts enabled → item reads "Disable"; toggle off then on,
// reopening the menu each time to read the updated label.
// Two races to defend against on each toggle:
// 1. Radix leaves `pointer-events: none` on body briefly during the
// close transition — re-opening without `force: true` makes
// Playwright auto-wait for the body to accept pointer events.
// 2. The toggle's PUT resolves before SWR refetches `/dns/zones`, so
// the row's `zone.enabled` is stale and the re-opened menu shows
// the old label. Wait for the GET refetch before re-opening.
const actions = row.getByTestId("dns-zone-actions");
const toggle = page.getByTestId("dns-zone-active-toggle");
const waitForRefetch = () =>
page.waitForResponse(
(r) =>
r.url().includes("/api/dns/zones") &&
r.request().method() === "GET" &&
r.ok(),
{ timeout: 10_000 },
);
await actions.click({ force: true });
let refetch = waitForRefetch();
await toggle.click({ force: true });
await refetch;
await expect(toggle).toBeHidden();
await actions.click();
await expect(toggle).toContainText("Enable");
refetch = waitForRefetch();
await toggle.click({ force: true });
await refetch;
await expect(toggle).toBeHidden();
await actions.click();
await expect(toggle).toContainText("Disable");
await page.keyboard.press("Escape");
// Toggle search domain off
const searchToggle = row.getByTestId("dns-zone-search-domain-toggle");
await searchToggle.click({ force: true });
await expect(searchToggle).toHaveAttribute("data-state", "unchecked");
});
test("Should update distribution groups", async ({ dashboardAsOwner: page }) => {
const newGroup = generateRandomName("zone-group-");
zoneGroup2 = newGroup;
await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("multiple-groups").click({ force: true });
await expect(page.getByTestId("save-groups")).toBeVisible();
await page.getByTestId("group-selector-dropdown").click();
await page.getByTestId("group-selector-dropdown-search").fill(newGroup);
await page.getByTestId("group-selector-dropdown-search").press("Enter");
await page.getByTestId("group-selector-dropdown-search").press("Escape");
await page.getByTestId("save-groups").click();
await expect(page.getByTestId("save-groups")).not.toBeVisible();
});
test("Should edit the zone and toggle settings back", async ({ dashboardAsOwner: page }) => {
// Page is on /dns/zones from previous test
await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("dns-zone-actions").click({ force: true });
await page.getByTestId("edit-dns-zone").click({ force: true });
await page.getByTestId("dns-zone-search-domains").click();
await expect(page.getByTestId("dns-zone-search-domains")).toHaveAttribute("data-state", "checked");
await page.getByTestId("submit-dns-zone").click();
});
test("Should filter and search zones", async ({ dashboardAsOwner: page }) => {
await page.reload();
const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }).first();
// Filter: Active should show, Inactive should hide
await applyRadioTableFilter(page, "enabled", "Active");
await expect(zoneRow).toBeVisible();
await applyRadioTableFilter(page, "enabled", "Inactive");
await expect(zoneRow).toBeHidden();
await applyRadioTableFilter(page, "enabled", "All");
// Search by domain
const searchInput = page.getByTestId("table-search-input");
await searchInput.fill(zoneDomain);
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
// Search by content
await searchInput.fill("10.0.0.99");
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
// Search by group
await searchInput.fill(zoneGroup);
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
await searchInput.fill("");
});
test("Should delete the zone and groups", async ({ dashboardAsOwner: page }) => {
await deleteDnsZonesByPrefix(page, zoneDomain);
for (const group of [zoneGroup, zoneGroup2]) {
if (!group) continue;
await deleteGroupsByPrefix(page, group);
}
});
});

View File

@@ -0,0 +1,216 @@
/**
* Temporary spec validating edition gating (cloud / licensed / oss).
*
* The test build hard-codes APP_ENV=test, so isNetBirdCloud() normally returns
* true. This spec uses the test-only `netbird-test-edition` localStorage
* override (see testEditionOverride in src/utils/netbird.ts) to drive each
* edition against the OSS test management backend, which does not report the
* premium permission modules (edr, idp, event_streaming). That absence is what
* triggered the original `permission.event_streaming.read` crash and is now
* covered by withDefaultModules in PermissionsProvider.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
type Edition = "cloud" | "licensed" | "oss";
// Premium permission modules the open-source management server does not report.
const PREMIUM_MODULES = [
"edr",
"idp",
"event_streaming",
"assistant",
"msp",
"tenants",
"billing",
"proxy",
"proxy_configuration",
];
// stripPremiumModules rewrites /users/current to drop the premium permission
// modules, reproducing an open-source management backend regardless of what the
// test management returns. This is the exact condition that crashed before the
// withDefaultModules default in PermissionsProvider.
async function stripPremiumModules(page: Page) {
await page.route("**/users/current", async (route) => {
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (body?.permissions?.modules) {
PREMIUM_MODULES.forEach((m) => delete body.permissions.modules[m]);
}
return route.fulfill({ response, json: body });
});
}
async function openAs(
browser: Browser,
edition: Edition,
opts: { stripModules?: boolean } = {},
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript((ed) => {
try {
window.localStorage.setItem("netbird-test-edition", ed as string);
} catch (e) {}
}, edition);
const page = await context.newPage();
if (opts.stripModules) await stripPremiumModules(page);
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
function collectPageErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}
const SELF_HOSTED_CTA = "self-hosted-upgrade-cta";
const START_TRIAL = "Start 14-Day Free Trial";
test.describe.serial("Edition gating @edition", () => {
test("integrations renders when premium permission modules are absent", async ({
browser,
}) => {
// Reproduces the original crash: OSS management omits event_streaming/edr/
// idp permission modules, and the integrations children read them directly.
const { page, close } = await openAs(browser, "oss", {
stripModules: true,
});
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/integrations");
await expect(
page.getByText("Identity Provider Sync").first(),
).toBeVisible();
await expect(page.getByText("MDM & EDR").first()).toBeVisible();
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
test("integrations renders without crashing on oss (teaser + upsell)", async ({
browser,
}) => {
const { page, close } = await openAs(browser, "oss");
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/integrations");
// Tabs render (the crash happened while rendering these children).
await expect(
page.getByText("Identity Provider Sync").first(),
).toBeVisible();
await expect(page.getByText("MDM & EDR").first()).toBeVisible();
// Self-hosted upsell CTA is present.
await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible();
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
test("integrations renders unlocked on licensed (no upsell)", async ({
browser,
}) => {
const { page, close } = await openAs(browser, "licensed");
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/integrations");
await expect(
page.getByText("Identity Provider Sync").first(),
).toBeVisible();
// Licensed self-hosted unlocks features: no upsell CTA.
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
test("traffic events is locked with cloud upgrade CTA on cloud free", async ({
browser,
}) => {
const { page, close } = await openAs(browser, "cloud");
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/events/traffic");
// Cloud free plan locks the feature with a trial/upgrade CTA, not the
// self-hosted license CTA.
await expect(page.getByText(START_TRIAL).first()).toBeVisible();
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
test("traffic events is locked with self-hosted CTA on oss", async ({
browser,
}) => {
const { page, close } = await openAs(browser, "oss");
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/events/traffic");
await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible();
await expect(page.getByText(START_TRIAL)).toHaveCount(0);
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
test("traffic events is unlocked on licensed (no upsell)", async ({
browser,
}) => {
const { page, close } = await openAs(browser, "licensed");
const errors = collectPageErrors(page);
try {
await navigateTo(page, "/events/traffic");
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
await expect(page.getByText(START_TRIAL)).toHaveCount(0);
expect(
errors,
`unexpected runtime errors: ${errors.join(" | ")}`,
).toHaveLength(0);
} finally {
await close();
}
});
});

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

@@ -0,0 +1,109 @@
import { test } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";
import { waitForProxyClustersOnline } from "../helpers/api";
import { loginToApp } from "../helpers/auth";
type TestUser = "owner" | "user";
const AUTH_DIR = path.resolve(__dirname, "../fixtures/auth");
const credentials: Record<TestUser, { username: string; password: string }> = {
owner: { username: "owner@localhost.test", password: "testMe123@" },
user: { username: "user@localhost.test", password: "testMe123@" },
};
async function loginAndSave(
page: import("@playwright/test").Page,
user: TestUser,
) {
const { username, password } = credentials[user];
await page.goto("/");
await page.locator("input[id=loginName]").waitFor({ state: "visible" });
await page.locator("input[id=loginName]").fill(username);
await page.locator("button[id=submit-button]").click();
await page.locator("input[id=password]").waitFor({ state: "visible" });
await page.locator("input[id=password]").fill(password);
await page.locator("button[id=submit-button]").click();
// After submitting credentials, we land on either:
// - 2FA skip prompt, or
// - the app directly (redirect to localhost:1337)
const skipButton = page.locator("button[name=skip]");
const appNav = page.getByTestId("left-navigation-item").first();
const modal = page.getByTestId("setup-netbird-modal");
const approval = page.getByText("User Approval Pending");
const after_login = await Promise.race([
skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const),
appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const),
modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const),
approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const),
]);
if (after_login === "2fa") {
await skipButton.click();
await Promise.race([
appNav.waitFor({ timeout: 15_000 }),
modal.waitFor({ timeout: 15_000 }),
approval.waitFor({ timeout: 15_000 }),
]);
}
// Dismiss setup modal if present
if (await modal.isVisible().catch(() => false)) {
await modal.getByTestId("modal-close").click();
}
await page
.context()
.storageState({ path: path.join(AUTH_DIR, `${user}.json`) });
}
test.describe("Global Setup", () => {
for (const user of ["owner", "user"] as TestUser[]) {
test(`authenticate ${user}`, async ({ page }) => {
const authFile = path.join(AUTH_DIR, `${user}.json`);
test.skip(fs.existsSync(authFile), `${user} auth file already exists`);
await loginAndSave(page, user);
});
}
// Wait for the test reverse-proxy clusters to be registered and online
// before the rest of the suite runs. They come up asynchronously after
// test:setup, so without this the reverse-proxy specs flake when the
// domain picker is still empty.
//
// This deliberately does NOT fail the run if the clusters never appear:
// it only adds a bounded wait so slow registration is absorbed. A hard
// gate would skip the entire suite on any cluster hiccup, which is worse
// than letting the individual reverse-proxy specs report the problem.
test("wait for reverse-proxy clusters to be online", async ({ browser }) => {
test.setTimeout(15_000);
const context = await browser.newContext({
storageState: path.join(AUTH_DIR, "owner.json"),
});
const page = await context.newPage();
try {
// storageState only carries the Zitadel session cookies — the app
// still needs the OIDC redirect flow to get an access token before
// it makes any API call, so log in like every other consumer does.
await loginToApp(page, "owner");
await waitForProxyClustersOnline(page, [
"example.com",
"noports.example.com",
]);
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
`[setup] proxy clusters not confirmed online; reverse-proxy specs may be affected: ${
(err as Error).message
}`,
);
} finally {
await context.close();
}
});
});

View File

@@ -0,0 +1,176 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteRoutesByNetworkIdPrefix } from "../helpers/api";
const networkRoutes: string[] = [];
let networkRoutesCreatedGroups: string[] = [];
async function closePopover(
page: import("@playwright/test").Page,
selectorCy: string,
) {
await page.getByTestId(`${selectorCy}-search`).press("Escape");
await expect(page.getByTestId(`${selectorCy}-search`)).not.toBeVisible();
}
test.describe.serial("Network Routes @network", () => {
test("Should create a network route with IP range", async ({ dashboardAsOwner: page }) => {
// Clean up leftovers from previous runs
await deleteRoutesByNetworkIdPrefix(page, "network-route-");
await deleteGroupsByPrefix(page, "route-peer-");
await deleteGroupsByPrefix(page, "route-dist-");
await deleteGroupsByPrefix(page, "route-acl-");
await navigateTo(page, "/network-routes");
const peerGroup = generateRandomName("route-peer-");
const distGroup = generateRandomName("route-dist-");
const aclGroup = generateRandomName("route-acl-");
networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup);
const name = generateRandomName("network-route-");
await createNetworkRoute(page, {
name,
range: "192.168.1.0/24",
peer_groups: [peerGroup],
distribution_groups: [distGroup],
access_control_groups: [aclGroup],
description: "This is a test route",
});
networkRoutes.push(name);
});
test("Should create a network route with domains", async ({ dashboardAsOwner: page }) => {
const peerGroup = generateRandomName("route-peer-");
const distGroup = generateRandomName("route-dist-");
const aclGroup = generateRandomName("route-acl-");
networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup);
const name = generateRandomName("network-route-");
await createNetworkRoute(page, {
name,
domains: ["netbird.io"],
peer_groups: [peerGroup],
distribution_groups: [distGroup],
access_control_groups: [aclGroup],
description: "This is a test route with domains",
});
networkRoutes.push(name);
});
test("Should delete network routes", async ({ dashboardAsOwner: page }) => {
for (const route of networkRoutes) {
await deleteNetworkRoute(page, route);
}
});
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
for (const prefix of networkRoutesCreatedGroups) {
await deleteGroupsByPrefix(page, prefix);
}
networkRoutesCreatedGroups = [];
});
});
async function createNetworkRoute(
page: import("@playwright/test").Page,
opts: {
range?: string;
domains?: string[];
peer_groups?: string[];
distribution_groups?: string[];
access_control_groups?: string[];
name: string;
description?: string;
masquerade?: boolean;
metric?: string;
},
) {
await page.getByTestId("open-add-route").click();
if (opts.range) {
await page.getByTestId("network-range").fill(opts.range);
}
if (opts.domains && opts.domains.length > 0) {
await page.getByTestId("route-type-domains").click();
for (const domain of opts.domains) {
await page.getByTestId("add-domain").click();
await page.getByTestId("domain-input").last().fill(domain);
}
}
if (opts.peer_groups && opts.peer_groups.length > 0) {
await page.getByTestId("route-tab-peer-group").click();
await page.getByTestId("routing-peer-groups-selector").click();
for (const group of opts.peer_groups) {
const search = page.getByTestId("routing-peer-groups-selector-search");
await expect(search).toBeVisible({ timeout: 10_000 });
await search.fill(group);
await search.press("Enter");
}
await closePopover(page, "routing-peer-groups-selector");
}
await page.getByTestId("route-continue").click();
if (opts.distribution_groups && opts.distribution_groups.length > 0) {
await page.getByTestId("distribution-groups-selector").click();
for (const group of opts.distribution_groups) {
const search = page.getByTestId("distribution-groups-selector-search");
await expect(search).toBeVisible();
await search.fill(group);
await search.press("Enter");
}
await closePopover(page, "distribution-groups-selector");
}
if (opts.access_control_groups && opts.access_control_groups.length > 0) {
await page.getByTestId("access-control-groups-selector").click();
for (const group of opts.access_control_groups) {
const search = page.getByTestId("access-control-groups-selector-search");
await expect(search).toBeVisible();
await search.fill(group);
await search.press("Enter");
}
await closePopover(page, "access-control-groups-selector");
}
await page.getByTestId("route-continue").click();
await page.getByTestId("network-identifier").fill(opts.name);
if (opts.description) {
await page.getByTestId("description").fill(opts.description);
}
await page.getByTestId("route-continue").click();
if (opts.masquerade === false) {
await page.getByText("Masquerade").click();
}
if (opts.metric) {
await page.getByTestId("metric").fill(opts.metric);
}
await page.getByTestId("submit-route").click();
if (opts.access_control_groups && opts.access_control_groups.length > 0) {
await page.getByTestId("confirmation.cancel").click();
}
await expect(page.getByTestId(opts.name)).toBeVisible();
}
async function deleteNetworkRoute(
page: import("@playwright/test").Page,
name: string,
) {
await page
.locator("tr")
.filter({ hasText: name })
.getByRole("button", { name: "Delete" })
.click();
await page.getByTestId("confirmation.confirm").click();
await expect(page.getByTestId(name)).not.toBeVisible();
}

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

@@ -0,0 +1,164 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteNetworksByPrefix, deletePoliciesByGroupName, deletePoliciesBySubstring } from "../helpers/api";
let networkName = "";
let resourceName = "";
let policySourceGroup = "";
let routingPeerGroup = "";
test.describe.serial("Networks @network", () => {
test("Should create a network with a resource, policy, and routing peer", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/networks");
const name = generateRandomName("test-network-");
networkName = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("network-description-input").fill("E2E test network");
await page.getByTestId("submit-network").click();
// "Add Resource?" → confirm
await page.getByTestId("confirmation.confirm").click({ force: true });
// Resource tab
const resName = generateRandomName("test-resource-");
resourceName = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.50.0.1");
await page.getByTestId("resource-optional-settings").click();
await page.getByTestId("resource-description-input").fill("E2E test resource");
await page.getByTestId("resource-continue").click();
// Access control tab — add policy
await page.getByTestId("add-policy").click();
const srcGroup = generateRandomName("net-src-group-");
policySourceGroup = srcGroup;
await page.getByTestId("source-group-selector").click();
await page.getByTestId("source-group-selector-search").fill(srcGroup);
await page.getByTestId("source-group-selector-search").press("Enter");
await page.getByTestId("source-group-selector-search").press("Escape");
await page.getByTestId("policy-continue").click();
await page.getByTestId("policy-continue").click();
await page.getByTestId("submit-policy").click();
// Submit resource
await page.getByTestId("submit-resource").click();
// "Add Routing Peer?" → confirm
await page.getByTestId("confirmation.confirm").click({ force: true });
// Routing peer
await page.getByTestId("routing-peer-tab-group").click({ force: true });
const rpGroup = generateRandomName("net-rp-group-");
routingPeerGroup = rpGroup;
await page.getByTestId("group-selector-dropdown").click();
await page.getByTestId("group-selector-dropdown-search").fill(rpGroup);
await page.getByTestId("group-selector-dropdown-search").press("Enter");
await page.getByTestId("group-selector-dropdown-search").press("Escape");
await page.getByTestId("routing-peer-continue").click();
await page.getByTestId("toggle-masquerade").click();
await page.getByTestId("metric").fill("100");
await page.getByTestId("submit-routing-peer").click();
// Verify network in table
await expect(page.locator("tr").filter({ hasText: name })).toBeVisible({ timeout: 10_000 });
});
test("Should add a CIDR range resource", async ({ dashboardAsOwner: page }) => {
await addResourceToNetwork(page, "cidr-resource-", "192.168.100.0/24");
});
test("Should add a domain resource", async ({ dashboardAsOwner: page }) => {
await addResourceToNetwork(page, "domain-resource-", "resource.internal");
});
test("Should rename the network from the table", async ({ dashboardAsOwner: page }) => {
// Page is already on /networks from previous test
const row = page.locator("tr").filter({ hasText: networkName });
await expect(row).toBeVisible();
await row.getByTestId("network-actions").click({ force: true });
await page.getByTestId("rename-network").click({ force: true });
const newName = generateRandomName("test-network-");
await page.getByTestId("network-name-input").fill(newName);
await page.getByTestId("network-description-input").fill("Updated description");
await page.getByTestId("submit-network").click();
await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 });
networkName = newName;
});
test("Should navigate to the network detail page and verify tabs", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/networks");
const row = page.locator("tr").filter({ hasText: networkName });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.locator("button").first().click();
// Wait for detail page to load (tab bar appears)
await expect(page.locator('[role="tab"]').filter({ hasText: "Resource" })).toBeVisible();
await expect(page.getByText(resourceName).first()).toBeVisible({ timeout: 10_000 });
// Routing Peers tab
await page.getByTestId("network-tab-routing-peers").click();
await expect(page.getByText(routingPeerGroup).first()).toBeVisible();
// Services tab
await page.getByTestId("network-tab-services").click();
await expect(page.getByTestId("network-tab-services")).toHaveAttribute("data-state", "active");
});
test("Should rename the network from the detail page", async ({ dashboardAsOwner: page }) => {
// Already on the detail page from previous test
await page.getByTestId("network-detail-actions").click();
await page.getByTestId("rename-network").click({ force: true });
const newName = generateRandomName("test-network-");
await page.getByTestId("network-name-input").fill(newName);
await page.getByTestId("network-description-input").fill("Renamed from detail page");
await page.getByTestId("submit-network").click();
await expect(page.getByText(newName).first()).toBeVisible();
networkName = newName;
});
test("Should delete the network and clean up", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, "test-network-");
await deletePoliciesByGroupName(page, policySourceGroup);
await deletePoliciesBySubstring(page, "test-resource-");
for (const group of [policySourceGroup, routingPeerGroup]) {
await deleteGroupsByPrefix(page, group);
}
});
});
async function addResourceToNetwork(
page: import("@playwright/test").Page,
prefix: string,
address: string,
) {
// Page should already be on /networks from previous test
const row = page.locator("tr").filter({ hasText: networkName });
await expect(row).toBeVisible();
const name = generateRandomName(prefix);
// The per-row resource-add affordance is now an icon "Add" button.
await row.getByTestId("add-resource").click();
await expect(page.getByTestId("resource-name-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("resource-name-input").fill(name);
await page.getByTestId("resource-address-input").fill(address);
await page.getByTestId("resource-continue").click();
await page.getByTestId("submit-resource").click();
// "No policies configured" warning
await page.getByTestId("confirmation.confirm").click();
// "Add Routing Peer?" prompt — wait for it and dismiss
await page.getByTestId("confirmation.cancel").click();
}

View File

@@ -0,0 +1,167 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import {
gotoReverseProxyPage,
selectL4Resource,
selectProxyDomain,
openServiceEdit,
deleteService,
resetServiceFilters,
CUSTOM_PORTS_DOMAIN,
} from "../helpers/reverse-proxy-l4";
const DOMAINS_GLOB = "**/reverse-proxies/domains";
// Force the test clusters to advertise CrowdSec support so the selector renders,
// independent of whether the test backend has CrowdSec configured. The save
// payload assertion below verifies the real wiring regardless of the backend.
async function forceCrowdSecSupport(page: import("@playwright/test").Page) {
await page.route(DOMAINS_GLOB, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const response = await route.fetch();
let body: any;
try {
body = await response.json();
} catch (e) {
return route.fulfill({ response });
}
if (Array.isArray(body)) {
body = body.map((d) => ({ ...d, supports_crowdsec: true }));
}
return route.fulfill({ response, json: body });
});
}
test.describe.serial("Reverse Proxy - CrowdSec @reverse-proxy", () => {
let network = "";
let resource = "";
let subdomain = "";
test("Should configure CrowdSec on a service and send crowdsec_mode on save", async ({
dashboardAsOwner: page,
}) => {
test.setTimeout(90_000);
await forceCrowdSecSupport(page);
await deleteServicesByPrefix(page, "crowdsec-svc-");
await deleteNetworksByPrefix(page, "rp-crowdsec-net-");
// Create a network with a resource (same inline flow as the L4 specs).
await navigateTo(page, "/networks");
network = generateRandomName("rp-crowdsec-net-");
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(network);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
resource = generateRandomName("rp-resource-");
await page.getByTestId("resource-name-input").fill(resource);
await page.getByTestId("resource-address-input").fill("10.99.99.40");
await page.getByTestId("resource-continue").click();
const resourcePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await resourcePromise;
const cancelBtn = page.getByTestId("confirmation.cancel");
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await cancelBtn.click({ force: true });
}
await gotoReverseProxyPage(page, "/reverse-proxy/services");
subdomain = generateRandomName("crowdsec-svc-");
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
await page
.getByTestId("service-mode-select-button")
.click({ force: true });
await page.getByTestId("service-mode-option-tcp").click({ force: true });
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({
timeout: 10_000,
});
await selectL4Resource(page, resource);
await expect(page.getByTestId("listen-port-input")).toBeEnabled({
timeout: 10_000,
});
await page.getByTestId("listen-port-input").fill("3306");
await page.getByTestId("destination-port-input").fill("3306");
await page.getByTestId("proxy-continue").click();
// Access control step: the CrowdSec selector renders for supporting clusters.
const crowdsecTrigger = page.getByTestId("crowdsec-mode-trigger");
await expect(crowdsecTrigger).toBeVisible({ timeout: 10_000 });
await crowdsecTrigger.click({ force: true });
await page.getByTestId("crowdsec-mode-enforce").click({ force: true });
await expect(crowdsecTrigger).toContainText("Enforce");
await page.getByTestId("proxy-continue").click();
const savePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/reverse-proxies/services") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-service").click();
const saveResp = await savePromise;
// Core assertion: the configured mode is included in the save payload.
const payload = saveResp.request().postDataJSON();
expect(
payload?.access_restrictions?.crowdsec_mode,
"crowdsec_mode should be sent in the service payload",
).toBe("enforce");
await resetServiceFilters(page);
await expect(
page.locator("tr").filter({ hasText: subdomain }),
).toBeVisible({ timeout: 30_000 });
});
test("Should show CrowdSec in the access control cell and persist on reopen", async ({
dashboardAsOwner: page,
}) => {
test.setTimeout(60_000);
await forceCrowdSecSupport(page);
await gotoReverseProxyPage(page, "/reverse-proxy/services");
await resetServiceFilters(page);
// The access control cell counts CrowdSec as a rule and lists it on hover.
const cell = page
.locator("tr")
.filter({ hasText: subdomain })
.locator("[data-access-control-cell]");
await expect(cell).toContainText("1", { timeout: 10_000 });
// Reopen the service: the selector reflects the persisted Enforce mode.
await openServiceEdit(page, subdomain);
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await expect(page.getByTestId("crowdsec-mode-trigger")).toContainText(
"Enforce",
{ timeout: 10_000 },
);
await page.keyboard.press("Escape");
});
test("Should clean up the CrowdSec service and network", async ({
dashboardAsOwner: page,
}) => {
await forceCrowdSecSupport(page);
await gotoReverseProxyPage(page, "/reverse-proxy/services");
await resetServiceFilters(page);
await deleteService(page, subdomain);
await deleteNetworksByPrefix(page, network);
await page.unroute(DOMAINS_GLOB);
});
});

View File

@@ -0,0 +1,87 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { applyRadioTableFilter, generateRandomName } from "../helpers/utils";
import { gotoReverseProxyPage } from "../helpers/reverse-proxy-l4";
let domain = "";
const TARGET_CLUSTER = "example.com";
test.describe.serial("Reverse Proxy - Custom Domains @reverse-proxy", () => {
test("Should validate domain input and add a custom domain", async ({
dashboardAsOwner: page,
}) => {
await gotoReverseProxyPage(page, "/reverse-proxy/custom-domains");
await page.getByTestId("add-custom-domain").click();
await expect(page.getByTestId("custom-domain-input")).toBeVisible();
// Invalid input should show error
await page.getByTestId("custom-domain-input").fill("mycustomdomain");
await page.getByTestId("custom-domain-input").blur();
await expect(page.getByText("Please enter a valid TLD domain")).toBeVisible();
// Fill valid domain — error should disappear
const prefix = generateRandomName("mycustomdomain-");
domain = `${prefix}.com`;
await page.getByTestId("custom-domain-input").fill(domain);
await expect(page.getByText("Please enter a valid TLD domain")).toHaveCount(0);
// Pick the target proxy cluster explicitly — with multiple clusters the
// dashboard does not auto-select.
const clusterSection = page.getByTestId("custom-domain-cluster-selector");
await clusterSection.locator("button").first().click({ force: true });
await page
.locator('[role="option"]')
.filter({ has: page.getByText(TARGET_CLUSTER, { exact: true }) })
.first()
.click({ force: true });
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/reverse-proxies/domains") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-custom-domain").click();
const response = await responsePromise;
expect([200, 201]).toContain(response.status());
await expect(page.getByRole("heading", { name: "Verify Domain" })).toBeVisible();
await expect(page.getByText(`*.${domain}`)).toBeVisible();
await page.getByTestId("verify-domain-later").click();
const row = page.locator("tr").filter({ hasText: domain });
await expect(row).toBeVisible();
await expect(row).toContainText("Pending Verification");
await expect(row).toContainText(TARGET_CLUSTER);
});
test("Should filter domains by Pending and Active", async ({ dashboardAsOwner: page }) => {
await applyRadioTableFilter(page, "validated", "Pending");
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
await applyRadioTableFilter(page, "validated", "Active");
await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible();
await applyRadioTableFilter(page, "validated", "All");
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
});
test("Should search for the domain", async ({ dashboardAsOwner: page }) => {
const searchInput = page.getByTestId("table-search-input");
await searchInput.fill(domain);
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
await searchInput.fill("");
});
test("Should delete the custom domain", async ({ dashboardAsOwner: page }) => {
await page
.locator("tr")
.filter({ hasText: domain })
.getByTestId("delete-custom-domain")
.click();
await page.getByTestId("confirmation.confirm").click();
await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible();
});
});

View File

@@ -0,0 +1,275 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4";
let createdNetwork = "";
let createdResource = "";
let createdSubdomain = "";
test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => {
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
// Clean up leftovers from previous runs (unique prefix per protocol)
await deleteServicesByPrefix(page, "https-svc-");
await deleteNetworksByPrefix(page, "rp-https-net-");
await navigateTo(page, "/networks");
const name = generateRandomName("rp-https-net-");
createdNetwork = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
// Add resource
const resName = generateRandomName("rp-resource-");
createdResource = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.99.99.10");
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await responsePromise;
await page.getByTestId("confirmation.cancel").click({ force: true });
});
test("Should create an HTTPS reverse proxy service with full configuration", async ({
dashboardAsOwner: page,
}) => {
test.setTimeout(60_000);
await gotoReverseProxyPage(page, "/reverse-proxy/services");
const subdomain = generateRandomName("https-svc-");
createdSubdomain = subdomain;
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
// Step 1: Service
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
// Add 2 targets: http with options, https with port
await addTarget(page, {
resourceName: createdResource,
protocol: "http",
timeout: "10s",
customHeader: { name: "X-Custom-Header", value: "custom-value" },
});
await addTarget(page, {
resourceName: createdResource,
location: "/secure",
protocol: "https",
port: 4433,
});
const targetsSection = page.getByText("HTTPS Targets").locator("..");
await expect(targetsSection.locator("table tbody tr")).toHaveCount(2);
await page.getByTestId("proxy-continue").click();
// Step 2: Authentication
await page.getByTestId("auth-sso-card").click();
await page.getByTestId("submit-sso").click();
await page.getByTestId("auth-password-card").click();
await page.getByTestId("password-input").fill("super-secret-pass");
await page.getByTestId("submit-password").click();
await page.getByTestId("auth-pin-card").click();
const pinInputs = page.locator('input[inputmode="numeric"][maxlength="1"]');
for (let i = 0; i < 6; i++) {
await pinInputs.nth(i).fill(String(i + 1), { force: true });
}
await page.getByTestId("submit-pin").click();
await page.getByTestId("auth-header-card").click();
await page.getByTestId("header-type-select").click();
await page.locator("[cmdk-list]").getByText("Basic Auth").click({ force: true });
await page.getByTestId("header-basic-username").fill("admin");
await page.getByTestId("header-basic-password").fill("admin-pass");
await page.getByTestId("submit-headers").click();
await page.getByTestId("proxy-continue").click();
// Step 3: Access Control
await page.getByTestId("add-access-rule").click();
await page.getByTestId("access-rule-0").getByText("Select country...").click();
await page.getByTestId("select-dropdown-search").fill("Germany");
await page.getByText("Germany (DE)").click({ force: true });
await page.getByTestId("add-access-rule").click();
await page.getByTestId("access-rule-1").getByTestId("access-rule-action").click();
await page.getByText("Block Only").click({ force: true });
await page.getByTestId("access-rule-1").getByTestId("access-rule-type").click();
await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true });
const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value");
await expect(ipInput).toBeVisible();
await ipInput.fill("85.203.15.42");
await page.getByTestId("proxy-continue").click();
// Step 4: Advanced Settings
await page.getByTestId("toggle-pass-host-header").click();
await page.getByTestId("toggle-rewrite-redirects").click();
await page.getByTestId("submit-service").click();
await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 });
});
test("Should edit the service, remove auth and rules, then delete", async ({
dashboardAsOwner: page,
}) => {
await resetServiceFilters(page);
await page.locator("tr").filter({ hasText: createdSubdomain }).getByTestId("service-actions").click({ force: true });
await page.getByTestId("edit-service").click({ force: true });
// Edit first target
const targetsSection = page.getByText("HTTPS Targets").locator("..");
await targetsSection.locator("table tbody tr").first().click({ force: true });
await page.getByTestId("target-location-input").fill("/new-location");
await page.getByTestId("submit-target").click();
// Remove second target
await targetsSection.locator("table tbody tr").filter({ hasText: "/secure" }).getByTestId("target-row-actions").click();
await page.getByTestId("remove-target").click();
await expect(targetsSection.locator("table tbody tr")).toHaveCount(1);
// Remove all auth methods — click Edit on each card, then Remove in the modal
await page.getByTestId("proxy-tab-auth").click({ force: true });
await removeAuthMethod(page, "auth-sso-card", "remove-sso");
await removeAuthMethod(page, "auth-password-card", "remove-password");
await removeAuthMethod(page, "auth-pin-card", "remove-pin");
await removeAuthMethod(page, "auth-header-card", "remove-headers");
// Remove access control rules
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await page.getByTestId("remove-access-rule").last().click({ force: true });
await page.getByTestId("remove-access-rule").first().click({ force: true });
// Toggle advanced settings back
await page.getByTestId("proxy-tab-settings").click({ force: true });
await page.getByTestId("toggle-pass-host-header").click({ force: true });
await page.getByTestId("toggle-rewrite-redirects").click({ force: true });
// Save and wait for API response
const saveResponse = page.waitForResponse(
(resp) =>
resp.url().includes("/api/reverse-proxies/services") &&
resp.request().method() === "PUT",
{ timeout: 15_000 },
);
await page.getByTestId("proxy-save").click();
const confirmBtn = page.getByTestId("confirmation.confirm");
if (await confirmBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
await confirmBtn.click({ force: true });
}
await saveResponse;
// Verify no auth / no access rules: both cells now show a "0" count badge.
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: createdSubdomain });
await expect(row.locator("[data-auth-cell]")).toContainText("0", {
timeout: 15_000,
});
await expect(
row.locator("[data-access-control-cell]"),
).toContainText("0", { timeout: 15_000 });
// Delete the service
await row.getByTestId("service-actions").click({ force: true });
await page.getByTestId("delete-service").click({ force: true });
await page.getByTestId("confirmation.confirm").click({ force: true });
await expect(row).not.toBeVisible();
});
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, createdNetwork);
});
});
async function resetServiceFilters(page: import("@playwright/test").Page) {
const resetBtn = page.getByTestId("reset-filters-and-search");
if (await resetBtn.isVisible().catch(() => false)) {
await resetBtn.click();
}
}
type AddTargetOptions = {
resourceName: string;
location?: string;
protocol?: "http" | "https";
port?: number;
timeout?: string;
customHeader?: { name: string; value: string };
};
async function addTarget(page: import("@playwright/test").Page, opts: AddTargetOptions) {
await page.getByTestId("add-target").scrollIntoViewIfNeeded();
await page.getByTestId("add-target").click();
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("group-selector-dropdown").click();
await page.locator('[role="tab"]').filter({ hasText: "Resources" }).click({ force: true });
const search = page.getByTestId("group-selector-dropdown-search");
await expect(search).toBeVisible({ timeout: 5_000 });
await search.fill(opts.resourceName);
await page.getByText(opts.resourceName).click({ force: true, timeout: 15_000 });
await expect(page.getByTestId("target-port-input")).toBeVisible({ timeout: 10_000 });
if (opts.location) {
await expect(page.getByTestId("target-location-input")).toBeEnabled({ timeout: 5_000 });
await page.getByTestId("target-location-input").fill(opts.location);
}
if (opts.protocol === "https") {
await page.getByTestId("target-protocol-select").click();
await page.locator("[cmdk-list]").getByText("https://").click({ force: true });
}
if (opts.port !== undefined) {
await page.getByTestId("target-port-input").fill(String(opts.port));
} else {
await page.getByTestId("target-port-input").fill("");
}
if (opts.timeout || opts.customHeader) {
await page.getByTestId("target-optional-settings").click();
if (opts.timeout) {
await page.getByTestId("target-timeout-input").fill(opts.timeout);
}
if (opts.customHeader) {
await page.getByTestId("add-custom-header").click();
await page.getByTestId("custom-header-name-0").fill(opts.customHeader.name);
await page.getByTestId("custom-header-value-0").fill(opts.customHeader.value);
}
}
await page.getByTestId("submit-target").click();
}
async function removeAuthMethod(
page: import("@playwright/test").Page,
cardTestId: string,
removeTestId: string,
) {
const card = page.getByTestId(cardTestId);
const removeBtn = page.getByTestId(removeTestId);
// Click the card to open the auth modal
await card.click();
await expect(removeBtn).toBeVisible();
await removeBtn.click();
// Wait for the modal to fully close — the remove button must disappear
// and the "Enabled" badge on the card should also disappear
await expect(removeBtn).not.toBeVisible();
await expect(card.getByText("Enabled")).not.toBeVisible();
}

View File

@@ -0,0 +1,115 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import {
gotoReverseProxyPage,
selectL4Resource,
addAccessControlRules,
removeAllAccessControlRules,
resetServiceFilters,
openServiceEdit,
deleteService,
saveServiceEdit,
selectProxyDomain,
CUSTOM_PORTS_DOMAIN,
} from "../helpers/reverse-proxy-l4";
let tcpNetwork = "";
let tcpResource = "";
let tcpSubdomain = "";
test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => {
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
await deleteServicesByPrefix(page, "tcp-svc-");
await deleteNetworksByPrefix(page, "rp-tcp-net-");
await navigateTo(page, "/networks");
const name = generateRandomName("rp-tcp-net-");
tcpNetwork = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
const resName = generateRandomName("rp-resource-");
tcpResource = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.99.99.30");
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await responsePromise;
const cancelBtn = page.getByTestId("confirmation.cancel");
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await cancelBtn.click({ force: true });
}
});
test("Should create a TCP service", async ({ dashboardAsOwner: page }) => {
await gotoReverseProxyPage(page, "/reverse-proxy/services");
const subdomain = generateRandomName("tcp-svc-");
tcpSubdomain = subdomain;
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
await page.getByTestId("service-mode-select-button").click({ force: true });
await page.getByTestId("service-mode-option-tcp").click({ force: true });
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await selectL4Resource(page, tcpResource);
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("listen-port-input").fill("3306");
await page.getByTestId("destination-port-input").fill("3306");
await page.getByTestId("proxy-continue").click();
await addAccessControlRules(page);
await page.getByTestId("proxy-continue").click();
await page.getByTestId("connection-timeout-input").fill("20s");
await page.getByTestId("toggle-preserve-client-ip").click();
await page.getByTestId("submit-service").click();
await resetServiceFilters(page);
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TCP", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("Should edit the TCP service and delete it", async ({ dashboardAsOwner: page }) => {
await openServiceEdit(page, tcpSubdomain);
await page.getByTestId("listen-port-input").fill("5432");
await page.getByTestId("destination-port-input").fill("5432");
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await removeAllAccessControlRules(page);
await page.getByTestId("proxy-tab-settings").click({ force: true });
await page.getByTestId("toggle-preserve-client-ip").click({ force: true });
await page.getByTestId("connection-timeout-input").fill("15s");
await saveServiceEdit(page);
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: tcpSubdomain });
await expect(row.locator("[data-access-control-cell]")).toContainText(
"0",
{ timeout: 10_000 },
);
await deleteService(page, tcpSubdomain);
});
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, tcpNetwork);
});
});

View File

@@ -0,0 +1,117 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import {
gotoReverseProxyPage,
selectL4Resource,
addAccessControlRules,
removeAllAccessControlRules,
resetServiceFilters,
openServiceEdit,
deleteService,
saveServiceEdit,
selectProxyDomain,
CUSTOM_PORTS_DOMAIN,
} from "../helpers/reverse-proxy-l4";
let tlsNetwork = "";
let tlsResource = "";
let tlsSubdomain = "";
test.describe.serial("Reverse Proxy - Services (TLS Passthrough) @reverse-proxy", () => {
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
// Clean up leftover networks
await deleteServicesByPrefix(page, "tls-svc-");
await deleteNetworksByPrefix(page, "rp-tls-net-");
await navigateTo(page, "/networks");
// Create network
const name = generateRandomName("rp-tls-net-");
tlsNetwork = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
// Add resource directly from the confirmation flow
const resName = generateRandomName("rp-resource-");
tlsResource = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.99.99.20");
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await responsePromise;
// "Add Routing Peer?" prompt may or may not appear
const cancelBtn = page.getByTestId("confirmation.cancel");
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await cancelBtn.click({ force: true });
}
});
test("Should create a TLS Passthrough service", async ({ dashboardAsOwner: page }) => {
test.setTimeout(60_000);
await gotoReverseProxyPage(page, "/reverse-proxy/services");
const subdomain = generateRandomName("tls-svc-");
tlsSubdomain = subdomain;
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
await page.getByTestId("service-mode-select-button").click({ force: true });
await page.getByTestId("service-mode-option-tls").click({ force: true });
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await selectL4Resource(page, tlsResource);
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("listen-port-input").fill("8443");
await page.getByTestId("destination-port-input").fill("443");
await page.getByTestId("proxy-continue").click();
await addAccessControlRules(page);
await page.getByTestId("proxy-continue").click();
await page.getByTestId("toggle-preserve-client-ip").click();
await page.getByTestId("connection-timeout-input").fill("20s");
await page.getByTestId("submit-service").click();
await resetServiceFilters(page);
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TLS Passthrough")).toBeVisible({ timeout: 30_000 });
});
test("Should edit the TLS service and delete it", async ({ dashboardAsOwner: page }) => {
await openServiceEdit(page, tlsSubdomain);
await page.getByTestId("listen-port-input").fill("9443");
await page.getByTestId("destination-port-input").fill("8443");
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await removeAllAccessControlRules(page);
await page.getByTestId("proxy-tab-settings").click({ force: true });
await page.getByTestId("toggle-preserve-client-ip").click({ force: true });
await page.getByTestId("connection-timeout-input").fill("");
await saveServiceEdit(page);
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: tlsSubdomain });
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
await deleteService(page, tlsSubdomain);
});
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, tlsNetwork);
});
});

View File

@@ -0,0 +1,119 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import {
gotoReverseProxyPage,
selectL4Resource,
addAccessControlRules,
removeAllAccessControlRules,
resetServiceFilters,
openServiceEdit,
deleteService,
saveServiceEdit,
selectProxyDomain,
NO_CUSTOM_PORTS_DOMAIN,
} from "../helpers/reverse-proxy-l4";
let udpNetwork = "";
let udpResource = "";
let udpSubdomain = "";
test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => {
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
await deleteServicesByPrefix(page, "udp-np-svc-");
await deleteNetworksByPrefix(page, "rp-udp-np-net-");
await navigateTo(page, "/networks");
const name = generateRandomName("rp-udp-np-net-");
udpNetwork = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
const resName = generateRandomName("rp-resource-");
udpResource = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.99.99.41");
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await responsePromise;
const cancelBtn = page.getByTestId("confirmation.cancel");
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await cancelBtn.click({ force: true });
}
});
test("Should create a UDP service on the no-custom-ports cluster", async ({ dashboardAsOwner: page }) => {
await gotoReverseProxyPage(page, "/reverse-proxy/services");
const subdomain = generateRandomName("udp-np-svc-");
udpSubdomain = subdomain;
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, NO_CUSTOM_PORTS_DOMAIN);
await page.getByTestId("service-mode-select-button").click({ force: true });
await page.getByTestId("service-mode-option-udp").click({ force: true });
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await selectL4Resource(page, udpResource);
// Listen port is auto-assigned when the cluster has custom ports disabled
await expect(page.getByTestId("listen-port-input")).toBeDisabled({ timeout: 10_000 });
await expect(page.getByTestId("listen-port-input")).toHaveAttribute("placeholder", "Auto");
await page.getByTestId("destination-port-input").fill("5060");
await page.getByTestId("proxy-continue").click();
await addAccessControlRules(page);
await page.getByTestId("proxy-continue").click();
await page.getByTestId("connection-timeout-input").fill("30s");
await page.getByTestId("submit-service").click();
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: subdomain });
await expect(row.getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(row).toContainText(NO_CUSTOM_PORTS_DOMAIN);
});
test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => {
await openServiceEdit(page, udpSubdomain);
// Listen port must remain auto-assigned on this cluster
await expect(page.getByTestId("listen-port-input")).toBeDisabled();
await page.getByTestId("destination-port-input").fill("5061");
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await removeAllAccessControlRules(page);
await page.getByTestId("proxy-tab-settings").click({ force: true });
await page.getByTestId("connection-timeout-input").fill("");
await saveServiceEdit(page);
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: udpSubdomain });
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
await deleteService(page, udpSubdomain);
});
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, udpNetwork);
});
});

View File

@@ -0,0 +1,111 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
import {
gotoReverseProxyPage,
selectL4Resource,
addAccessControlRules,
removeAllAccessControlRules,
resetServiceFilters,
openServiceEdit,
deleteService,
saveServiceEdit,
selectProxyDomain,
CUSTOM_PORTS_DOMAIN,
} from "../helpers/reverse-proxy-l4";
let udpNetwork = "";
let udpResource = "";
let udpSubdomain = "";
test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => {
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
await deleteServicesByPrefix(page, "udp-svc-");
await deleteNetworksByPrefix(page, "rp-udp-net-");
await navigateTo(page, "/networks");
const name = generateRandomName("rp-udp-net-");
udpNetwork = name;
await page.getByTestId("add-network").click();
await page.getByTestId("network-name-input").fill(name);
await page.getByTestId("submit-network").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
const resName = generateRandomName("rp-resource-");
udpResource = resName;
await page.getByTestId("resource-name-input").fill(resName);
await page.getByTestId("resource-address-input").fill("10.99.99.40");
await page.getByTestId("resource-continue").click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/networks/") &&
resp.url().includes("/resources") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByTestId("submit-resource").click();
await page.getByTestId("confirmation.confirm").click({ force: true });
await responsePromise;
const cancelBtn = page.getByTestId("confirmation.cancel");
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await cancelBtn.click({ force: true });
}
});
test("Should create a UDP service", async ({ dashboardAsOwner: page }) => {
await gotoReverseProxyPage(page, "/reverse-proxy/services");
const subdomain = generateRandomName("udp-svc-");
udpSubdomain = subdomain;
await page.getByTestId("add-service").first().click();
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
await page.getByTestId("service-mode-select-button").click({ force: true });
await page.getByTestId("service-mode-option-udp").click({ force: true });
// Wait for mode switch to take effect
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
await selectL4Resource(page, udpResource);
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
await page.getByTestId("listen-port-input").fill("5060");
await page.getByTestId("destination-port-input").fill("5060");
await page.getByTestId("proxy-continue").click();
await addAccessControlRules(page);
await page.getByTestId("proxy-continue").click();
await page.getByTestId("connection-timeout-input").fill("30s");
await page.getByTestId("submit-service").click();
await resetServiceFilters(page);
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => {
await openServiceEdit(page, udpSubdomain);
await page.getByTestId("listen-port-input").fill("5061");
await page.getByTestId("destination-port-input").fill("5061");
await page.getByTestId("proxy-tab-access-control").click({ force: true });
await removeAllAccessControlRules(page);
await page.getByTestId("proxy-tab-settings").click({ force: true });
await page.getByTestId("connection-timeout-input").fill("");
await saveServiceEdit(page);
await resetServiceFilters(page);
const row = page.locator("tr").filter({ hasText: udpSubdomain });
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
await deleteService(page, udpSubdomain);
});
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
await deleteNetworksByPrefix(page, udpNetwork);
});
});

View File

@@ -0,0 +1,80 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
test.describe.serial("Settings - Authentication @settings", () => {
test("Should toggle peer approval", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/settings");
await toggleAndSave(page, "peer-approval");
});
test("Should toggle peer login expiration off and back on", async ({
dashboardAsOwner: page,
}) => {
await toggleAndSave(page, "peer-login-expiration");
await toggleAndSave(page, "peer-login-expiration");
});
test("Should change peer login expiration time", async ({ dashboardAsOwner: page }) => {
await ensureToggleState(page, "peer-login-expiration", "checked");
// Use a value different from current to ensure the save button enables
const currentValue = await page.getByTestId("peer-login-expiration-input").inputValue();
const hoursValue = currentValue === "17" ? "22" : "17";
await page.getByTestId("peer-login-expiration-input").fill(hoursValue);
await page.getByTestId("peer-login-expiration-select").click();
await page
.getByTestId("peer-login-expiration-select-content")
.getByText("Hours")
.click();
await save(page);
await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(hoursValue);
// Change to a different days value
const currentDays = await page.getByTestId("peer-login-expiration-input").inputValue();
const daysValue = currentDays === "180" ? "90" : "180";
await page.getByTestId("peer-login-expiration-input").fill(daysValue);
await page.getByTestId("peer-login-expiration-select").click();
await page
.getByTestId("peer-login-expiration-select-content")
.getByText("Days")
.click();
await save(page);
await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(daysValue);
await expect(page.getByTestId("peer-login-expiration-select-value")).toContainText("Days");
});
test("Should toggle peer inactivity expiration", async ({ dashboardAsOwner: page }) => {
await toggleAndSave(page, "peer-inactivity-expiration");
});
});
async function save(page: import("@playwright/test").Page) {
await page.getByTestId("save-authentication-settings").click();
await expect(page.getByText("successfully saved").first()).toBeVisible();
}
async function toggleAndSave(
page: import("@playwright/test").Page,
name: string,
) {
const toggle = page.getByTestId(name);
const initialState = await toggle.getAttribute("data-state");
const expectedState = initialState === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expectedState);
await save(page);
}
async function ensureToggleState(
page: import("@playwright/test").Page,
name: string,
desiredState: "checked" | "unchecked",
) {
const toggle = page.getByTestId(name);
const currentState = await toggle.getAttribute("data-state");
if (currentState !== desiredState) {
await toggle.click();
}
}

View File

@@ -0,0 +1,124 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix } from "../helpers/api";
let peerExposeGroup = "";
test.describe.serial("Settings - Clients @settings", () => {
test("Should set automatic updates to Latest Version with force updates", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/settings?tab=clients");
// Ensure we start from Disabled so the change to Latest Version is always detected
const currentMethod = await page.getByTestId("auto-update-method").textContent();
if (currentMethod?.includes("Latest")) {
await selectAutoUpdateMethod(page, "Disabled");
await save(page);
}
await selectAutoUpdateMethod(page, "Latest Version");
const forceToggle = page.getByTestId("force-auto-updates");
if ((await forceToggle.getAttribute("data-state")) !== "checked") {
await forceToggle.click();
}
await expect(forceToggle).toHaveAttribute("data-state", "checked");
await save(page);
});
test("Should switch to Custom Version and disable force updates", async ({
dashboardAsOwner: page,
}) => {
await page.getByTestId("force-auto-updates").click();
await expect(page.getByTestId("force-auto-updates")).toHaveAttribute("data-state", "unchecked");
await selectAutoUpdateMethod(page, "Custom Version");
await page.getByTestId("auto-update-version-input").fill("0.5");
await save(page);
});
test("Should set automatic updates back to Disabled", async ({ dashboardAsOwner: page }) => {
await selectAutoUpdateMethod(page, "Disabled");
await save(page);
await expect(page.getByTestId("auto-update-version-input")).toBeDisabled();
});
test("Should enable peer expose with a group", async ({ dashboardAsOwner: page }) => {
// Ensure peer expose starts disabled for a clean test
const toggle = page.getByTestId("peer-expose");
if ((await toggle.getAttribute("data-state")) === "checked") {
// Remove any existing groups first
const badges = page.getByTestId("group-badge");
const count = await badges.count();
for (let i = 0; i < count; i++) {
await badges.first().click();
}
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", "unchecked");
await save(page);
}
// Now enable and add group
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", "checked");
const name = generateRandomName("expose-group-");
peerExposeGroup = name;
await page.getByTestId("peer-expose-groups-selector").click();
const search = page.getByTestId("peer-expose-groups-selector-search");
await search.fill(name);
await search.press("Enter");
await search.press("Escape");
await save(page);
});
test("Should remove the group and disable peer expose", async ({ dashboardAsOwner: page }) => {
const toggle = page.getByTestId("peer-expose");
// Remove the group badge if it exists
const badge = page.getByTestId("group-badge").filter({ hasText: peerExposeGroup });
if (await badge.first().isVisible().catch(() => false)) {
await badge.first().click();
await expect(badge).not.toBeVisible({ timeout: 5_000 });
}
// Disable peer expose if enabled
if ((await toggle.getAttribute("data-state")) === "checked") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("data-state", "unchecked");
await save(page);
// Verify peer expose persisted after save
await page.reload();
await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked", { timeout: 10_000 });
await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked");
});
test("Should toggle lazy connections on and off", async ({ dashboardAsOwner: page }) => {
const toggle = page.getByTestId("lazy-connections");
await toggle.click();
await expect(page.getByText("successfully").first()).toBeVisible();
await toggle.click();
await expect(page.getByText("successfully").first()).toBeVisible();
});
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
if (!peerExposeGroup) return;
await deleteGroupsByPrefix(page, peerExposeGroup);
});
});
async function selectAutoUpdateMethod(
page: import("@playwright/test").Page,
label: string,
) {
await page.getByTestId("auto-update-method").click({ force: true });
await page.locator("[cmdk-list]").getByText(label).click();
}
async function save(page: import("@playwright/test").Page) {
await page.getByTestId("save-clients-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
}

View File

@@ -0,0 +1,24 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
test.describe.serial("Settings - Groups @settings", () => {
test("Should toggle user group propagation", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/settings?tab=groups");
const toggle = page.getByTestId("user-group-propagation");
const initialState = await toggle.getAttribute("data-state");
const expectedState = initialState === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expectedState);
await page.getByTestId("save-groups-settings").click();
await expect(page.getByText("updated successfully").first()).toBeVisible();
await expect(toggle).toHaveAttribute("data-state", expectedState);
// Toggle back to restore original state
await page.getByTestId("user-group-propagation").click();
await page.getByTestId("save-groups-settings").click();
await expect(page.getByText("updated successfully").first()).toBeVisible();
});
});

View File

@@ -0,0 +1,240 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix } from "../helpers/api";
let trafficGroup = "";
let ipv6Group = "";
test.describe.serial("Settings - Networks @settings", () => {
test("Should update DNS domain and network range", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/settings?tab=networks");
const origDomain = await page.getByTestId("dns-domain-input").inputValue();
const origRange = await page.getByTestId("network-range-input").inputValue();
// Use values guaranteed to differ from current
const testDomain = origDomain === "test.internal" ? "test2.internal" : "test.internal";
const testRange = origRange === "10.100.0.0/16" ? "10.200.0.0/16" : "10.100.0.0/16";
await page.getByTestId("dns-domain-input").fill(testDomain);
await page.getByTestId("network-range-input").fill(testRange);
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
// Verify UI shows new values
await expect(page.getByTestId("dns-domain-input")).toHaveValue(testDomain);
await expect(page.getByTestId("network-range-input")).toHaveValue(testRange);
// Revert
await page.getByTestId("dns-domain-input").fill(origDomain || "netbird.selfhosted");
await page.getByTestId("network-range-input").fill(origRange || "100.64.0.0/10");
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
});
test("Should toggle DNS wildcard routing", async ({ dashboardAsOwner: page }) => {
await toggleAndRevert(page, "dns-wildcard-routing");
});
test("Should toggle traffic events", async ({ dashboardAsOwner: page }) => {
await toggleAndRevert(page, "traffic-events");
});
test("Should toggle traffic reporting kernel", async ({ dashboardAsOwner: page }) => {
await ensureToggleState(page, "traffic-events", "checked");
const toggle = page.getByTestId("traffic-reporting-kernel");
await expect(toggle).toBeVisible();
// Dispatch click via JS to bypass pointer-events interception from parent layout
await toggle.dispatchEvent("click");
// Confirmation dialog only appears when turning ON
const confirmBtn = page.getByTestId("confirmation.confirm");
if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await confirmBtn.click({ force: true });
}
await expect(page.getByText("successfully").first()).toBeVisible();
// Toggle back
await page.getByTestId("traffic-reporting-kernel").dispatchEvent("click");
if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
await confirmBtn.click({ force: true });
}
await expect(page.getByText("successfully").first()).toBeVisible();
});
test("Should add a group to traffic events and save", async ({ dashboardAsOwner: page }) => {
// Clean up stale groups from previous runs
await deleteGroupsByPrefix(page, "traffic-group-");
await navigateTo(page, "/settings?tab=networks");
await ensureToggleState(page, "traffic-events", "checked");
// Scope to the traffic-events selector so we don't accidentally remove
// badges from other group selectors on the same page (e.g. IPv6 groups).
const trafficSelector = page.getByTestId("traffic-events-groups-selector");
const existingBadges = trafficSelector.getByTestId("group-badge");
const badgeCount = await existingBadges.count();
for (let i = 0; i < badgeCount; i++) {
await existingBadges.first().click({ force: true });
}
if (badgeCount > 0) {
await page.getByTestId("save-traffic-groups").click({ force: true });
await expect(page.getByText("successfully updated").first()).toBeVisible();
}
const name = generateRandomName("traffic-group-");
trafficGroup = name;
await page.getByTestId("traffic-events-groups-selector-open-close").click({ force: true });
const search = page.getByTestId("traffic-events-groups-selector-search");
await expect(search).toBeVisible({ timeout: 5_000 });
await search.fill(name);
await search.press("Enter");
if (await search.isVisible().catch(() => false)) {
await search.press("Escape");
}
await page.getByTestId("save-traffic-groups").click({ force: true });
await expect(page.getByText("successfully updated").first()).toBeVisible();
// Verify group is visible in UI within the traffic selector
await expect(trafficSelector.getByText(name).first()).toBeVisible();
// Remove the group (force needed due to parent pointer-events interception)
await trafficSelector.getByTestId("group-badge").filter({ hasText: name }).click({ force: true });
await page.getByTestId("save-traffic-groups").click({ force: true });
await expect(page.getByText("successfully updated").first()).toBeVisible();
});
test("Should delete the created traffic group", async ({ dashboardAsOwner: page }) => {
await deleteGroupsByPrefix(page, trafficGroup);
});
test("Should update the IPv6 network range", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/settings?tab=networks");
const input = page.getByTestId("network-range-v6-input");
await expect(input).toBeVisible();
const origRange = await input.inputValue();
// Pick a value guaranteed to differ from the current one
const testRange =
origRange === "fd00:1234::/64" ? "fd00:5678::/64" : "fd00:1234::/64";
await input.fill(testRange);
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
await expect(input).toHaveValue(testRange);
// Revert
await input.fill(origRange);
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
await expect(input).toHaveValue(origRange);
});
test("Should reject an invalid IPv6 network range", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/settings?tab=networks");
const input = page.getByTestId("network-range-v6-input");
const origRange = await input.inputValue();
// Prefix length outside the allowed /48..../112 window
await input.fill("fd00:1234::/32");
await expect(page.getByTestId("save-network-settings")).toBeDisabled();
// Non-IPv6 string
await input.fill("not-an-ip");
await expect(page.getByTestId("save-network-settings")).toBeDisabled();
// Restore so subsequent tests start from a clean state
await input.fill(origRange);
});
test("Should add and remove a group from IPv6 enabled groups", async ({ dashboardAsOwner: page }) => {
await deleteGroupsByPrefix(page, "ipv6-group-");
await navigateTo(page, "/settings?tab=networks");
const ipv6Selector = page.getByTestId("ipv6-enabled-groups-selector");
await expect(ipv6Selector).toBeVisible();
// Start from a clean slate: remove any existing badges scoped to this selector
const existingBadges = ipv6Selector.getByTestId("group-badge");
const badgeCount = await existingBadges.count();
for (let i = 0; i < badgeCount; i++) {
await existingBadges.first().click({ force: true });
}
if (badgeCount > 0) {
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
}
const name = generateRandomName("ipv6-group-");
ipv6Group = name;
await page.getByTestId("ipv6-enabled-groups-selector-open-close").click({ force: true });
const search = page.getByTestId("ipv6-enabled-groups-selector-search");
await expect(search).toBeVisible({ timeout: 5_000 });
await search.fill(name);
await search.press("Enter");
if (await search.isVisible().catch(() => false)) {
await search.press("Escape");
}
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
// Verify the new group appears as a badge in the IPv6 selector
await expect(
ipv6Selector.getByTestId("group-badge").filter({ hasText: name }),
).toBeVisible();
// Remove the group via the badge and save again
await ipv6Selector
.getByTestId("group-badge")
.filter({ hasText: name })
.click({ force: true });
await page.getByTestId("save-network-settings").click();
await expect(page.getByText("successfully updated").first()).toBeVisible();
await expect(
ipv6Selector.getByTestId("group-badge").filter({ hasText: name }),
).not.toBeVisible();
});
test("Should delete the created IPv6 group", async ({ dashboardAsOwner: page }) => {
await deleteGroupsByPrefix(page, ipv6Group);
});
});
async function toggleAndRevert(
page: import("@playwright/test").Page,
name: string,
) {
const toggle = page.getByTestId(name);
const initialState = await toggle.getAttribute("data-state");
const expectedState = initialState === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(page.getByText("successfully").first()).toBeVisible();
await expect(toggle).toHaveAttribute("data-state", expectedState);
// Toggle back
await toggle.click();
await expect(page.getByText("successfully").first()).toBeVisible();
}
async function ensureToggleState(
page: import("@playwright/test").Page,
name: string,
desiredState: "checked" | "unchecked",
) {
const toggle = page.getByTestId(name);
const currentState = await toggle.getAttribute("data-state");
if (currentState !== desiredState) {
await toggle.click();
await expect(page.getByText("successfully").first()).toBeVisible();
}
}

View File

@@ -0,0 +1,75 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { deleteNotificationChannelsByType } from "../helpers/api";
const TEST_EMAIL = "notify@example.test";
test.describe.serial("Settings - Notifications - Email @notifications", () => {
test("Should add an email recipient", async ({ dashboardAsOwner: page }) => {
await deleteNotificationChannelsByType(page, "email");
await navigateTo(page, "/settings?tab=notifications");
await expect(page.getByTestId("notification-channel-email")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("notification-channel-email").click();
await expect(page.getByTestId("notification-email-input")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("notification-email-input").fill(TEST_EMAIL);
await page.getByTestId("notification-email-add").click();
await expect(
page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }),
).toBeVisible();
});
test("Should toggle email channel enabled and verify on overview", async ({
dashboardAsOwner: page,
}) => {
const toggle = page.locator('[data-testid="notification-email-enabled"]');
if ((await toggle.getAttribute("data-state")) !== "checked") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("data-state", "checked");
await backToOverview(page);
await expect(page.getByTestId("notification-channel-email")).toContainText("Enabled");
await page.getByTestId("notification-channel-email").click();
await page.locator('[data-testid="notification-email-enabled"]').click();
await backToOverview(page);
await expect(page.getByTestId("notification-channel-email")).toContainText("Disabled");
});
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
await page.getByTestId("notification-channel-email").click();
const toggle = page.getByTestId("notification-event-peer.pending.approval");
const initial = await toggle.getAttribute("data-state");
const expected = initial === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expected);
// Toggle back to restore
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", initial!);
});
test("Should remove the email recipient and leave channel disabled", async ({
dashboardAsOwner: page,
}) => {
await page
.getByTestId("notification-email-recipient")
.filter({ hasText: TEST_EMAIL })
.click({ force: true });
await expect(
page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }),
).not.toBeVisible();
const toggle = page.locator('[data-testid="notification-email-enabled"]');
if ((await toggle.getAttribute("data-state")) === "checked") {
await toggle.click();
}
await expect(toggle).toHaveAttribute("data-state", "unchecked");
});
});
async function backToOverview(page: import("@playwright/test").Page) {
await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click();
}

View File

@@ -0,0 +1,55 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { deleteNotificationChannelsByType } from "../helpers/api";
test.describe.serial("Settings - Notifications - Slack @notifications", () => {
test("Should connect Slack through the 2-step wizard", async ({ dashboardAsOwner: page }) => {
await deleteNotificationChannelsByType(page, "slack");
await navigateTo(page, "/settings?tab=notifications");
await expect(page.getByTestId("notification-channel-slack")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("notification-channel-slack").click();
await expect(page.getByTestId("slack-channel-connect")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("slack-channel-connect").click();
await expect(page.getByText("Create a Slack App")).toBeVisible();
await page.getByTestId("slack-continue").click({ force: true });
await expect(page.getByText("Configure Incoming Webhook")).toBeVisible();
await page.getByTestId("slack-webhook-url-input").fill("https://hooks.slack.com/services/T000/B000/XXXX");
await page.getByTestId("slack-connect").click();
await expect(page.getByTestId("slack-actions")).toBeVisible();
});
test("Should show Enabled on overview", async ({ dashboardAsOwner: page }) => {
await backToOverview(page);
await expect(page.getByTestId("notification-channel-slack")).toContainText("Enabled");
});
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
await page.getByTestId("notification-channel-slack").click();
const toggle = page.getByTestId("notification-event-peer.pending.approval");
const initial = await toggle.getAttribute("data-state");
const expected = initial === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expected);
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", initial!);
});
test("Should disconnect Slack and show Disabled on overview", async ({
dashboardAsOwner: page,
}) => {
await page.getByTestId("slack-actions").click({ force: true });
await page.getByTestId("slack-disconnect").click({ force: true });
await page.getByTestId("confirmation.confirm").click({ force: true });
await expect(page.getByTestId("slack-channel-connect")).toBeVisible();
await backToOverview(page);
await expect(page.getByTestId("notification-channel-slack")).toContainText("Disabled");
});
});
async function backToOverview(page: import("@playwright/test").Page) {
await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click();
}

View File

@@ -0,0 +1,130 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { deleteNotificationChannelsByType } from "../helpers/api";
test.describe.serial("Settings - Notifications - Webhook @notifications", () => {
test("Should connect a webhook with no authentication", async ({ dashboardAsOwner: page }) => {
await deleteNotificationChannelsByType(page, "webhook");
await navigateTo(page, "/settings?tab=notifications");
await expect(page.getByTestId("notification-channel-webhook")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("notification-channel-webhook").click();
await expect(page.getByTestId("webhook-connect")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("webhook-connect").click();
await page.getByTestId("webhook-url-input").fill("https://webhook.example/test");
await expect(page.getByTestId("webhook-auth-type")).toContainText("No Authentication");
await page.getByTestId("webhook-continue").click();
await page.getByTestId("webhook-save").click();
await expect(page.getByTestId("webhook-actions")).toBeVisible();
});
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
const toggle = page.getByTestId("notification-event-peer.pending.approval");
const initial = await toggle.getAttribute("data-state");
const expected = initial === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expected);
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", initial!);
});
test("Should edit webhook and cycle through auth types", async ({ dashboardAsOwner: page }) => {
// Basic Auth
await openWebhookEdit(page);
await selectWebhookAuth(page, "Basic Auth");
await page.getByTestId("webhook-basic-username").fill("admin");
await page.getByTestId("webhook-basic-password").fill("password");
await page.getByTestId("webhook-save").click();
// Bearer Token
await openWebhookEdit(page);
await selectWebhookAuth(page, "Bearer Token");
await page.getByTestId("webhook-bearer-token").fill("my-bearer-token");
await page.getByTestId("webhook-save").click();
// Custom Auth
await openWebhookEdit(page);
await selectWebhookAuth(page, "Custom Authentication");
await page.getByTestId("webhook-custom-auth-name").fill("X-API-Key");
await page.getByTestId("webhook-custom-auth-value").fill("secret-api-key");
await page.getByTestId("webhook-save").click();
});
test("Should manage custom headers", async ({ dashboardAsOwner: page }) => {
await page.reload();
// Ensure webhook exists (previous test may have failed)
if (await page.getByTestId("webhook-connect").isVisible().catch(() => false)) {
await page.getByTestId("webhook-connect").click();
await page.getByTestId("webhook-url-input").fill("https://webhook.example/test");
await page.getByTestId("webhook-continue").click();
await page.getByTestId("webhook-save").click();
await expect(page.getByTestId("webhook-actions")).toBeVisible();
}
await openWebhookEdit(page);
await page.getByTestId("webhook-tab-headers").click({ force: true });
// Remove existing headers
const removeButtons = page.getByTestId("webhook-header-remove");
const count = await removeButtons.count();
for (let i = 0; i < count; i++) {
await page.getByTestId("webhook-header-remove").first().click({ force: true });
}
// Add new header
await page.getByTestId("webhook-add-header").click({ force: true });
await page.getByTestId("webhook-header-name").last().fill("X-Custom-Header");
await page.getByTestId("webhook-header-value").last().fill("my-custom-value");
await page.getByTestId("webhook-save").click();
// Verify persistence
await page.reload();
await openWebhookEdit(page);
await page.getByTestId("webhook-tab-headers").click({ force: true });
// Verify the custom header exists (there may be auth headers with the same testid)
const headerNames = page.getByTestId("webhook-header-name");
const headerCount = await headerNames.count();
let found = false;
for (let i = 0; i < headerCount; i++) {
if ((await headerNames.nth(i).inputValue()) === "X-Custom-Header") {
found = true;
break;
}
}
expect(found).toBe(true);
await page.getByRole("button", { name: "Cancel" }).click({ force: true });
});
test("Should delete the webhook", async ({ dashboardAsOwner: page }) => {
await page.reload();
await page.getByTestId("webhook-actions").click({ force: true });
await page.getByTestId("webhook-delete").click({ force: true });
await page.getByTestId("confirmation.confirm").click({ force: true });
await expect(page.getByTestId("webhook-connect")).toBeVisible();
});
});
async function openWebhookEdit(page: import("@playwright/test").Page) {
await expect(page.getByTestId("webhook-actions")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("webhook-actions").click({ force: true });
await expect(page.getByTestId("webhook-edit")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("webhook-edit").click({ force: true });
}
async function selectWebhookAuth(page: import("@playwright/test").Page, label: string) {
await page.getByTestId("webhook-auth-type").click();
await page.locator("[cmdk-list]").getByText(label).click();
}
async function ensureWebhookDisconnected(page: import("@playwright/test").Page) {
await expect(
page.getByTestId("webhook-connect").or(page.getByTestId("webhook-actions")),
).toBeVisible();
if (await page.getByTestId("webhook-actions").isVisible().catch(() => false)) {
await page.getByTestId("webhook-actions").click({ force: true });
await page.getByTestId("webhook-delete").click({ force: true });
await page.getByTestId("confirmation.confirm").click({ force: true });
await expect(page.getByTestId("webhook-connect")).toBeVisible();
}
}

View File

@@ -0,0 +1,41 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
test.describe.serial("Settings - Permissions @settings", () => {
test("Should toggle restrict dashboard for regular users", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/settings?tab=permissions");
const toggle = page.getByTestId("restrict-regular-users");
await expect(toggle).toBeVisible({ timeout: 15_000 });
const initialState = await toggle.getAttribute("data-state");
const expectedState = initialState === "checked" ? "unchecked" : "checked";
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expectedState);
await page.getByTestId("save-permissions-settings").click();
await expect(page.getByText("updated successfully").first()).toBeVisible();
// Verify persistence — wait for settings API to load after reload
await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes("/api/accounts") &&
resp.request().method() === "GET",
),
page.reload(),
]);
await expect(page.getByTestId("restrict-regular-users")).toHaveAttribute(
"data-state",
expectedState,
{ timeout: 15_000 },
);
// Toggle back to restore original state
await page.getByTestId("restrict-regular-users").click();
await page.getByTestId("save-permissions-settings").click();
await expect(page.getByText("updated successfully").first()).toBeVisible();
});
});

View File

@@ -0,0 +1,161 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = [];
let setupKeysCreatedGroups: string[] = [];
test.describe.serial("Setup Keys @setup-keys", () => {
test("Should create a simple setup key", async ({ dashboardAsOwner: page }) => {
// Clean up leftovers from previous runs
await deleteSetupKeysByPrefix(page, "setup-key");
await deleteGroupsByPrefix(page, "sk-group-");
await navigateTo(page, "/setup-keys");
const name = generateRandomName("setup-key");
await createSetupKey(page, { name });
setupKeys.push(name);
});
test("Should create a reusable setup key", async ({ dashboardAsOwner: page }) => {
const name = generateRandomName("setup-key");
await createSetupKey(page, { name, reusable: true });
setupKeys.push(name);
});
test("Should create a setup key with all options", async ({ dashboardAsOwner: page }) => {
const group1 = generateRandomName("sk-group-");
const group2 = generateRandomName("sk-group-");
setupKeysCreatedGroups.push(group1, group2);
const name = generateRandomName("setup-key");
await createSetupKey(page, {
name,
reusable: true,
usageLimit: "100",
expiration: "365",
ephemeral: true,
groups: [group1, group2],
});
setupKeys.push(name);
});
test("Should revoke setup keys", async ({ dashboardAsOwner: page }) => {
for (const name of setupKeys) {
await revokeSetupKey(page, name);
}
});
test("Should delete setup keys", async ({ dashboardAsOwner: page }) => {
for (const name of setupKeys) {
await deleteSetupKey(page, name);
}
});
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
for (const prefix of setupKeysCreatedGroups) {
await deleteGroupsByPrefix(page, prefix);
}
setupKeysCreatedGroups = [];
});
});
async function createSetupKey(
page: import("@playwright/test").Page,
opts: {
name: string;
reusable?: boolean;
usageLimit?: string;
expiration?: string;
ephemeral?: boolean;
groups?: string[];
},
) {
await page.getByTestId("open-create-setup-key").click();
await page.getByTestId("setup-key-name").fill(opts.name);
if (opts.reusable) {
await page.getByText("Make this key reusable").click();
if (opts.usageLimit) {
await page.getByTestId("setup-key-usage-limit").fill(opts.usageLimit);
}
}
if (opts.expiration) {
await page.getByTestId("setup-key-expire-in-days").fill(opts.expiration);
}
if (opts.ephemeral) {
await page.getByText("Ephemeral Peers").click();
}
if (opts.groups && opts.groups.length > 0) {
await page.getByTestId("group-selector-dropdown").click();
for (const group of opts.groups) {
const search = page.getByTestId("group-selector-dropdown-search");
await expect(search).toBeVisible();
await search.fill(group);
await search.press("Enter");
}
await page.getByTestId("group-selector-dropdown-search").press("Escape");
await expect(
page.getByTestId("group-selector-dropdown-search"),
).not.toBeVisible();
}
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys") && resp.request().method() === "GET",
);
await page.getByTestId("create-setup-key").click();
const copyInput = page.getByTestId("setup-key-copy-input");
const keyValue = await copyInput.getAttribute("data-testid-setup-key-value");
expect(keyValue!.length).toBeGreaterThan(10);
await page.getByTestId("setup-key-close").click();
await expect(copyInput).not.toBeVisible();
await responsePromise;
await expect(page.getByText(opts.name)).toBeVisible();
}
async function revokeSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
.click({ force: true });
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(
page
.locator("tr")
.filter({ hasText: name })
.getByTestId("circle-icon-inactive"),
).toBeVisible();
}
async function deleteSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await page
.locator("tr")
.filter({ hasText: name })
.getByTestId("setup-key-actions")
.click({ force: true });
await page.getByTestId("delete-setup-key").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
}

View File

@@ -0,0 +1,115 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
let regularUser = "";
let adminServiceUser = "";
test.describe.serial("Team - Service Users @team", () => {
test("Should create service users and verify roles", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/team/service-users");
regularUser = generateRandomName("svc-user-");
adminServiceUser = generateRandomName("svc-admin-");
await createServiceUser(page, regularUser, "User");
await createServiceUser(page, adminServiceUser, "Admin");
await checkServiceUserRow(page, regularUser, "User");
await checkServiceUserRow(page, adminServiceUser, "Admin");
});
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
await page.locator("tr").getByText(regularUser).click();
await changeRoleTo(page, "Admin");
await page.getByTestId("save-changes").click();
// Create and delete access token
const tokenName = generateRandomName("tkn_");
await page.getByTestId("access-token-open-modal").click();
await page.getByTestId("access-token-name").fill(tokenName);
await page.getByTestId("access-token-expires-in").fill("30");
await page.getByTestId("create-access-token").click();
await expect(page.getByTestId("access-token-copy-close")).toBeVisible();
await page.getByTestId("access-token-copy-close").click();
const tokenRow = page.locator("tr").filter({ hasText: tokenName });
await tokenRow.getByTestId("access-token-delete").click();
await page.getByTestId("confirmation.confirm").click();
await expect(tokenRow).not.toBeVisible();
await page.getByText("Service Users").first().click();
});
test("Should update admin user role and verify all changes persisted", async ({
dashboardAsOwner: page,
}) => {
await page.locator("tr").getByText(adminServiceUser).click();
await changeRoleTo(page, "User");
const saveResponse = page.waitForResponse(
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
await page.getByText("Service Users").first().click();
await checkServiceUserRow(page, regularUser, "Admin");
await checkServiceUserRow(page, adminServiceUser, "User");
// Single reload to verify all changes persisted
await page.reload();
await checkServiceUserRow(page, regularUser, "Admin");
await checkServiceUserRow(page, adminServiceUser, "User");
});
test("Should delete service users", async ({ dashboardAsOwner: page }) => {
for (const name of [regularUser, adminServiceUser]) {
const row = page.locator("tr").filter({ hasText: name });
// Row actions are now behind a dropdown menu; open it, then delete.
await row.getByTestId("user-actions").click({ force: true });
await page.getByTestId("delete-user").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(row).not.toBeVisible();
}
});
});
async function createServiceUser(
page: import("@playwright/test").Page,
name: string,
role: string,
) {
await page.getByTestId("open-service-user-modal").click();
await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("service-user-name").fill(name);
await page.getByTestId("user-role-selector").click({ force: true });
await page
.getByTestId("user-role-selector-item")
.getByText(role, { exact: true })
.click({ force: true });
await page.getByTestId("create-service-user").click();
// Wait for modal to close
await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 });
}
async function checkServiceUserRow(
page: import("@playwright/test").Page,
name: string,
role: string,
) {
const row = page.locator("tr").filter({ hasText: name });
await expect(row).toBeVisible({ timeout: 10_000 });
await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
}
async function changeRoleTo(
page: import("@playwright/test").Page,
role: string,
) {
await page.getByTestId("user-role-selector").click();
await page
.getByTestId("user-role-selector-item")
.getByText(role, { exact: true })
.click();
}

View File

@@ -0,0 +1,150 @@
import { expect, test } from "../helpers/fixtures";
import { loginToApp, navigateTo } from "../helpers/auth";
import { deleteUserByEmail } from "../helpers/api";
test.setTimeout(60_000);
test.describe.serial("User Approval & Billing Admin @team", () => {
// ── User Approval ────────────────────────────────────────────────────
test("Should show approval pending for the second user", async ({
browser,
dashboardAsOwner: ownerPage,
}) => {
// Clean up user from previous runs so approval flow starts fresh
await deleteUserByEmail(ownerPage, "user@localhost.test");
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/user.json",
});
const page = await context.newPage();
await loginToApp(page, "user");
await expect(page.getByText("User Approval Pending")).toBeVisible();
await context.close();
});
test("Should approve the pending user", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/team/users");
const pendingRow = page.locator("tr").filter({ hasText: "Pending" });
await expect(pendingRow).toBeVisible();
await pendingRow.getByRole("button", { name: "Approve" }).click();
await expect(pendingRow).not.toBeVisible();
});
test("Should delete the approved user", async ({
dashboardAsOwner: page,
}) => {
const userRow = page
.locator("tr")
.filter({ hasText: "user@localhost.test" });
await expect(userRow).toBeVisible();
// Row actions are now behind a dropdown menu.
await userRow.getByTestId("user-actions").click({ force: true });
await page.getByTestId("delete-user").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(userRow).not.toBeVisible();
});
// ── Billing Admin ────────────────────────────────────────────────────
test("Should login as second user to trigger registration", async ({
browser,
}) => {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/user.json",
});
const page = await context.newPage();
await loginToApp(page, "user");
await context.close();
});
test("Should approve user and assign Billing Admin role", async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/team/users");
const pendingRow = page.locator("tr").filter({ hasText: "Pending" });
if (await pendingRow.isVisible({ timeout: 5_000 }).catch(() => false)) {
await pendingRow.getByRole("button", { name: "Approve" }).click();
await expect(pendingRow).not.toBeVisible();
}
const userRow = page
.locator("tr")
.filter({ hasText: "user@localhost.test" });
await expect(userRow).toBeVisible();
await userRow.getByTestId("user-name-cell").click();
await expect(
page.getByTestId("breadcrumb-item").filter({ hasText: /^user/i }),
).toBeVisible();
await expect(page.getByTestId("user-role-selector")).toBeEnabled({
timeout: 15_000,
});
const currentRole = await page
.getByTestId("user-role-selector")
.textContent();
if (!currentRole?.includes("Billing Admin")) {
await page.getByTestId("user-role-selector").click();
await page
.getByTestId("user-role-selector-item")
.filter({ hasText: "Billing Admin" })
.click();
await page.getByTestId("save-changes").click();
}
});
test("Should show Plans & Billing and Invoices for the Billing Admin", async ({
browser,
}) => {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/user.json",
});
const page = await context.newPage();
await loginToApp(page, "user");
await expect(page.getByTestId("user-dropdown")).toBeVisible({
timeout: 15_000,
});
await page.getByTestId("user-dropdown").click({ force: true });
await page.getByText("Plans & Billing").click();
await expect(
page.getByTestId("settings-tab-plans-and-billing"),
).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("settings-tab-invoices")).toBeVisible();
await expect(
page.getByTestId("settings-content-plans-and-billing"),
).toBeVisible();
await page.getByTestId("settings-tab-invoices").click();
await expect(page.getByTestId("settings-content-invoices")).toBeVisible();
await expect(
page.getByTestId("settings-tab-authentication"),
).not.toBeVisible();
await expect(
page.getByTestId("settings-tab-permissions"),
).not.toBeVisible();
await expect(page.getByTestId("settings-tab-clients")).not.toBeVisible();
await context.close();
});
test("Should delete the second user", async ({ dashboardAsOwner: page }) => {
await navigateTo(page, "/team/users");
const userRow = page
.locator("tr")
.filter({ hasText: "user@localhost.test" });
await expect(userRow).toBeVisible();
// Row actions are now behind a dropdown menu.
await userRow.getByTestId("user-actions").click({ force: true });
await page.getByTestId("delete-user").click({ force: true });
await page.getByTestId("confirmation.confirm").click();
await expect(userRow).not.toBeVisible();
});
});

View File

@@ -0,0 +1,105 @@
import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix } from "../helpers/api";
let createdGroupName = "";
test.describe.serial("Team - Users @team", () => {
test('Should show the owner with "You" badge and "Owner" role', async ({
dashboardAsOwner: page,
}) => {
await navigateTo(page, "/team/users");
const ownerRow = page
.getByTestId("user-name-cell")
.filter({ hasText: "You" })
.locator("xpath=ancestor::tr");
await expect(ownerRow).toBeVisible();
await expect(ownerRow.getByText("Owner", { exact: true })).toBeVisible();
});
test("Should open the user detail page with Peers and Access Tokens tabs", async ({
dashboardAsOwner: page,
}) => {
await openOwnerDetailPage(page);
await expect(page.getByTestId("user-tab-peers")).toBeVisible();
await expect(page.getByTestId("user-tab-access-tokens")).toBeVisible();
await page.getByTestId("user-tab-peers").click();
await expect(page.getByText("View all peers registered by this user.")).toBeVisible();
await page.getByTestId("user-tab-access-tokens").click();
await expect(page.getByText("Access tokens give access to NetBird API.")).toBeVisible();
});
test("Should add an auto-assigned group, save, and verify persistence", async ({
dashboardAsOwner: page,
}) => {
// Go back to users list via breadcrumb
await page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }).click();
await openOwnerDetailPage(page);
const name = generateRandomName("user-group-");
createdGroupName = name;
await page.getByTestId("user-group-selector").click();
const search = page.getByTestId("user-group-selector-search");
await expect(search).toBeVisible();
await search.fill(name);
await search.press("Enter");
await expect(
page.getByTestId("user-group-selector").getByText(name),
).toBeVisible();
await search.press("Escape");
const saveResponse = page.waitForResponse(
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
{ timeout: 30_000 },
);
await page.getByTestId("save-changes").click();
await saveResponse;
await expect(
page.getByTestId("user-group-selector").getByText(name),
).toBeVisible();
await page.reload();
await expect(
page.getByTestId("user-group-selector").getByText(name),
).toBeVisible();
});
test("Should remove the auto-assigned group, save, and verify removal", async ({
dashboardAsOwner: page,
}) => {
// Already on user detail page from previous test (after reload)
await page
.getByTestId("user-group-selector")
.getByTestId("group-badge")
.filter({ hasText: createdGroupName })
.click();
await page.getByTestId("save-changes").click();
await expect(
page.getByTestId("user-group-selector").getByText(createdGroupName),
).not.toBeVisible();
await page.reload();
await expect(
page.getByTestId("user-group-selector").getByText(createdGroupName),
).not.toBeVisible();
});
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
await deleteGroupsByPrefix(page, createdGroupName);
});
});
async function openOwnerDetailPage(page: import("@playwright/test").Page) {
await page.getByTestId("user-name-cell").filter({ hasText: "You" }).click();
await expect(
page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }),
).toBeVisible();
await expect(page.getByText("Auto-assigned groups")).toBeVisible();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

19
next.config.js Normal file
View File

@@ -0,0 +1,19 @@
const createNextIntlPlugin = require('next-intl/plugin');
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
images: {
unoptimized: true,
},
reactStrictMode: false,
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
},
};
module.exports = withNextIntl(nextConfig);

20711
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +1,116 @@
{
"name": "wiretrustee-dashboard",
"version": "0.1.0",
"name": "netbird-dashboard",
"version": "2.0.0",
"private": true,
"dependencies": {
"@ant-design/icons": "^4.7.0",
"@auth0/auth0-react": "^1.6.0",
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.4",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^27.5.1",
"@types/lodash": "^4.14.182",
"@types/node": "^17.0.35",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.5",
"@types/react-redux": "^7.1.24",
"@types/react-router-dom": "^5.3.3",
"@types/styled-components": "^5.1.25",
"antd": "^4.20.6",
"autoprefixer": "^10.4.4",
"axios": "^0.27.2",
"heroicons": "^1.0.6",
"highlight.js": "^11.2.0",
"history": "^5.0.1",
"lodash": "^4.17.21",
"postcss": "^8.4.12",
"prop-types": "^15.7.2",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-highlight": "^0.14.0",
"react-redux": "^8.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^5.0.1",
"react-table": "^7.7.0",
"redux": "^4.2.0",
"redux-devtools-extension": "^2.13.9",
"redux-saga": "^1.1.3",
"styled-components": "^5.3.5",
"tailwindcss": "^3.0.23",
"typesafe-actions": "^5.1.0",
"typescript": "^4.6.4",
"web-vitals": "^2.1.4"
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
"dev": "next dev -p 3000",
"turbo": "next dev -p 3000 --turbo",
"build": "next build",
"postbuild": "node postbuild.js",
"start": "next start",
"lint": "next lint",
"test:setup": "cd ./e2e/environment && sh create-test-env.sh",
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
"test": "npx playwright test --config=e2e/playwright.config.ts",
"test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui",
"test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.36.1",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "4.17.24",
"@types/node": "20.10.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"clsx": "^2.0.0",
"cmdk": "^1.1.1",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"elkjs": "^0.10.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"framer-motion": "^12.29.2",
"ip-address": "^10.2.0",
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.7",
"lodash": "4.18.1",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-intl": "^4.13.0",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.0",
"react-confetti-explosion": "^3.0.3",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
"react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"timescape": "^0.7.1",
"typescript": "^5"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@types/react-highlight": "^0.12.5"
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",
"@types/js-cookie": "^3.0.6",
"@playwright/test": "^1.52.0",
"eslint": "^9.39.1",
"eslint-config-next": "^16.1.6",
"postcss": "^8",
"prettier": "3.0.3",
"tailwindcss": "^3.4.17"
}
}

96
postbuild.js Normal file
View File

@@ -0,0 +1,96 @@
const { resolve, join } = require("path");
const { createHash } = require("crypto");
const {
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
} = require("fs");
process.env.NODE_ENV = "production";
const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER";
console.log("Starting post-build script to extract inline scripts...");
// Function to find HTML files recursively
function findHtmlFiles(dir) {
const files = [];
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...findHtmlFiles(fullPath));
} else if (entry.endsWith(".html")) {
files.push(fullPath);
}
}
return files;
}
// For Next.js export output, the files are in the 'out' directory
const baseDir = resolve("out");
const htmlFiles = findHtmlFiles(baseDir);
console.log(`Found ${htmlFiles.length} .html files to process`);
// Ensure assets directory exists
const assetsDir = `${baseDir}/assets`;
mkdirSync(assetsDir, { recursive: true });
htmlFiles.forEach((file) => {
// Read file contents
const contents = readFileSync(file, "utf8");
const scripts = [];
// Extract inline scripts
const newFile = contents.replace(
/<script(?![^>]*src)([^>]*)>(.+?)<\/script>/gs,
(match, attributes, scriptContent) => {
// Skip if script has src attribute (external script)
if (attributes.includes("src=")) {
return match;
}
const addPlaceholderString = scripts.length === 0;
const cleanedScript = scriptContent.trim();
if (cleanedScript) {
scripts.push(
`${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`,
);
}
return addPlaceholderString ? PLACEHOLDER : "";
},
);
// Early exit if no inline scripts found
if (!scripts.length) {
console.log(`No inline scripts found`);
return;
}
// Combine scripts and create hash
const chunk = scripts.join("\n");
const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8);
const chunkFileName = `chunk.${hash}.js`;
const chunkPath = `${assetsDir}/${chunkFileName}`;
// Write the chunk file
writeFileSync(chunkPath, chunk, "utf8");
// Replace placeholder string with script tag
const updatedFile = newFile.replace(
PLACEHOLDER,
`<script src="/assets/${chunkFileName}" crossorigin=""></script>`,
);
// Write updated HTML file
writeFileSync(file, updatedFile, "utf8");
});
console.log("Post-build script completed successfully!");

View File

@@ -0,0 +1,6 @@
// Add here trusted domains, access tokens will be send to
const trustedDomains = {
default:["$NETBIRD_MGMT_API_ENDPOINT"],
auth0:[]
};

View File

@@ -0,0 +1,148 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 512 512">
<path fill="#d0103a" d="M0 0h512v512H0z"/>
<path fill="#fedf00" d="M0 0h348.2v512H0z"/>
<path fill="#0018a8" d="M0 0h163.8v512H0z"/>
<path fill="#c7b37f" d="M240.3 173.3c6.2 0 8.7 5.3 14.9 5.3 3.8 0 6-1.2 9.3-3.1 2.4-1.3 3.8-2 6.5-2s4.4.8 5.8 3.1a9 9 0 0 1 1 5.4 32 32 0 0 1-2.1 6.7c-.5 1.2-1 2-1 3.3 0 3.3 4.4 4.4 7.4 4.5.7 0 6.3 0 9.7-3.4-1.9 0-4-1.5-4-3.4 0-2 1.5-3.5 3.5-4.1.4-.1 1 .2 1.4 0 .5-.2.2-.8.7-1.1 1-.8 1.6-1.3 2.9-1.3a3 3 0 0 1 2 .6c.3.2.5.6.9.6 1 0 1.4-.6 2.3-.6.7 0 1.2 0 1.8.4.6.3.6 1.2 1.2 1.2.3 0 1.9-.6 2.8-.6 1.7 0 2.7.6 3.8 2 .3.3.5 1 .8 1a5 5 0 0 1 3.9 2.4c.2.3.5 1.1.9 1.3.4.1.7.1 1.3.5a4.8 4.8 0 0 1 2.3 3.9c0 .5-.3 1.2-.4 1.7-1.5 5.2-5.1 7-8.7 11.4-1.6 2-2.8 3.5-2.8 6 0 .6.8 1.7 1 2.2-.1-1.2.4-2.6 1.6-2.7 1.7 0 3 1.2 3.2 2.8 0 .4 0 1.1-.2 1.5.9-.6 2-1 3.2-1.1a9.9 9.9 0 0 1 1.5 0 13 13 0 0 1 7.4 3 16.9 16.9 0 0 1 5.9 13.4c-.7 4.3-.3 11.9-11 15 2 .8 3.3 2.3 3.3 4.1 0 2-1.5 3.8-3.5 3.8a3.5 3.5 0 0 1-2.8-1.1c-2.2 2.2-2.7 4.5-2.7 7.7 0 1.9.4 3 1.2 4.7a9 9 0 0 0 3 4.2c.8-1.2 1.6-2 3-2 1.5 0 2.7.4 3.3 1.7.1.3 0 .7.2 1 .2.5.7.6 1 1.1.3.8 0 1.4.3 2.2.2.5.7.6 1 1 .3.9.4 1.4.4 2.3 0 2.4-2.2 4-4.6 4-.8 0-1.2-.2-1.9-.1 1.4 1.3 2.4 2 3.5 3.6a14.1 14.1 0 0 1 2.3 8.2c0 3.6-.6 5.8-2.2 9a16 16 0 0 1-5.6 6.8 28 28 0 0 1-12.8 5c-3.4.7-5.3 1-8.8 1.2l-11.3.6c-5.7.4-9.7 1.2-13.8 5.3 2 1.4 3.3 2.8 3.3 5.2 0 2.4-1.5 4.2-3.9 5-.5.1-1 0-1.4.2-.6.3-.6 1-1.2 1.4a5 5 0 0 1-3 .8c-2.2 0-3.6-.5-5.2-2-1.7 1.4-2.3 2.7-4.3 3.9-.7.3-1 .8-1.7.8-1.2 0-1.8-.7-2.7-1.4a18.4 18.4 0 0 1-3.6-3.3c-1.8 1.1-2.9 2-5 2a5.2 5.2 0 0 1-3.1-.9c-.6-.3-.7-.9-1.3-1.2-.6-.4-1-.2-1.7-.5-2.4-1-4-2.8-4-5.5 0-2.3 1.5-3.8 3.6-4.7-4-4-8-4.7-13.6-5-4.4-.4-7-.4-11.3-.7-3.4-.2-5.4-.6-8.8-1.1-2.6-.4-4.1-.6-6.5-1.7-8.2-3.8-13.4-9-14.5-18v-2c0-4.7 1.8-7.5 5-10.8-.8-.2-1.3 0-2.2-.2-2-.8-3.5-2.2-3.5-4.4 0-.8 0-1.4.4-2 .3-.6.8-.7 1-1.2.1-.8 0-1.3.3-2 .2-.5.6-.5.8-1 .7-1.5 1.6-2.7 3.3-2.7 1.4 0 2.3.8 3 2 1.4-.7 1.8-1.7 2.6-3 1.3-2.2 1.8-3.7 1.8-6.2a11 11 0 0 0-.7-4.4c-.4-1.2-.5-2-1.4-3a3.5 3.5 0 0 1-2.8 1.2c-2.3 0-4-2-4-4.3 0-1.7.8-3 2.4-3.7-1.3-1-2.4-1.2-3.7-2-2.1-1.4-2.9-2.7-4.2-4.8-1-1.4-1.2-2.3-1.6-3.8a15 15 0 0 1-.9-5v-1.3c.6-3.9 1.3-6.4 3.8-9.5a11 11 0 0 1 4.6-3.9 11.6 11.6 0 0 1 6.5-1.3c1 .2 1.5.2 2.3.7.3.2.9.7.9.3l-.2-1c0-1.7 1.2-3.2 2.8-3.2 1.2 0 1.7 1 2.3 2 .4-.6.6-1 .6-1.7 0-2.8-1.5-4.2-3.2-6.3-3.7-4.7-8.4-6.9-8.4-12.8 0-1.8.9-3 2.4-4 .4-.2 1 0 1.5-.2.3-.3.3-.7.5-1.1a4 4 0 0 1 1.3-1.3c.8-.8 1.6-.5 2.5-1.2.5-.3.6-.7 1-1.2 1-1.2 2-1.8 3.6-1.8.8 0 1.3 0 2 .3.3 0 .8.5.9.4.1-.2.6-.7 1.1-1 .7-.3 1-.4 1.8-.4.9 0 1.4.5 2.3.5.4 0 .4-.3.7-.5.8-.5 1.2-.8 2.2-.8 1 0 1.4.3 2.2.8.7.4.8 1 1.6 1.5l1.2.3c2 .6 3.6 2 3.6 4.2 0 1.2-.2 2-1.1 2.8-.7.6-1.4.5-2.3.8a13 13 0 0 0 9 2.8c3.5 0 7.6-1.3 7.6-4.7 0-1.6-.9-2.4-1.5-3.8a15 15 0 0 1-1.7-6.9c0-2.2.2-3.5 1.5-5.3 1.3-1.9 3-2.3 5.2-2.3"/>
<g fill="none" stroke="#703d29">
<path stroke-linejoin="round" stroke-width=".5" d="M217.9 191.2c.2.9.9 1.6 2 2a3 3 0 0 0 3-1.1c.8-1 .7-2.3.5-3.3a3.8 3.8 0 0 0-1.4-1.8z"/>
<path stroke-linecap="round" stroke-width=".5" d="M320.8 252.9c-1-2.3-3.4-1.3-3.6 0-.3 3 2.3 3.8 4.1 3.3.9-.2 1.6-.8 2-1.5.5-.8.6-2 .3-3a4 4 0 0 0-.7-1.3 4 4 0 0 0-1-1c-.7-.4-1.5-.5-2.7-.5-4.4 0-8.3 5.3-9.6 10.8a23.6 23.6 0 0 0-.2 9.6 18 18 0 0 0 4.7 9 20 20 0 0 0 7.9 4.7c1.1.3 2.2.3 3.1 0 2.7-.5 3.9-3 2.6-5.5-1.1-2-4.3-3.2-5.8-.6a2.6 2.6 0 0 0-.4 1.3c0 .7.3 1.5.8 1.8 1.2.8 3 .6 3-1.5"/>
<path stroke-width=".7" d="M307 283.2a9 9 0 0 1 5.3-3c2.4-.2 4.5.5 6.6 1.6a14.9 14.9 0 0 1 8.6 13.6c0 3-.8 6-1.5 7.6-.7 1.3-2.5 7.1-12.3 11.2a67.4 67.4 0 0 1-20.5 3c-8.4.4-16 .7-20.5 6.2"/>
<g stroke-width=".6">
<path d="M309.1 292.6c-.2-.9 0-1.7.7-2.7 1-1.3 2.9-1.7 4.7-.7.6.3 1.3.8 2 1.7l1 1.2.8 2c2 5.6-1.2 11.7-5.2 14.1-3.2 2-7 2.8-11.5 3.3l-5.3.3h-7.6a56.3 56.3 0 0 0-5.8 0l-6 .6-4.4.8-1.5.4-1 .3a31.9 31.9 0 0 0-7.7 3.3c-.7.4-1.5.9-2 1.4l-1.1 1c-1.5 1.4-3.1 3-3.5 5.3v1.3c0 1.4 1.1 3.4 4.3 4m4.4-136.1c.6 1.2 1 2 .6 3.1-.4 1.4-1.4 2.3-2.8 2.3-3.2 0-5-3.8-3.6-6.2 2.5-4.3 7.4-1.9 12 .2-.3-1-.7-1.4-.6-2.8 0-3.3 2.6-4.8 3.6-8 .6-1.8.8-3.4-.6-4.7-1.1-1.2-2.5-1.1-4-.5-3.1 1.2-6.8 4.6-13.3 4.7-6.5 0-10.3-3.5-13.4-4.7-1.5-.6-2.9-.7-4 .5-1.4 1.3-1.2 3-.6 4.8 1 3 3.5 4.6 3.6 8 0 1.3-.3 1.6-.6 2.7 4.6-2 9.7-4.7 12-.2 1.3 2.5-.4 6.2-3.6 6.2-1.4 0-2.4-1-2.8-2.3-.4-1.1 0-2.2.6-3.1"/>
<path stroke-linecap="round" d="M251.7 191.9c1.2 1 2 2.1 1.9 4-.1 2-.7 2.5-2.2 3.6m1.9-3c-.1 1.2-.6 2-1.8 2.5"/>
</g>
<path fill="#c7b37f" stroke="none" d="m221.4 186.6.5.4.6.7.4.8.2.6v1.5l-.2.7-.4.5-.4.5-.7.3-.9.2-.7.2-.8-.4-.8-.5-.4-.7-.3-.8v-.3z"/>
<path stroke-linecap="round" stroke-width=".5" d="M220.2 189.7c-.3-1.3-1.8-1.6-2.4-.8-1 1.2-.3 3.2 1.6 3.8a3 3 0 0 0 3-1.1c.8-1 .8-2.3.5-3.2-.2-.7-.7-1.2-1.4-1.7-2.2-1.7-5.7-1.3-6.8 1.5-1.5 3.6 1.7 6.3 4.7 8.3 3.8 2.5 8 3 11.3 3 7.3-.1 12.9-3.6 16.5-5.6.8-.5 1.7-.4 2.1.2.5.6.5 1.5-.2 2.2"/>
<path stroke-width=".5" d="m198.4 289-1.6.5-1.7 1.3-.7 1-.9 1.6-.4 1.2-.3 1.5-.2 1m15.2-8v1.4l-.3 1-.7 1.7-1.1 1.5-1.2 1-1 .4-1.2.3"/>
<path stroke-width=".6" d="M255.8 327.3c-.3 1.3-1.5 2.8-4.3 3.4h-.5"/>
<path stroke-width=".7" d="M323.4 285a14.6 14.6 0 0 1 4.5 10.8c-.1 2.8-.8 6-1.6 7.5-.7 1.3-2.5 7.2-12.3 11.2a67.7 67.7 0 0 1-20.5 3.1c-8.2.4-15.8.7-20.3 6"/>
<path stroke-width=".5" d="M310 290.3c.6-.9 2.8-1.9 4.6-1a5 5 0 0 1 2 1.7"/>
<path stroke-width=".7" d="m321.3 283 1.1.4a5.6 5.6 0 0 0 3.2 0c2.2-.6 3.7-2.7 2.5-5.5a4.5 4.5 0 0 0-1.4-1.7"/>
<path stroke-linecap="round" stroke-width=".5" d="M192.2 223.8c-1.5 1-2.6 1.2-3.8 2.5a22.5 22.5 0 0 0-2.1 5.5m36.9-41.4c0 1.4-1 2.3-2.4 2.6"/>
<path stroke-width=".5" d="M317.7 217.6c3.8 0 14.8 2.9 14.9 15.8 0 12.8-8 14.9-11.1 15.7"/>
<path stroke-width=".5" d="M318.7 217.6c6.5-.3 13.2 4.5 13.5 16.5.3 9.4-6.4 13.6-9.6 14.5m-7.6 14.1.2-1.2.4-2 .6-1.7.7-1.3.8-1m6.3-2.7-.1 1.2-.4.9-.5.8-.7.5-1 .3h-1.5m-11.4-42.3.3-1.3.6-1.3.7-1.2 1.4-1.7 1-1.2 1.7-1.7 1.5-1.5 1-1.1 1.2-1.5 1-1.7.7-1.3.4-1.8.1-2.1-.2-.7M310 296.7l1.3-.3 1-.5.5-.5.4-.7.2-1v-.6M187 283.3l.9.1h1.2l1.3-.5m4-29.3-.2 1.2-.2.4-.4.5-.5.4-.6.3-.8.1h-.5m8-12.5-.3 1.8-.4.7-.7 1-1 .7-.9.5-1.8.4m12.2-31.8-.3 1-.5.8-.6.9-.8.7-1 .5-.8.2h-.6m.3-5v.8"/>
<g stroke-width=".5">
<path stroke-linecap="round" d="M203.4 243.3a5.5 5.5 0 0 1-1.6 1M322.2 280l.4.2c1 .7 3.3-.2 2.7-2"/>
<path d="M318.2 255.7c.8 1 2.4 1.3 3.6 1 .9-.2 1.5-.8 2-1.6.4-.8.6-1.9.3-3a4 4 0 0 0-.7-1.3 5.4 5.4 0 0 0-1.1-1.1l-.3-.2m5.2 27.2a3.1 3.1 0 0 0 0-.6c0-.9-.4-1.7-1-2.3-.2-.2-.4-.5-.7-.6m.4.3c0-1.5-1.3-2.5-2.8-2.8m-3.3 2.3c-.4-.3-.8-.5-1-.9a12.6 12.6 0 0 1-3.5-8.5c0-3.3 1.3-6.7 2.8-8M273 323.3l1.5-1.3 1-.8 1.8-1.1 1.8-.9 1.2-.3 2.5-.5 2.9-.5M262 333.4a14.1 14.1 0 0 1-6.1 5 14.1 14.1 0 0 1-6.1-5"/>
<path stroke-linecap="round" d="M251.5 330.1a8 8 0 0 1-1.7 3.3"/>
<path d="m251.8 328.4-.4 1.8m-1.8 3.3-.8.8-1.4.7-1.5.5m-4.5-142.2c.2-.6.4-1.1.3-2.1 0-3.4-2.5-4.9-3.5-8-.6-1.8-.9-3.4.5-4.8 1.2-1.1 2.5-1 4-.5 3.2 1.2 6.9 4.7 13.4 4.8-6.5-.1-10.2-3.6-13.3-4.8-1.6-.6-3-.8-4.2.4-1.4 1.3-1 3-.4 5 1 3 3.3 4.5 3.4 7.9 0 1-.2 1.5-.4 2m14.9-10.7c6.4-.4 11.9-4.7 13.7-5 1.6-.3 2.4-.2 3.6.9-1.2-1.1-2.5-1-4-.5-3 1.2-6.8 4.7-13.3 4.8m63.7 90.3a12.4 12.4 0 0 1-5-9.9c0-3.3 1.3-6.7 2.9-8m-56 78a14.1 14.1 0 0 1-6 5 14 14 0 0 1-6.2-5"/>
<path stroke-linecap="round" d="m245.3 195 1.9-1c.8-.6 1.9-.5 2.3 0 .5.7.6 1.7-.1 2.3"/>
<path d="M235.8 199.4c4.4-.9 8-2.9 10.6-4.4m25.9 131.4.6.7.2.7c.2 1.2-.6 2-1.5 2.1a2.7 2.7 0 0 1-2.8-1.6m-33.3-129.1c4.4-1 8-2.9 10.7-4.4m78 85.5c-.7.3-1.2.3-2.2-.2l-1.5-.8c-2-1.1-4.5-3-6.8-7.2a15 15 0 0 1-1.3-3.6c-.2-.9-.4-1.8-.4-2.7a20.5 20.5 0 0 1 .5-5 16.2 16.2 0 0 1 3.2-7.2c1-1.3 1.7-2 3.5-2m-115-31.5a5.7 5.7 0 0 1 2.1 4.6c0 2.5-2 6.5-7.2 8-2 .5-3.8 0-5-.7"/>
<path d="M205 228.5c1 .6 1.3 1.4 1.3 2.6 0 .8-.5 2-1.5 3a9.9 9.9 0 0 1-7 3.2 8.2 8.2 0 0 1-4.8-1.4 7.3 7.3 0 0 1-3-4.3"/>
<path d="M205 233.8c1 1 1.3 2.2 1.3 3.7 0 2.2-.9 3.9-3 5.7a5 5 0 0 1-1.5 1m103.6-17.6v2.9m-.3-3.6v4m.3-12.6v5.2m-.3-6.3v7m-1.5 65.7c-1 2-1.8 3-3.3 4.5a15.7 15.7 0 0 1-4.7 3.3 19.7 19.7 0 0 1-5.2 1.7c-2.1.5-3.4.6-5.5.7-2 0-3.1 0-5.1-.2-2.1 0-3.3-.4-5.4-.6-1.7-.1-2.7-.3-4.5-.3a22.8 22.8 0 0 0-8.7 1.5c-2.2.9-4.6 2.4-5.1 3-.5-.6-3-2.1-5.1-3a22.8 22.8 0 0 0-8.8-1.5c-1.7 0-2.7.2-4.4.3-2.1.2-3.3.5-5.4.6a37.3 37.3 0 0 1-10.6-.5c-2.1-.5-3.3-.8-5.3-1.8a15.7 15.7 0 0 1-5-3.7m33.6 42.7 1.5-.2m24.2-1.9 1.4-.1 1.4-.6 1-.5 1.3-1.6.3-.6.2-1.3v-.6M314 218.8c.6-2.1-.2-4.3-2.2-4.3m-105.6 37.3a6.5 6.5 0 0 1-2.9 3.7m3-37.4a5.2 5.2 0 0 1-3 3.2c-1.4.7-3.2 0-4-.6"/>
<path stroke-linecap="round" d="M195 225.9c1.3.6 2.5-.3 2.3-1.9a2.3 2.3 0 0 0-2-1.8"/>
<path d="M200.1 293.3c.3.3.4.6.7.6.5.1 1 .3 1.5-.4.7-.9.3-2.2-.4-3.1a4 4 0 0 0-4.7-.7c-.5.3-1.3.7-2 1.6l-.9 1.3-.9 2c-1.6 4.6.3 9.5 3.4 12.5"/>
<path stroke-linecap="round" d="m272.2 326.3.5.6.2.7c.2 1.2-.6 2-1.6 2-1.3.2-2.2-.6-2.7-1.6"/>
<path d="M311.6 187.8a6 6 0 0 1 5 5.6c0 3.6-1.2 4.9-3.1 7.4-2 2.7-8.5 7.7-8.5 13.4 0 3.4 1 5.6 3.4 6.7 1.6.7 3.5 0 4.3-.8 2-1.9 1.3-5.2-1-5.6-2.5-.4-3 3.7-.5 3.4m14.3 55.3a3 3 0 0 0-2.9-2.5 3 3 0 0 0-3 3c0 .8.4 1.5.9 2"/>
<path d="M307.1 220.1a5.7 5.7 0 0 0-2.1 4.6c0 2.5 2 6.5 7.2 8 1.9.5 3.8.4 5-.3m-124.9-8.2a7.5 7.5 0 0 0-3.8 2.7 13.5 13.5 0 0 0-1.9 4.9c-.1.7-.3 3 .1 5.3a12.7 12.7 0 0 0 1.9 4.5l.8 1 .9.7m51.2 73.6c3.9 1.8 6.7 3 9.2 6.9a8.2 8.2 0 0 1-1.7 10 6.6 6.6 0 0 1-5.4 1.6c-1.5-.2-3-1.3-3.2-2m-37.2-90a6.6 6.6 0 0 1 3.1 6c0 3-1.5 4.8-3.2 5.9"/>
<path stroke-linecap="round" d="M201.2 253.1c3.3 4.1 5 6.5 5.1 11.3.1 4.6-1.4 7.7-4 11"/>
<path d="M263.8 199.5a3.3 3.3 0 0 0 1.3-1.8c.4-1.2.4-2.2-.3-3.1.8 1 .9 1.9.7 3.1-.2.8-.7 1.2-1.3 1.8m41.2 69v12.8a19.6 19.6 0 0 1-.3 3.4m0-17.5V283l-.4 2.1m.4-34.3v11.6m.3-10.7v9.4m0-21.5v7.1m-.3-7.9v8.8m.3-15.2v2.8m-.3-3.4v4m-1.4 52.2-.3.5a15 15 0 0 1-3.4 4.6 15.7 15.7 0 0 1-4.6 3.2 19.7 19.7 0 0 1-5.3 1.8c-2 .5-3.3.6-5.5.7-2 0-3 0-5-.2-2.2-.1-3.3-.4-5.4-.6-1.8-.1-2.8-.3-4.5-.3a22.9 22.9 0 0 0-8.8 1.5c-2.1.9-4.5 2.4-5 3a17 17 0 0 0-5.1-3 22.9 22.9 0 0 0-8.8-1.5c-1.7 0-2.7.2-4.5.3-2 .2-3.2.5-5.4.6a37.3 37.3 0 0 1-10.5-.5 19.8 19.8 0 0 1-10-5 17.6 17.6 0 0 1-1.9-2.3m-1.6-2.5a8 8 0 0 1-1.8 6.2c-.7.7-2.2 2-4 2-3 .1-4-2-4.1-2.5"/>
<path d="M204.5 287a8.2 8.2 0 0 1 1.5 2.1c.7 1.4.5 3.8-.1 5a3.7 3.7 0 0 1-.3.3m-16.1 14.4c1.8 2 4.5 4 8.7 5.7a67.4 67.4 0 0 0 20.5 3.1c8 .3 15.5.7 20 5.7m13.9-3.3a12 12 0 0 1 3.2 4.5m-5.9 9.2a7 7 0 0 1-.5.5 6.6 6.6 0 0 1-5.3 1.6 5 5 0 0 1-3.5-2m-4.3-2.4.3.3a6 6 0 0 0 4 2m21.6 0a14.1 14.1 0 0 1-6.1 4.9 14.1 14.1 0 0 1-6.1-5l-.2-.3m12.4.3.6.6a6.6 6.6 0 0 0 5.3 1.6 4.4 4.4 0 0 0 3.3-2l.4-.6"/>
<path d="m271.2 333.3-.6 1-.9.7-1.3.6H267"/>
<path d="M274.4 324.2a6.1 6.1 0 0 1 1.9 2.3c.2.6.4 1.3.4 2a4.7 4.7 0 0 1-1.1 3.2 6 6 0 0 1-4.4 2 4.4 4.4 0 0 1-.3 0m.1-.2a5.5 5.5 0 0 1-4.1-1.7m51-54.3a19 19 0 0 1-4-5.2 15 15 0 0 1-1.3-3.6c-.2-.9-.4-1.7-.4-2.6 0-1.6.1-3.2.5-5a16.7 16.7 0 0 1 3.3-7.3c.5-.6 1-1.4 1.6-1.8m-1-60.6c2 .2 3.8 2.3 3.8 4.5 0 3.1-1 4.4-3.5 7.4-2.1 2.7-8.5 7.3-8.3 11.7 0 .8.5 1.6 1 2.2M307 220c.4.5 1 .8 1.6 1.1a4 4 0 0 0 3.4-.2m-16.9-34.6a4.8 4.8 0 0 1 1.8 2.1c1.4 3.6-1.8 6.3-4.8 8.3a17 17 0 0 1-6.6 2.6"/>
<path d="M291.7 193.2c-.7 0-1.6-.2-2.5-1.2a2.7 2.7 0 0 1-.6-.7m-11.9 3.9a3.7 3.7 0 0 1-1-.8c-.7-.8-1.2-1.9-.7-3.5.5-1.5 3-5.8 3-8.7.3-4.5-1.5-7.2-4.2-8.2"/>
<path stroke-linecap="round" d="m277.9 181.2-.1 1.7-.5 1.7-.9 2.3-.7 1.6-.7 1.5-.3 1-.2.8.1.8m30.5 101c0 .3.4.6.4.6a6.2 6.2 0 0 0 4.4 2.5c3 0 3.7-2.1 3.8-2.6.4-2.3-.4-3-1.6-3.6 0 0-.7-.3-1.5-.2"/>
<path d="M189.6 283.5a5.5 5.5 0 0 1-3 0c-2.3-.7-4-2.9-3.1-5.5m10.7-25.5c.2.2.3.6.3.8.3 3-2.2 3.8-4 3.4a4.5 4.5 0 0 1-2.5-1.9 3.8 3.8 0 0 1-.5-1.8m17.7-19c.4.5.8 1 1 1.5m-1-6.8c.5.3.8.6 1 1"/>
<path stroke-linecap="round" d="M206.3 232.4a6.8 6.8 0 0 1-1.3 2 9.9 9.9 0 0 1-7 3.1 8.2 8.2 0 0 1-4.8-1.4 7.6 7.6 0 0 1-3.3-4.4"/>
<path d="M204.3 220.2a6.2 6.2 0 0 1 2 2.7"/>
<path stroke-linecap="round" d="M206.3 226.6a9.4 9.4 0 0 1-7 6.3 7 7 0 0 1-5.2-.9"/>
<path d="M192 226c.2 2.1 1.7 3.7 4.3 3.8 3.8 0 6-5.4 2.7-9.3"/>
<path stroke-linecap="round" d="M183.6 244.4c.5.7 1.2 1.3 1.8 1.9a13.4 13.4 0 0 0 4.8 2.6m4.2.4c3.4-.4 5.3-2.9 4.9-5.8-.3-2.3-2.4-4-3.8-4"/>
<path d="M199.9 214.5c1.4 0 2.3 1.3 2.2 2.4"/>
<path stroke-linecap="round" d="M199.5 194.5a9.2 9.2 0 0 0 4 4.6M319 224a3.7 3.7 0 0 1-3.3 5.7 4.2 4.2 0 0 1-3.5-2"/>
<path d="M305.4 199.3v12.6"/>
<path stroke-linecap="round" d="M195 225.9c1.2.8 2.6-.6 2-2.1-.3-1-1.8-2.1-3.8-.8-2.1 1.5-1.5 6.3 2.7 6.3 3.7.1 6-5.4 2.7-9.2-3.2-3.7-9-2.9-13 .2a17.1 17.1 0 0 0-5.6 9.3 17 17 0 0 0 0 7.4 16.7 16.7 0 0 0 2.4 6l1 1.3 1.6 1.6a12 12 0 0 0 8.3 3c3.8-.1 6-2.8 5.5-5.9-.4-3-3.4-4.5-5.4-3-1.3.9-1.8 3.8.6 4.5 1.3.4 2.5-1.3 1.6-2.3m103.6-57.5c2.2-1.2 3.8-1 5 .7a7.9 7.9 0 0 1 1.3 5.8c-.4 2.2-1 3-2.8 4.6"/>
<path stroke-linecap="round" d="M304.4 185.6c2.5-1.6 5.2-1 6.6 1.3a7.3 7.3 0 0 1 1.3 4.9 9 9 0 0 1-4.6 7.3"/>
<path d="M316 191.3c2 .2 3.7 2 3.7 4.2 0 3-.8 4.4-3.3 7.4-2.1 2.6-8.4 7.2-8.3 11.7 0 1.6 1.5 3.2 2.7 3.3"/>
<path stroke-linecap="round" d="M316.3 225.9c-1.2.8-2.6-.5-2-2 .4-1 1.8-2.2 3.7-.9 2.2 1.5 1.6 6.3-2.6 6.3-3.7.1-6.3-5.2-2.7-9.2 3.3-3.7 9.4-3 13.2 0 1.6 1.4 5 5 5.6 9.6.9 5.6.7 12.6-5 16.8a13.8 13.8 0 0 1-8.5 2.4c-3.8-.1-6-2.8-5.5-5.9.4-3 3.3-4.3 5.4-3 2.2 1.1 1.8 4.3-.6 4.5-1.4.2-2.5-1.3-1.6-2.3"/>
<path d="M314.3 224c.6-2.9 3-3.1 5-3.1 5.2 0 8.9 6.3 9 12.4 0 7.6-3.3 12.1-9 12.3-1.3.1-3.8-.6-3.9-2.3"/>
<path stroke-linecap="square" d="M317.5 222.7c5.6 1.2 7.6 6.2 7.6 11 0 3.9-.4 9.2-8 11"/>
<path d="M326.7 276.3a3.1 3.1 0 1 0-5 1.8"/>
<path stroke-linecap="round" d="M315.6 271.5a13.3 13.3 0 0 0 5 4.8m-1 8.4c-2.7-1.7-7.7-4-12.2-1.8a6.3 6.3 0 0 0-3.4 3.5 8 8 0 0 0 1.5 7.7 6 6 0 0 0 4 2.1c3 0 3.7-2 3.8-2.5.3-2.2-1-3.1-1.6-3.3-.6-.2-2.2-.2-2.6 1-.1.4-.1 1.1.2 1.6"/>
<path stroke-linecap="round" d="M272.4 326.7c.8 1.8-.1 2.6-1.3 2.7-1.7.2-2.6-1.1-2.7-2.3-.2-2 1.5-3.9 3.5-3.8a4.4 4.4 0 0 1 4 2.8c.2.6.3 1.2.3 1.9a4.7 4.7 0 0 1-1.1 3.3 6 6 0 0 1-4.3 2c-3.4.1-6-3-6-6.3 0-6.1 9.1-9.5 12.8-10.4a67 67 0 0 1 14.3-1.8c2.9-.2 5-.1 8.1-.4 2.8-.3 4.3-.5 7.2-1.1a22 22 0 0 0 10-5.2 13.7 13.7 0 0 0 3.7-17.7 11.5 11.5 0 0 0-8.2-5.3c-3-.5-5.6.8-7.2 3.8a6.2 6.2 0 0 0 .1 5c.5.9 2 2.3 3.8 2.3 3 0 3.8-2 3.9-2.5.3-2.2-1-3.1-1.6-3.3-.6-.2-2.2-.2-2.6 1-.1.4-.1 1.1.2 1.6"/>
<path stroke-linecap="round" d="M269.8 317c-4 1.7-6.8 3-9.2 6.7a7.9 7.9 0 0 0-1 4c0 2.1 1 4.5 2.7 6a6.6 6.6 0 0 0 5.4 1.7c1.5-.2 3-1.3 3.2-2"/>
<path d="M308 243.3c-1.7.6-3 3.4-3 6 0 3 1.4 5 3.2 6"/>
<path stroke-linecap="round" d="M310 253.1c-3.2 4.1-5 6.5-5 11.3-.1 4.6 1.3 7.7 4 11"/>
<path d="m292.7 185.6.3-.4c1.3-2 3.7-2.5 5.5-1.2 2 1.6 2.6 4.3 2 7.2a7 7 0 0 1-3.2 4.4"/>
<path stroke-linecap="round" d="M212 184.7c-2-1-3.7-.8-5 .7a7.5 7.5 0 0 0-1.2 5.8c.4 2.1 1 3 2.8 4.6"/>
<path d="M206.9 185.6c-2.5-1.6-5.2-1-6.6 1.3a7.3 7.3 0 0 0-1.3 4.9 9 9 0 0 0 4.6 7.3"/>
<path d="M199.7 187.8a5.5 5.5 0 0 0-4.8 5.3c0 3.6.9 5 2.9 7.7s8.5 7.7 8.5 13.4c0 3.4-1 5.6-3.4 6.7-1.6.7-3.5 0-4.3-.8-2-1.9-1.2-5.2.9-5.6 2.6-.4 3.1 3.7.6 3.4"/>
<path d="M195.2 191.3c-2 .2-4 2-4 4 0 3.1 1.2 4.5 3.7 7.6 2 2.6 8 7.2 7.9 11.6 0 1.6-1.2 3.7-2.3 3.4"/>
<path stroke-linecap="round" d="M190.5 252.9c1-2.3 3.4-1.3 3.5 0 .4 3-2.2 3.8-4 3.3-1-.2-1.6-.8-2-1.5a3.9 3.9 0 0 1 .4-4.3 4 4 0 0 1 1-1c.7-.4 1.5-.5 2.7-.5 4.4 0 8.3 5.3 9.6 10.8a23.6 23.6 0 0 1 .2 9.6 18 18 0 0 1-4.7 9 20.1 20.1 0 0 1-7.9 4.7 5.6 5.6 0 0 1-3.2 0c-2.2-.6-3.7-2.8-2.5-5.5 1-2.1 4.3-3.2 5.8-.6.1.3.3.7.3 1.3 0 .7-.3 1.5-.8 1.8-1.1.8-3 .6-2.9-1.5"/>
<path d="M187 280.3c.8.3 1.3.3 2.3-.2l1.5-.8c2-1.1 4.5-3 6.7-7.2a15.1 15.1 0 0 0 1.4-3.6c.2-.9.4-1.8.4-2.7a20.5 20.5 0 0 0-.5-5 16.2 16.2 0 0 0-3.2-7.2c-1-1.3-1.7-2-3.5-2m-7.5 24.7a3.1 3.1 0 1 1 5 1.8"/>
<path d="M185.8 273.2a3 3 0 0 1 2.9-2.5 3 3 0 0 1 3 3 3 3 0 0 1-1 2"/>
<path d="M191.5 273a12.4 12.4 0 0 0 5-9.9c0-3.3-1.3-6.7-2.9-8"/>
<path stroke-linecap="round" d="M195.7 271.5a13.2 13.2 0 0 1-5 4.8"/>
<path d="M203.7 283c-.8-1.8-2.2-2.6-4.6-2.9a11 11 0 0 0-6.6 1.6 14.8 14.8 0 0 0-8 9 13.7 13.7 0 0 0-.6 4.6c0 2.9.8 6 1.6 7.5.6 1.4 2.4 7.2 12.2 11.2a67.7 67.7 0 0 0 20.6 3.2c8.3.3 16 .6 20.4 6.1"/>
<path stroke-linecap="round" d="M191.7 284.7c2.7-1.7 7.6-4 12.1-1.8a7 7 0 0 1 3.5 3.5 8 8 0 0 1-1.5 7.7c-.7.7-2.1 2-4 2.1-3 0-3.7-2-3.8-2.5-.3-2.2 1-3.1 1.6-3.3.5-.2 2.2-.2 2.6 1 .1.4.1 1.1-.2 1.6"/>
<path d="M202.2 292.6a2.7 2.7 0 0 0-.7-2.7 4.1 4.1 0 0 0-4.7-.7 5 5 0 0 0-2 1.7l-1 1.2-.8 2c-2 5.6 1.2 11.6 5.2 14.1a24 24 0 0 0 11.5 3.3l5.3.3h13.4l6 .6 4.4.8 1.5.4 1 .3a31.9 31.9 0 0 1 7.7 3.3c.7.4 1.5.8 2 1.4l1.1 1c1.5 1.4 3.1 3 3.5 5.3v1.3c0 1.4-1.1 3.4-4.3 4"/>
<path d="M239 326.7c-1 1.8 0 2.6 1.2 2.7 1.7.2 2.6-1.1 2.7-2.3.2-2-1.5-3.9-3.5-3.8a4.4 4.4 0 0 0-4 2.8 5.5 5.5 0 0 0-.3 1.9 4.7 4.7 0 0 0 1 3.3 6 6 0 0 0 4.4 2c3.4.1 6-3 6-6.3 0-6.1-9.1-9.5-12.8-10.4a67 67 0 0 0-14.3-1.8c-2.9-.2-5-.1-8.1-.4-2.8-.3-4.3-.5-7.2-1.1a22 22 0 0 1-10-5.2 13.7 13.7 0 0 1-3.7-17.7 11.5 11.5 0 0 1 8.2-5.3c3-.5 5.6.8 7.1 3.8.8 1.4.6 3.8 0 5a4.8 4.8 0 0 1-3.9 2.3c-3 0-3.7-2-3.8-2.5-.3-2.2 1-3.1 1.6-3.3.5-.2 2.2-.2 2.6 1 .1.4.1 1.1-.2 1.6"/>
<path stroke-linecap="round" d="M218.6 185.6a97 97 0 0 0-.3-.4c-1.3-2-3.7-2.5-5.5-1.2-2 1.6-2.6 4.3-2 7.2a7 7 0 0 0 3.2 4.4"/>
<path d="M293.4 191.7c-3.2 3.5-6.5 4.6-11.3 4.8-1.5 0-4.4-.5-6-1.7-1-.8-2.3-2-1.5-4.4.5-1.5 3-5.7 3-8.7.2-4.5-1.5-7-4.2-7.9-5-1.8-10.4 3.2-13.6 4.3a11 11 0 0 1-4.1.6c-1.6 0-2.5 0-4.2-.6-3.2-1.1-8.6-6-13.6-4.3-2.7 1-4.4 3.4-4.2 8 0 2.9 2.5 7.1 3 8.6.8 2.3-.4 3.6-1.5 4.4a11.6 11.6 0 0 1-6 1.7c-4.9-.2-8-1.3-11.3-4.8"/>
<path stroke-linecap="round" d="M237.9 315.5c.6.3.1-.1 4.2 1.7 3.8 1.7 6.6 3.2 9 7a8.5 8.5 0 0 1 .7 5.9"/>
<path d="M238.1 332.8a6.4 6.4 0 0 0 2.6.7c3.4.1 6-3 6-6.3 0-2.2-1.2-4-2.9-5.6"/>
<path stroke-linecap="round" d="M238.9 326.7c-.9 1.9.3 2.8 1.5 3 1.7.2 2.6-1.2 2.8-2.4a3.6 3.6 0 0 0-1.7-3.3"/>
<path d="M312 187.8c2.6 0 4.9 2.9 4.9 5.8 0 3.4-1.8 5.5-3.1 7-1 1.3-2.2 2.4-3.6 3.8"/>
<path stroke-linecap="round" d="M309 185.1a5 5 0 0 1 2.3 2 7.3 7.3 0 0 1 1.2 4.9c-.1 3.4-2.5 5.7-4.7 7.1m-3.8-14 .5.6a7 7 0 0 1 1.2 5.7 6.5 6.5 0 0 1-3 4.4m-4-11.6c2 1.6 2.7 4.4 2 7.2-.5 2-1.8 3.3-3.3 4.2m8.9 32.9c.2.7.6 1 1.2 1.5a10.8 10.8 0 0 0 4.9 2.9 6.2 6.2 0 0 0 5-.7M187 275.4c1 0 2 .6 2.7 1.8a2.6 2.6 0 0 1 .3 1.2c0 .7-.3 1.4-.8 1.8-1.2.7-3.2.4-3.1-1.7"/>
<path d="M193.2 249c4 .8 7.7 5.5 9 10.7a23.6 23.6 0 0 1 .2 9.6 18 18 0 0 1-4.7 9c-.5.6-1 1-1.7 1.5l-.9.6m-6.3-9.7c1.6 0 3 1.5 3 3.2a3 3 0 0 1-.8 2"/>
<path d="M187.7 272.6c1.7 0 3.3 1.6 3.3 3.3a3.1 3.1 0 0 1-1.2 2.5"/>
<path stroke-linecap="round" d="M203.2 255.6c1.5 2 2.6 3.9 3 6.2m0 6.8a13.8 13.8 0 0 1-1.2 3.2 14.2 14.2 0 0 1-2.8 3.7"/>
<path d="M203.4 243.5a7.5 7.5 0 0 1 2.8 3.8"/>
<path stroke-linecap="round" d="M206.3 239.6a8.7 8.7 0 0 1-2.7 3.7m-7.3-13.8 1.7-.4 1-.8.7-1 .5-1.4.3-1.2"/>
<path d="m192.8 223.4-2 .7a7 7 0 0 0-2.8 2.4 13.5 13.5 0 0 0-1.8 4.8c-.2.7-.4 3 0 5.3a12.6 12.6 0 0 0 2 4.6l.8 1c1 1 2 1.7 3.5 1.4"/>
<path stroke-linecap="round" d="M202.4 215.8c-.2 1-.8 2.3-2.4 2.2"/>
<path d="M196.5 222.8c-1.5-1.5-4.8-1.9-8 .2-.5.2-.9.6-1.3 1a7 7 0 0 0-1.1 1.2l-1.2 2a10 10 0 0 0-.7 2c-.6 2.3-.6 4.5-.6 5l.3 2.2a15 15 0 0 0 1.8 5 8.2 8.2 0 0 0 6.2 4.3c1.4.1 3.9-.6 4-2.4"/>
<path stroke-linecap="round" d="M291 189.7c.2-1.4 1.8-1.6 2.4-.8 1 1.2.4 3.2-1.5 3.8a3 3 0 0 1-3-1.1c-.9-1-.8-2.2-.5-3.2.2-.7.7-1.2 1.4-1.7 2.1-1.7 5.7-1.3 6.8 1.5 1.5 3.6-1.7 6.3-4.7 8.3-3.8 2.5-8 3-11.3 3-7.3-.1-12.9-3.6-16.5-5.6-.8-.5-1.7-.4-2.1.2-.5.6-.5 1.5.2 2.1"/>
<path stroke-linecap="round" d="M292.5 188.4c.8 0 1 .4 1.2.7 1 1.2.3 3.2-1.6 3.8m14.3 41.2c-2.8 3-.3 8.3 1.8 9.5.7.5 1 .2 1.6.6"/>
<path d="M306.5 228.3c-1 .7-1.2 1.4-1.3 2.6a4.2 4.2 0 0 0 1.2 3.2 11.2 11.2 0 0 0 7.3 3 8.2 8.2 0 0 0 4.9-1.4 7.3 7.3 0 0 0 3-4.3M305 281v2c-.4 2.2-.7 3.5-1.7 5.5a15 15 0 0 1-3.4 4.5 15.7 15.7 0 0 1-4.7 3.3 19.7 19.7 0 0 1-5.2 1.8 33 33 0 0 1-5.5.6h-5l-5.5-.7c-1.7-.2-2.7-.3-4.4-.3a22.8 22.8 0 0 0-8.8 1.5 17 17 0 0 0-5 3c-.6-.6-3-2.2-5.2-3a17.6 17.6 0 0 0-4.1-1.2c-1.8-.3-2.8-.3-4.6-.3-1.8 0-2.7.1-4.5.3-2 .2-3.3.5-5.4.6-2 .1-3 .2-5 .1-2.2 0-3.4-.2-5.6-.6a19.7 19.7 0 0 1-5.2-1.8c-2-1-3-1.7-4.7-3.3a15 15 0 0 1-3.3-4.5 15.1 15.1 0 0 1-1.7-5.5v-83.4H305z"/>
</g>
<g fill="#c7b37f" stroke="#c7b37f">
<path stroke-width=".3" d="M198.3 292.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0zm-12.2-14.1c0-1 .6-1.8 1.4-1.8.8 0 1.4.8 1.4 1.8s-.6 1.8-1.4 1.8c-.8 0-1.4-.8-1.4-1.8z"/>
<path stroke="none" d="M193 242.9c0-.8.7-1.5 1.4-1.5.8 0 1.4.7 1.4 1.5s-.6 1.4-1.4 1.4c-.7 0-1.3-.6-1.3-1.4zm24.6-52.5c-.1-.9.4-1.6 1-1.6.7-.1 1.4.5 1.5 1.3 0 .8-.4 1.5-1.1 1.6-.7 0-1.4-.5-1.4-1.3"/>
</g>
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".5">
<path d="M191.4 251.2a1.8 1.8 0 0 0-.6.4l-.5.7-.2 1m3.8 21.3.7-.8.6-.8.4-.7.5-1m-1 11-1.2.6-.9.5a14 14 0 0 0-1 .7l-1 .8m12-30.3-.6-.7-.7-.7-.8-.5"/>
<path stroke-linecap="butt" d="m203.3 244-1 .4a4 4 0 0 1-1.1.2"/>
<path d="M190 230.8c0 .4.1.7.3 1.1l.7 1.4a6.8 6.8 0 0 0 2.2 2.1l1.2.7m-.9-4.7 1 .5a6 6 0 0 0 2.4.5l1.5-.1m5.7-32.5-1.6-1a9.6 9.6 0 0 1-2.4-2.3l-.7-1m6-3.6.5 1.3 1.2 1.7c.7.8 1.3 1 2.2 1.6m1.1-4.7.5 1.2.7 1 1 1c.6.5 1 .6 1.6 1"/>
</g>
<path fill="#703d29" stroke-width=".1" d="M266.6 185.3c0-1.4-1.3-1.5-1.9-1.5-1.4 0-1.8 1-3.7 2a9.5 9.5 0 0 1-5.3 1.4 9 9 0 0 1-5.4-1.5c-1.9-1-2.2-1.9-3.6-1.9-.8 0-1.9.7-1.8 2v.7s.2 0 .2.2c0-.7.1-1 .4-1.4a1.8 1.8 0 0 1 1.3-.7c1.5 0 2 1 3.9 2a9.5 9.5 0 0 0 5.3 1.5c2 0 3.1-.3 5.4-1.5 1.9-1 2.4-2 3.9-2 .5 0 .8.3 1 .8v.7h.2c0-.1.2-.2.1-.8z"/>
</g>
<g fill="#703d29">
<path d="M211.5 299.2c.4-.4.8-.3.8-.5l-.2-.2-.7-.2-.7-.3s-.3-.2-.4 0c0 .3.9.3.5 1.1 0 .2-.1.5-.6 1l-2.1 2.3-.2.2V299l.1-1.4c.2-.4.6 0 .7-.3 0-.2 0-.2-.2-.3-.2 0-.4 0-1-.3l-.7-.3c-.1 0-.4-.2-.5 0l.1.2c.3.2.4.3.4.7v6c0 .4.1.6.2.6l.3-.2 4.2-4.6z"/>
<path d="M214 300.1c.3-.8.8-.3.9-.6l-.3-.2-1-.3-1-.4h-.3c0 .4 1 .4.7 1.3l-1.4 4.4c-.3.8-.8.4-.9.7v.1l1 .3 1.2.4h.3c.1-.3-1-.2-.6-1.3zm3 1c.1-.6.4-.6.7-.5.8.3 1 1.1.8 2-.2.5-.4 1-1.6.7-.3-.1-.6-.2-.5-.4zm-2.3 3.9c-.4 1.1-1 .6-1 1l.2.1 1.3.4.7.2h.3c0-.4-.9-.2-.6-1.2l.5-1.6c0-.3 0-.4.5-.3.4.2.5.3.6.7l.3 1.7c0 .6.2 1.3.8 1.4.3.2 1 .1 1-.2v-.1h-.3l-.3-.3-.5-3 .6-.2c.3-.2.6-.4.8-1 .1-.4.3-1.7-1.5-2.3l-1.6-.4-1-.3h-.2c-.1.4.9.3.6 1.3l-1.2 4zm6.7 2c-.2 1-1 .4-1.2.7 0 .2.1.3.3.3l1.2.2 1.1.4.5-.1c0-.3-1.1-.3-.8-1.4l1-4.2c0-.5.2-.5.5-.4l.7.2c1 .2.5 1.1.8 1.2.3 0 .2-.3.3-.5v-1.1l-2.6-.6-2.5-.6c-.2 0-.2 0-.2.2l-.5 1.2v.3c.5.1.5-1.2 1.4-1l.7.2c.4.1.5.2.4.6l-1 4.3zm10.2-2.7c.3-.5.7-.4.7-.6l-.3-.2h-.7l-.7-.2c-.1 0-.4-.1-.4 0 0 .4.9.2.7 1 0 .2-.1.6-.5 1.1l-1.7 2.7-.1.2v-.3l-.6-3.2a4.3 4.3 0 0 1-.1-1.3c0-.4.5-.2.6-.5l-.3-.2-1-.1-.8-.2c-.1 0-.4-.1-.4 0l.1.2c.4.2.5.3.5.7l1.1 5.9c.1.4.2.5.3.5l.2-.2zm.5 5.4.1.4 1.4.6c1.1.2 2-.5 2.3-1.7.2-1.2-.3-1.7-1.2-2.3-1-.8-1.5-1-1.3-1.6 0-.6.5-1 1-.8 1.5.2 1.4 2 1.6 2 .1 0 .2 0 .2-.3l.1-1.3v-.3h-.5c-.3 0-.5-.4-1.2-.5-1-.2-1.8.5-2 1.6-.2 1 .2 1.4 1 1.9 1.2.9 1.7 1 1.6 1.9-.2.7-.8 1.1-1.4 1-1-.2-1.3-1.1-1.5-2l-.1-.3c-.2 0-.2.3-.2.4v1.3zm12.6-3.5c.3-.6.6-.5.7-.7 0-.2-.2-.2-.3-.2h-.8l-.7-.1-.4.1c0 .4 1 0 .8 1 0 .1 0 .5-.3 1l-1.4 2.9-.2.2v-.2l-1-3.2a4.3 4.3 0 0 1-.2-1.3c0-.4.6-.3.6-.5s0-.2-.3-.2h-1l-.8-.1c-.1 0-.4-.1-.4 0l.1.2c.4.2.5.3.6.6l1.7 5.8c.1.4.2.5.3.5l.2-.3z"/>
<path d="M246 310.8c0 1-.8.8-.8 1.2h1l1 .1.4-.1c0-.5-1.1.2-1.1-1.7v-3.4s.2 0 .3.2l4 5h.3v-.2l.1-5.3c0-1 .8-.8.8-1.1l-.2-.1h-2v.1c0 .3 1 .2 1 1v3.2l-.1.4-.3-.3-3.4-4.2c-.1-.2 0-.3-.3-.3h-1.4l-.1.2c0 .4 1-.2.9 1.7v3.6zm8.4-4.3c0-1 .6-.6.6-.9l-.3-.1h-2.3c0 .4.9.1.9 1v4.6c0 1-.6.7-.6 1v.1h2.3l.3-.1c0-.3-1 .1-1-1zm3.6 4.4c0 1.2-1 .7-1 1 0 .3.2.3.3.3h2.4c.3 0 .5 0 .5-.2 0-.3-1.1 0-1.1-1.2v-4.3c0-.5 0-.5.3-.5h.8c1 0 .7 1 1 1 .3 0 .2-.4.2-.5l-.1-.9s0-.2-.2-.2H256c-.2 0-.2.2-.2.3l-.1 1.2.1.4c.4 0 .1-1.3 1.1-1.3h.7c.4 0 .5 0 .5.5v4.4zm5-1.8h-.3v-.4l.6-1.8h.1l1 1.7v.3l-.2.1zm1.5.4c.2 0 .3 0 .6.8l.2.6c0 .6-.6.6-.6.8 0 .2.2.1.3.1h1l1-.1c.3 0 .4 0 .4-.2 0-.3-.5.1-.8-.6l-2.8-5.6-.2-.3-.2.4-1.9 5.9c-.2.5-.6.5-.6.7 0 .2.2.1.3.1h1.5c.2-.1.5 0 .5-.3 0-.2-1 0-1-.7l.1-.7c.2-.7.3-.7.5-.7zm6.6-4c0-.6 0-.6 1-.7 1.6-.3 1.1 1 1.5.9.2 0 .1-.4.1-.5l-.1-1h-.2l-2 .2-2.2.3c-.2 0-.2 0-.2.2 0 .3 1 0 1 .8l.6 4.4c.2 1.2-.5.7-.5 1.2h.2l1.1-.1 1-.1c.2 0 .4 0 .4-.2 0-.3-1 0-1.1-1l-.2-1.4c0-.5-.1-.6.3-.7h.6c.9-.2.8.9 1 .8.3 0 .2-.3.1-.5l-.2-1.6c0-.3-.2-.3-.2-.3-.2 0-.1 1-.8 1l-.6.1c-.4 0-.4 0-.4-.4zm3.2 2.2c.3 2 1.7 3 3.4 2.7 2.7-.5 2.8-3 2.5-4.2-.3-2-1.8-3-3.5-2.7-2 .4-2.8 2.2-2.4 4.2m.9-.7c-.3-1.4 0-2.7 1.4-3 1-.3 2.3.6 2.7 2.7.3 1.6 0 3-1.4 3.2-1.5.3-2.4-1.5-2.7-2.9m6.7-3.3c-.2-.6.1-.7.4-.7.8-.2 1.5.3 1.7 1.3.1.5.2 1-1 1.3-.3.1-.6.2-.7 0l-.4-2zm0 4.5c.3 1.2-.5 1-.4 1.3 0 .2.2.2.3.1l1.3-.3.7-.1c.2 0 .2-.2.2-.2 0-.4-.8.2-1-.8l-.4-1.6c-.1-.3-.2-.4.3-.5.4 0 .6 0 .9.3l1 1.3c.4.5.8 1 1.5.9.3-.1.8-.5.7-.7 0-.1 0-.2-.1-.1h-.6l-2-2.3.5-.5c.1-.3.3-.7.2-1.3-.1-.4-.6-1.7-2.5-1.2l-1.6.4-1 .2-.2.2c.1.4 1-.2 1.2.8zm6.9-1.5c.3 1-.8.9-.7 1.2 0 .2.2.2.3.2l1.2-.4 1.2-.3c.2 0 .4 0 .3-.2 0-.3-1 .2-1.3-.9l-1.1-4.2c-.1-.4 0-.5.3-.6l.7-.2c1-.3 1 .8 1.3.8.2 0 0-.4 0-.5l-.4-.9s0-.2-.2-.2l-2.5.7-2.5.7c-.2 0-.1.1-.1.2l.2 1.3c0 .1 0 .3.2.3.3-.1-.2-1.3.7-1.5l.7-.2c.3 0 .4 0 .6.4l1 4.3zm4.4-5.9c-.3-.9.4-.7.3-1h-.3c-.4 0-.7.2-1 .3l-1 .2s-.3 0-.2.2c0 .3 1-.2 1.2.6l1.2 4.4c.2 1-.4.8-.3 1.2l1-.2 1.3-.3c.2-.1.2-.2.2-.3 0-.3-.9.4-1.2-.7zm1.8 2.1c.6 1.9 2.1 2.7 3.8 2.2 2.6-.9 2.3-3.3 1.9-4.5-.6-2-2.3-2.7-3.8-2.2-2 .7-2.6 2.6-1.9 4.5m.8-.8c-.4-1.3-.4-2.7 1-3.2 1-.4 2.3.3 3 2.4.5 1.5.5 2.8-1 3.3-1.4.5-2.5-1.2-3-2.5m6.1-4.3c-.2-.6 0-.7.4-.8.8-.3 1.5.2 1.8 1 .2.6.4 1-.8 1.6-.3 0-.6.2-.7 0zm.7 4.5c.4 1-.4 1-.2 1.3 0 .2.2.1.3 0 .4 0 .8-.3 1.2-.4l.7-.3c.2 0 .2-.1.2-.2-.1-.3-.8.3-1.2-.6l-.6-1.5c0-.4-.2-.4.3-.6.4-.1.5-.1.9.2l1.2 1.2c.5.4 1 .8 1.6.6.3-.1.7-.5.6-.8 0 0 0-.1-.1 0h-.6l-2.2-2 .3-.5c.1-.3.2-.7 0-1.3-.2-.4-.8-1.6-2.6-.9l-1.6.7-1 .3v.2c.1.3.8-.4 1.2.6z"/>
</g>
<g fill="#fedf00" transform="translate(0 76.8)scale(.512)">
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13.2 13.2 0 0 1-3.8 4c-2 1.2-4 1.8-6 1.6-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6-1.4 1-1.4 2.8-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6.3 6.3 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5-2.3-.2-4 1-5 3-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a8.8 8.8 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8 4.9 0 7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
</g>
<g fill="#d52b1e">
<path fill="#fedf00" d="M257.8 204.4H300v42h-42z"/>
<path d="M263.7 204.4h6.3v42h-6.3zm12 0h6.3v42h-6.2zm12 0h6.3v42h-6.2z"/>
</g>
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
<path fill="#fedf00" stroke="none" d="M211.4 282.8c.2.8.4 2 1.1 3.4.8 1.2.5 1.2 2.2 3a13.8 13.8 0 0 0 6.7 3.6c3.4 1 5.7 1 8.5.9 2.2-.1 3.9-.4 5.3-.6 2-.2 3.4-.4 5.7-.5a32.4 32.4 0 0 1 3.1 0c1.2 0 2.4.3 3.7.5 2.8.6 5.6 1.7 5.6 1.7v-43.9h-42v30z"/>
<path stroke-width=".3" d="m216.3 290.5 2 1.2 2.7 1v-41.8h-4.7zm23.4 2v-41.6H235v42.2l4.7-.5zm9.3-41.6h-4.6v41.7a31 31 0 0 1 4.6.8zm-18.6 0v42.7h-4.7v-42.7z"/>
</g>
<g transform="translate(0 76.8)scale(.512)">
<path fill="#fedf00" d="M585.5 402.4a20.8 20.8 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26.3 26.3 0 0 1-13 7 51.8 51.8 0 0 1-16.6 1.6c-4.3-.2-7.5-.7-10.3-1-3.8-.6-6.7-.9-11-1a62.9 62.9 0 0 0-6.2 0 83.3 83.3 0 0 0-18.3 4.2V340h82.2v58.5z"/>
<g id="ad-b">
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8c-.4.4-.7.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1-.1.4-.3.6-.6 1-.4.4-.7.5-1 1l-1.2 1-.3.1h-.6c-.4.2-.5.6-.8.8l.3.6.8 1.4c.2.3.2.7.5.8.5.2.9.2 1.3.1.8.2 1.3.2 2 .5l1.5.8c.5.3.8.4 1.3.5h1.8v.3l2 1a1.7 1.7 0 0 0-.1.4c-.1.3-.2.7-.1.8.6 1.9 1.2 3 1.5 3.2.6.2.8.9 1.1 1.5l-.3.3c-.6.6-1.2 1-1.7 1.8-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4c.4.7.6 1.2.8 2 .2.7.3 1.2.3 2l1 .3.7-.6.6-1.2v-1c-.2-.1-.3-.4-.2-.7 0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9 0-.2 0-.4.4-.7l2-1.8c.2.1.6.2 1 .1l1.3.4c.6.2.9 0 1.2 0h.4l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7c0 .4-.2.7-.5 1-.2.4-.6.4-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5c.4-.2.8 0 .9-.1.2-.3 0-.4 0-.8 0-.6-.2-1-.3-1.6a11.8 11.8 0 0 1-.1-2.8c0-.6 0-1 .2-1.5.1-1 .4-1.4.6-2.2.3-1 .3-1.6.4-2.5a24.4 24.4 0 0 0 10.1-.6c.8.7 1.7 1.2 2.7 1.6v1c0 .3 0 .4.2.7l.3.3c.3 0 .5 0 .7-.2.2-.2.2-.4.2-.7v-.7h1.8v1.1c.1.3.3.4.5.4a.7.7 0 0 0 .6 0c.3-.2.2-.6.3-1v-.7l1-.4a5.1 5.1 0 0 1 0 .9l-.3.9c-.2.6-.5.8-.8 1.4-.4.6-.5 1-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2c0-.3 0-.6.3-.8.2-.3.4-.3.7-.4.4 0 .8 0 1-.2.4-.3.4-1 .7-1.5a12.7 12.7 0 0 1 3-3.9l1.7-1.4c.2-.4.5-.5.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9.1.9-.1 1.4-.3 2.3-.1.8-.1 1.3-.5 2a3.8 3.8 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7.4-.1.7 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6-.5-.7-.7-1-.7-1.5V362a42.7 42.7 0 0 1 0-2.8l.4-.2c1.2-.7 1.7-.9 2.4-2.5a3.4 3.4 0 0 0 .3-1.5v-1l-.4-1a3.2 3.2 0 0 0-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1a67 67 0 0 1-3.8.2l-2.2-.7c.9-.3 1.1-.5 1.5-1 .3-.4.2-.7.6-1.1l.7-1a2.2 2.2 0 0 0-.9-.4h-1a3 3 0 0 0-1.2.3l-.8.6-2.2-1.2a8.8 8.8 0 0 0-3-.9zm2 11.8"/>
<g fill="none" stroke="#fedf00" stroke-linecap="round">
<path d="m568.8 359.5-.8.3c-.9.4-1.6.4-2.6.5-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6a9.3 9.3 0 0 1-1.7.2l.5-1s-1.2.3-2 .3a7.5 7.5 0 0 1-1.6-.2l1-1-1.3-.2a4 4 0 0 1-1-.7 20.5 20.5 0 0 0 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3.8-.3 1-1 1.1-1.8.1-.8-.4-1.4-.8-1.8-.1 0-.5-.3-1.1-.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6c-.5 0-.9 0-1.3.3-.5.3-.6.7-1 1.1.5.1.8.4 1.2.3.4 0 .5-.2.8-.5.3-.4.4-.7.4-1.2z"/>
<path d="M536 363.8a13.6 13.6 0 0 0 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8.2 8.2 0 0 0 2.7-1.8"/>
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2c.3 0 .5 0 .7.2.2 0 .2.1.3.3 0 .2.2.3.1.5 0 .2-.3.4-.6.4-.2 0-.4 0-.5-.3a.5.5 0 0 1 0-.4 1 1 0 0 1-.9 0 1 1 0 0 1-.6-.5z"/>
</g>
<path fill="#0065bd" d="m525.1 364.2-2-.9c.4-.2.7-.2 1-.5.3-.4.3-.8.5-1.3s.2-1 .7-1.4c.3-.2.8-.2 1.1-.1.4 0 .8.4.9.7 0 .6-.2 1-.3 1.5 0 .6-.3.9-.2 1.4 0 .4.2.6.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2c-.4-.4-.4-.8-.6-1.2a4 4 0 0 1-.3-1.2v-2c0-.3 0-.6-.2-.9 0-.2-.4-.3-.3-.4 0-.1.3 0 .4 0 .4 0 .6.1 1 .4.3.3.5.6.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9.2 9.2 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2-.8 1.3-1.7 2.3-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a8.7 8.7 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2a8.4 8.4 0 0 1-1.3 0h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1c0 .1.1.3 0 .4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8c-.6-.3-1-1-.6-1.6.1-.2.4-.2.6-.4.2-.3.1-.5 0-.8l-.1-1-.2-1c0-.6 0-1 .4-1.6.2-.3.7-.6.8-.6.2.1 0 .5 0 .8 0 .5.1.7.3 1.2l.7 1.3c.2.6.4.8.4 1.4 0 .5 0 .7-.2 1.2a2 2 0 0 1-.6.8 2 2 0 0 1-.8.4 1.1 1.1 0 0 1-.6 0z"/>
</g>
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
</g>
<path fill="none" stroke="#703d29" stroke-width=".4" d="M211.3 204.4h42v42h-42zm46.5 0H300v42h-42zm-46.4 78.4c.2.8.4 2 1.1 3.4.8 1.2.5 1.2 2.2 3a13.8 13.8 0 0 0 6.7 3.6c3.4 1 5.7 1 8.5.9 2.2-.1 3.9-.4 5.3-.6 2-.2 3.4-.4 5.7-.5a32.4 32.4 0 0 1 3.1 0c1.2 0 2.4.3 3.7.5 2.8.6 5.6 1.7 5.6 1.7v-43.9h-42v30zm88.4 0c-.1.8-.4 2-1.1 3.4-.8 1.2-.5 1.2-2.2 3a13.8 13.8 0 0 1-6.7 3.6 26.1 26.1 0 0 1-8.5.9c-2.2-.1-3.9-.4-5.3-.6a55.6 55.6 0 0 0-5.6-.5 32.4 32.4 0 0 0-3.2 0c-1.2 0-2.4.3-3.7.5-2.8.6-5.7 1.7-5.7 1.7v-43.9H300v30z"/>
</svg>

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 512 512">
<path fill="#00732f" d="M0 0h512v170.7H0z"/>
<path fill="#fff" d="M0 170.7h512v170.6H0z"/>
<path fill="#000001" d="M0 341.3h512V512H0z"/>
<path fill="red" d="M0 0h180v512H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@@ -0,0 +1,81 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 512 512">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="#000001" d="M0 0h512v512H0z"/>
<path fill="#090" d="M341.3 0H512v512H341.3z"/>
<path fill="#bf0000" d="M170.7 0h170.6v512H170.7z"/>
</g>
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(2.2 86.8)scale(.84611)">
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2-.8 0-1.7-.6-1.9-1.3-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4-.3.8-1.1 1.5-2 1.6-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6c0 2 .7 3.1 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
<path d="M315.4 210.3h1v7.1h-1z"/>
<g id="af-a">
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7 0 2 .2 3.9 0 5.8-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6.1 6.1 0 0 0-2.4-1.8 5.3 5.3 0 0 1-2.4-1.5 3.6 3.6 0 0 1-.8-1.5 5.9 5.9 0 0 1 0-2l-.3.3c-2.3 1.6-4 5.9-4 8.7a28.5 28.5 0 0 0 0 2.3c.2.5.3 1 .6 1.3l1.1.8 2.7.7a7.1 7.1 0 0 1 2.6 2 10.5 10.5 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2 1.8.7 3.7 3 4.5 4.8"/>
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3-.7 2-1 4.3-.6 6.1 0 .7.3 1.1.5 1.5.2.3.4.5.6.5.3 0 .6 0 .7-.3l.2-.8c-.1-2-.1-3.8.3-5.4a7.7 7.7 0 0 1 3-4.4c.3-.2.4-.5.5-.7a1 1 0 0 0-.3-.7c-.4-.3-1-.4-1.5-.1m.2.4c.4-.2.8 0 1 .1l.1.2c0 .1 0 .2-.3.4a8.2 8.2 0 0 0-3.1 4.6 16.7 16.7 0 0 0-.3 5.6 1 1 0 0 1-.2.6s0 .1-.2 0c0 0-.2 0-.4-.3a3.9 3.9 0 0 1-.4-1.2c-.3-1.8 0-4 .7-6 .7-1.8 1.8-3.4 3-4z"/>
<path fill="#bd6b00" stroke="none" d="M295.2 157.7c-1.5.7-2.5 2.3-3 4.2a13.6 13.6 0 0 0-.3 5.9c.2 1.3 1 2 1.6 2 .3.1.6 0 .8-.3.2-.3.3-.6.2-1-.4-1.6-.5-3.4-.3-5.1.3-1.7 1-3.2 2.2-4.1.3-.3.5-.5.5-.8a.8.8 0 0 0-.2-.6c-.4-.3-1-.4-1.5-.2m.2.5c.4-.2.8-.1 1 0l.1.3-.3.4a6.5 6.5 0 0 0-2.4 4.4c-.3 1.8-.1 3.7.2 5.2.1.4 0 .6 0 .8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7.5-1.8 1.5-3.3 2.8-3.8"/>
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17.4 17.4 0 0 0-4.9 6z"/>
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1.4-1.7.5-4.6 2-5.2 1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a14.6 14.6 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7-1.2-1.1-2.5-2.7-4-3.8a17.5 17.5 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
<path stroke="none" d="M220.6 183c-1.2-1.4-.9-1.8 1-1.9 1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5 3.6.7 6.5 2.5 8.9 5.3a94.4 94.4 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5.3 5.3 0 0 1 0-3.4 7.5 7.5 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4.6.9.7 2.8.2 3.7-.6 1.3-.4 3-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1 1.1-1.1 2.4.4 1.2 1.2-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4-.4-2.2-2-.8-1.3.9.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5.4-2 .4-3.6 0-5.3-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a9.9 9.9 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a8.6 8.6 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1.1 1.1 0 0 1 .7-1.2 33.4 33.4 0 0 1 2.6-.8c1-.3 1.6.4 1.6.9v2c0 .7-.2.8-.7.9-.7.1-1.7.2-2.4.7-.6.4-1.2.1-1.5-.5zm10.6 0c0-.6-.2-1.1-.6-1.2a5.4 5.4 0 0 0-2.4-.4c-1 0-1.1.2-1.1.6v2.1c0 .8 0 .8.4 1 .7 0 1.8 0 2.5.6.5.3 1 0 1.1-.6z"/>
</g>
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
<g stroke="none">
<path d="M328.5 286.6c0 1.2.2 2.2 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a6.5 6.5 0 0 0-5-2.9zm3.8-21.7c-1.3-.5-2.7 0-4 1.4-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6 0-2 1.5-3.4 2.4-3.5 2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a66.5 66.5 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2.3 2.3 0 0 0-1.5 0c-.5.1-1 .4-1.3.7a115.5 115.5 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1.5-.2 1-.5 1.3-1a1 1 0 0 0 .1-.8 2 2 0 0 0-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4-.3-.5-.9-.6-1.5-.5zm0 .5c.5 0 1 0 1.1.3 3 4.3 7.7 7 11.9 10.5l.4.7a.5.5 0 0 1 0 .4c-.1.3-.6.6-1 .7-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4 4.2-3.2 8.2-6.8 11.8-10.4a2.5 2.5 0 0 1 1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3a6.7 6.7 0 0 1 1.5-.4z"/>
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a6.6 6.6 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7-.5.3-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8-.3 0-.6-.4-.4-.7zm4.3.3a8.3 8.3 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7c-.3.2-.1.7.1.8.6.2 1.5.2 2 0 .6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a33.7 33.7 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5-1.8.2-3.2 0-4.8.6-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2c.6-.5 1-1.3 1.5-1.8.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3 1.3 0 2.4 0 2.4-1.2 0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3a75.6 75.6 0 0 0-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47.1 47.1 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17.2 17.2 0 0 0-5-6.3"/>
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3-1.2 2.6-3.7 3.6-7.5 3.2"/>
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1.2 1.2 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6-.5.8-1 .7-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4a614.3 614.3 0 0 1-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a5.5 5.5 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3-.3-.3-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3-.2.6 0 1.2-.1 1.6-.2.2-.6.4-1 .2-.2-.2 0-.7-.6-1-.2 0-.6-.1-.8.2-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2-.9 0-3-2.7-3.7-4-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2a7 7 0 0 0-1.5 1.3m4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a3.7 3.7 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a3.5 3.5 0 0 0-1.2 2c0 .2.3.6.5.5a3.2 3.2 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4-.4-1-1.5-1-1.7-.7-1 1.3.8 1 .5 1.4-.5 1-1 1.6-1.3 2.6-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4.8-2-1.9-5.9-2.7-8z"/>
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25.4 25.4 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3 1.1-.7 1.7-.6 2.4-.4.3.1.4.5.2.7"/>
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2-.6.9-.9 2-1.7 2-.7 0-2-1.5-1.3-1.5 2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7 0 .8-.5 1.7-1.3 1.6-.4-.1.2-1.9-.2-2.4a.5.5 0 0 0-.7 0c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5c-.2.9-.2 1.6-.2 2.3 0 .5 1 .2 1 .1 0-.8.2-2 0-2.3-.2-.1-.7-.3-.8-.1"/>
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6c4.9 0 9.3 1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
</g>
</g>
<path fill="#fff" d="m249 299.7-.1 2.2h-.4v-1.5a7.4 7.4 0 0 0-.4-1.3 5.8 5.8 0 0 0-.5-1 11.3 11.3 0 0 0-.8-1.1l.7-1.8a5.3 5.3 0 0 1 1.1 2 7.5 7.5 0 0 1 .5 2.5m5.5-3.4c0 .6-.1 1-.3 1.2-.2.3-.6.5-1 .6l.2 1.1a5.3 5.3 0 0 1 0 1.7v1h-.4v-1a4.4 4.4 0 0 0-.2-.8 28.8 28.8 0 0 0-.3-.8 8.4 8.4 0 0 0-.6-1.2l-.8-1.3.5-1.6.8.9.7.2c.7 0 1-.3 1-1h.3a8 8 0 0 0 0 .5v.5m5.1 3.9-.4 1.7-.6-.6a3.5 3.5 0 0 1-.3-1 9.9 9.9 0 0 1 0-1.4 3 3 0 0 1-.9.1c-.4 0-.7 0-1-.3a1 1 0 0 1-.4-.8c0-.7.2-1.3.6-1.8.3-.6.7-.9 1.2-.9.3 0 .6.1.7.3l.3.8v1.6c0 .7 0 1.2.2 1.4 0 .3.3.5.6.9m-1.5-2.9c0-.4-.3-.6-.7-.6a.8.8 0 0 0-.4.1c-.2.1-.2.2-.2.3 0 .2.2.3.8.3a2.2 2.2 0 0 0 .5 0m6.9 2.3-.2 2.1c-.4-.3-.8-.8-1.1-1.5a20 20 0 0 1-1.1-3.3 41.3 41.3 0 0 1-.8 3l-.6 1.3a2 2 0 0 1-.6.6v-2l.8-1.2a6 6 0 0 0 .6-1.4 16 16 0 0 0 .3-2h.4l.7 2a6.7 6.7 0 0 0 1.6 2.4"/>
<path fill="#bf0000" d="M280.5 319.2c.3.3.5.6.6 1l.2 1.2h-.6a6.2 6.2 0 0 0-.7-1.1 15.2 15.2 0 0 0-1-1l-1.3-1.2a27.3 27.3 0 0 0-1.6-1.3l-.5-.4-.2-.6a9 9 0 0 1-.1-1.3l2.1 1.7a35.3 35.3 0 0 1 2 1.8zm-7.6-4.6-.1 1.6-2.5-.1.2-1.6h2.4m6.7 7.1-6 1.9-1.2-1.6 5.2-1.5a6.3 6.3 0 0 0-.5-.7l-.7-.5a1.1 1.1 0 0 1-.4.8 2 2 0 0 1-.8.5 2.7 2.7 0 0 1-1.4 0c-.5 0-.8-.3-1-.6a3.1 3.1 0 0 1-.5-1.7c0-.8.2-1.3.6-1.5.6-.2 1.4 0 2.5.5a6.5 6.5 0 0 1 2.4 2zm-4.7-3.2a3.1 3.1 0 0 0-.6-.2.9.9 0 0 0-.5 0 .5.5 0 0 0-.4.3.4.4 0 0 0 0 .4l.4.2h.5a.9.9 0 0 0 .3-.3zm-6.4-1.2-.4 1.6-2.5-.3.4-1.5zm6 6-1.4.4a4.2 4.2 0 0 1-1.4 0 2.8 2.8 0 0 1-1.2-.3c-.2.4-.6.7-1.1 1a5.9 5.9 0 0 1-1.3.4l-1 .3-.8-1.6 1-.2 1-.3.6-.4a4.7 4.7 0 0 0-.7-.4 1 1 0 0 0-.6-.1.3.3 0 0 0-.2 0 .5.5 0 0 0 0 .3h-.5c-.4-.7-.5-1.2-.3-1.6.3-.4.8-.7 1.6-.9.8-.2 1.5-.2 2.1 0 .6 0 1 .3 1.2.6.1.2.2.4.1.6 0 .2 0 .5-.3 1a1.6 1.6 0 0 0 1 0l1.3-.3zm-6.4 1.5-1.3.2c-.7 0-1.3 0-1.8-.4a4.3 4.3 0 0 1-1.3-2l-.6-1.7a2 2 0 0 0-.6-1l-.8-.3.5-1.7 1.1.9.8 1.3.4 1.2a5 5 0 0 0 1 1.7c.2.3.4.4.7.3l1.3-.2zm-5.5-6-.9 1.5-2.3-.6.8-1.5zm1.4 6.7-6 .5-.3-1.6 5-.5a1.9 1.9 0 0 0-.6-.7 6 6 0 0 0-.8-.5l.5-1.5c.5.3 1 .6 1.2 1 .2.4.5 1 .6 1.7zm-4.8.8a13 13 0 0 1-1.8-.2 8.3 8.3 0 0 1-1.3-.4 4.5 4.5 0 0 1-1 .3h-3c-.5 0-.8 0-1-.2l-.6-.8a3.3 3.3 0 0 1-1.3.7 4 4 0 0 1-1.3.2h-1.4l.2-1.8 1.3.1c.7 0 1.3 0 1.7-.3.6-.3 1-.8 1-1.4h.6a22.9 22.9 0 0 0-.1 1c0 .3 0 .5.3.6l.7.2h2.9c.4-.2.6-.5.7-1l.1-.3a2.6 2.6 0 0 1 .4-.2l.4-.1v.6l-.3.8a6.4 6.4 0 0 0 1.7.4c0-.1 0-.3-.2-.5 0-.3-.2-.4-.2-.5a.4.4 0 0 1 .1-.2l.3-.2.8-.7.3.7c0 .2.1.5 0 .8l-.1 2.4m-9-7-1.5 1-1.1-.6-1.1.8-1.5-.9 1.4-1 1.2.7 1.1-.9zm-2.4 6.4-5.8-1 .7-1.6 4.8.8a1.3 1.3 0 0 0 0-.8 4 4 0 0 0-.5-.6l1.3-1.3c.3.4.5.8.5 1.2 0 .4 0 1-.4 1.7zm-4.9-.8-1.2-.3c-.7-.1-1.1-.4-1.2-.9-.1-.5.1-1.2.7-2.2l1-1.7.2-.9-.3-.6 1.8-1.2.2 1.1c0 .4-.2.9-.6 1.4l-.6 1.2a4 4 0 0 0-.7 1.7c0 .3.1.5.4.5l1.2.3zm-3-6.3-2 .9-1.4-1.4 2-.8zm-.9 5.3a4 4 0 0 1-1.2 1.1c-.4.3-.9.4-1.4.5a7 7 0 0 1-1.9 0 11.8 11.8 0 0 1-2.2-.6 6 6 0 0 1-2.7-1.6c-.5-.6-.5-1.2 0-1.8a5.6 5.6 0 0 1 1.5-1.3 18.8 18.8 0 0 1 3-1.2l.4.4c-1 .4-1.8.7-2.2 1a3.3 3.3 0 0 0-1 .7c-.3.4-.3.8.1 1.3a8.4 8.4 0 0 0 5 1.8c1 0 1.6-.3 1.9-.6l.4-.7.1-1.4 2-1.2-.1 1.2c-.1.4-.4.8-.8 1.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 512 512">
<defs>
<clipPath id="ag-a">
<path fill="#25ff01" d="M109 47.6h464.8v464.9H109z"/>
</clipPath>
</defs>
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(-120 -52.4)scale(1.1014)">
<path fill="#fff" d="M0 47.6h693V512H0z"/>
<path fill="#000001" d="M109 47.6h464.8v186.1H109z"/>
<path fill="#0072c6" d="M128.3 232.1h435.8v103.5H128.3z"/>
<path fill="#ce1126" d="M692.5 49.2v463.3H347zm-691.3 0v463.3h345.7z"/>
<path fill="#fcd116" d="m508.8 232.2-69.3-17.6 59-44.4-72.5 10.3 37.3-63-64.1 37.2 11.3-73.5-43.4 58-17.6-67.3-19.6 69.3-43.4-59 12.4 75.6-64.1-39.3 37.2 63-70.3-11.3 57.9 43.4-72.4 18.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ai" viewBox="0 0 512 512">
<defs>
<path id="ai-b" fill="#f90" d="M271 87c1.5 3.6 6.5 7.6 7.8 9.6-1.7 2-2 1.8-1.8 5.4 3-3.1 3-3.5 5-3 4.2 4.2.8 13.3-2.8 15.3-3.4 2.1-2.8 0-8 2.6 2.3 2 5.1-.3 7.4.3 1.2 1.5-.6 4.1.4 6.7 2-.2 1.8-4.3 2.2-5.8 1.5-5.4 10.4-9.1 10.8-14.1 1.9-.9 3.7-.3 6 1-1.1-4.6-4.9-4.6-5.9-6-2.4-3.7-4.5-7.8-9.6-9-3.8-.7-3.5.3-6-1.4-1.6-1.2-6.3-3.4-5.5-1.6"/>
</defs>
<clipPath id="ai-a">
<path d="M0 0v128h298.7v128H256zm256 0H128v298.7H0V256z"/>
</clipPath>
<path fill="#012169" d="M0 0h512v512H0z"/>
<path stroke="#fff" stroke-width="50" d="m0 0 256 256m0-256L0 256"/>
<path stroke="#c8102e" stroke-width="30" d="m0 0 256 256m0-256L0 256" clip-path="url(#ai-a)"/>
<path stroke="#fff" stroke-width="75" d="M128 0v298.7M0 128h298.7"/>
<path stroke="#c8102e" stroke-width="50" d="M128 0v298.7M0 128h298.7"/>
<path fill="#012169" d="M0 256h256V0h85.3v341.3H0z"/>
<path fill="#fff" d="M323.6 224.1c0 90.4 9.8 121.5 29.4 142.5a179.4 179.4 0 0 0 35 30 179.7 179.7 0 0 0 35-30c19.5-21 29.3-52.1 29.3-142.5-14.2 6.5-22.3 9.7-34 9.5a78.4 78.4 0 0 1-30.3-9.5 78.4 78.4 0 0 1-30.3 9.5c-11.7.2-19.8-3-34-9.5z"/>
<g transform="matrix(1.96 0 0 2.002 -141.1 95.2)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<g transform="matrix(-.916 -1.77 1.733 -.935 463.1 861.4)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<g transform="matrix(-1.01 1.716 -1.68 -1.031 825 -71)">
<use xlink:href="#ai-b"/>
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
</g>
<path fill="#9cf" d="M339.8 347.4a78 78 0 0 0 13.2 19.2 179.4 179.4 0 0 0 35 30 180 180 0 0 0 35-30 78 78 0 0 0 13.2-19.2z"/>
<path fill="#fdc301" d="M321 220.5c0 94.2 10.1 126.6 30.5 148.5a187 187 0 0 0 36.5 31 186.3 186.3 0 0 0 36.4-31.1C444.8 347 455 314.7 455 220.5c-14.8 6.8-23.3 10.1-35.5 10-11-.3-22.6-5.7-31.5-10-9 4.3-20.6 9.7-31.5 10-12.3.1-20.7-3.2-35.6-10zm4 5c13.9 6.5 21.9 9.6 33.4 9.4a76.4 76.4 0 0 0 29.6-9.4c8.4 4 19.3 9.2 29.6 9.4 11.5.2 19.4-3 33.4-9.4 0 89-9.6 119.6-28.8 140.2a176 176 0 0 1-34.2 29.4 175.6 175.6 0 0 1-34.3-29.4c-19.2-20.6-28.7-51.3-28.7-140.2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 512 512">
<path fill="red" d="M0 0h512v512H0z"/>
<path id="al-a" fill="#000001" d="M204.9 99.5c-5 0-13.2 1.6-13 5.4-14-2.3-15.4 3.4-14.6 8.5 1.4-2 3-3.1 4.2-3.3 1.9-.3 3.8.3 5.8 1.5a23 23 0 0 1 5 4.4c-4.8 1.1-8.6.4-12.4-.3a17.6 17.6 0 0 1-6.1-2.5c-1.6-1.1-2.1-2.1-4.6-4.7-2.9-3-6-2.1-5 2.5 2.2 4.3 6 6.3 10.7 7 2.2.4 5.6 1.2 9.4 1.2 3.8 0 8.1-.5 10.5 0-1.4.8-3 2.4-6.2 3-3.2.6-8-2-11-2.6.4 2.5 3.5 4.8 9.7 6 10.2 2.2 18.7 4 24.3 7 5.6 3 9.1 6.8 11.6 9.8 5 6 5.3 10.5 5.6 11.5 1 9.5-2.2 14.8-8.4 16.4-3 .8-8.5-.7-10.5-3-2-2.4-4-6.4-3.4-12.7.5-2.5 3.4-9 1-10.3a291.6 291.6 0 0 0-34.4-16c-2.7-1.1-5 2.5-5.8 4A53.5 53.5 0 0 1 129 107c-4.6-8.1-12.1 0-10.9 7.7 2.1 8.6 8.6 14.8 16.5 19.2 8 4.5 18.1 8.8 28.3 8.6 5.5 1 5.5 8.2-1.1 9.5-13 0-23.2-.2-32.9-9.6-7.4-6.7-11.5 1.3-9.4 5.8 3.6 14 23.6 18 43.8 13.4 7.8-1.3 3.1 7 .9 7.2-8.4 6-23.5 12-36.8-.1-6.1-4.7-10.2-.7-8 6 6 17.5 28.5 13.8 44 5.2 4-2.2 7.6 3 2.7 6.9-19.2 13.4-28.9 13.6-37.6 8.4-10.8-4.3-11.8 7.8-5.3 11.8 7.2 4.4 25.4 1 38.9-7.4 5.7-4.2 6 2.4 2.3 5-15.9 13.8-22.2 17.5-38.8 15.2-8.2-.6-8 9.5-1.6 13.5 8.8 5.4 26.1-3.6 39.5-14.7 5.6-3 6.6 2 3.8 7.8a57.4 57.4 0 0 1-23.3 19.2 29.1 29.1 0 0 1-19.5.7c-6.2-2.2-7 4.2-3.6 10 2 3.5 10.6 4.7 19.7 1.4 9.2-3.2 19-10.8 25.7-19.8 6-5.1 5.2 1.8 2.5 6.7-13.5 21.3-25.9 29.2-42.1 27.9-7.3-1.2-8.9 4.4-4.3 9.6 8 6.7 18.2 6.4 27-.2a751 751 0 0 0 30.8-32.6c5.5-4.4 7.3 0 5.7 9-1.5 5.1-5.2 10.5-15.3 14.5-7 4-1.8 9.4 3.4 9.5 2.9 0 8.7-3.3 13-8.3 5.9-6.5 6.2-11 9.5-21.1 3-5 8.4-2.7 8.4 2.5-2.6 10.2-4.8 12-10 16.2-5.1 4.7 3.4 6.3 6.3 4.4 8.3-5.6 11.3-12.8 14.1-19.4 2-4.8 7.8-2.5 5.1 5.3-6.4 18.5-17 25.8-35.5 29.6-1.9.3-3 1.4-2.4 3.6l7.5 7.5c-11.5 3.3-20.8 5.2-32.2 8.5L142 300.6c-1.5-3.4-2.2-8.7-10.4-5-5.7-2.6-8.2-1.6-11.4 1 4.5.1 6.5 1.3 8.3 3.4 2.3 6 7.6 6.6 13 5 3.5 2.9 5.4 5.2 9 8.2l-17.8-.6c-6.3-6.7-11.3-6.3-15.8-1-3.5.5-5 .5-7.3 4.7 3.7-1.5 6-2 7.7-.3 6.6 3.9 11 3 14.3 0l18.7 1.1c-2.3 2-5.6 3.1-8 5.2-9.7-2.8-14.7 1-16.4 8.8a18.2 18.2 0 0 0-1.4 10c1-3.2 2.5-5.9 5.3-7.6 8.6 2.2 11.8-1.3 12.3-6.5 4.2-3.4 10.5-4.1 14.6-7.6 4.9 1.6 7.2 2.6 12.1 4.1 1.7 5.3 5.7 7.4 12 6 7.7.3 6.3 3.4 7 5.9 2-3.6 2-7-2.8-10.3-1.7-4.6-5.5-6.7-10.4-4-4.7-1.3-5.9-3.2-10.5-4.6 11.7-3.7 20-4.5 31.8-8.3 3 2.8 5.2 4.8 8.2 7.2 1.6 1 3 1.2 4 0 7.3-10.6 10.6-20 17.4-27 2.6-2.9 6-6.8 9.6-7.8 1.8-.4 4-.2 5.5 1.4 1.4 1.6 2.6 4.4 2 8.7-.6 6.2-2 8.2-3.8 11.8-1.7 3.7-3.9 6-6 8.8-4.4 5.7-10.1 9-13.5 11.2-6.8 4.4-9.7 2.5-15 2.2-6.7.8-8.5 4.1-3 8.7a21 21 0 0 0 13.7 2.3c3.3-.6 7-4.8 9.8-7 3-3.6 8.1.6 4.7 4.7-6.3 7.5-12.6 12.4-20.3 12.3-8.2 1-6.7 5.7-1.3 7.9 9.8 4 18.6-3.5 23-8.5 3.5-3.7 6-3.9 5.3 2-3.4 10.5-8.1 14.6-15.7 15.1-6.2-.5-6.3 4.2-1.7 7.5 10.3 7 17.7-5 21.2-12.4 2.5-6.6 6.3-3.5 6.7 2 0 7.3-3.2 13.2-12 20.7 6.7 10.7 14.5 21.7 21.3 32.5l20.5-228.2-20.5-36c-2.1-2-9.3-10.5-11.2-11.7-.7-.7-1.1-1.2-.1-1.6 1-.4 3.2-.8 4.8-1-4.4-4.4-8-5.8-16.3-8.2 2-.8 4-.3 9.9-.6a32.3 32.3 0 0 0-14.4-11c4.5-3 5.3-3.3 9.8-7-7.7-.6-14.3-2-20.8-4a41 41 0 0 0-12.8-3.7m.7 9c4 0 6.6 1.4 6.6 3 0 1.7-2.5 3.1-6.6 3.1-4 0-6.6-1.5-6.6-3.2 0-1.7 2.6-3 6.6-3z"/>
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 512 0)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 512 512">
<path fill="#d90012" d="M0 0h512v170.7H0z"/>
<path fill="#0033a0" d="M0 170.7h512v170.6H0z"/>
<path fill="#f2a800" d="M0 341.3h512V512H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ao" viewBox="0 0 512 512">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="red" d="M0 0h512v259.8H0z"/>
<path fill="#000001" d="M0 252.2h512V512H0z"/>
</g>
<path fill="#ffec00" fill-rule="evenodd" d="M228.7 148.2c165.2 43.3 59 255.6-71.3 167.2l-8.8 13.6c76.7 54.6 152.6 10.6 174-46.4 22.2-58.8-7.6-141.5-92.6-150z"/>
<path fill="#ffec00" fill-rule="evenodd" d="m170 330.8 21.7 10.1-10.2 21.8-21.7-10.2zm149-99.5h24v24h-24zm-11.7-38.9 22.3-8.6 8.7 22.3-22.3 8.7zm-26-29.1 17.1-16.9 16.9 17-17 16.9zm-26.2-39.8 22.4 8.4-8.5 22.4-22.4-8.4zM316 270l22.3 8.9-9 22.2-22.2-8.9zm-69.9 70 22-9.3 9.5 22-22 9.4zm-39.5 2.8h24v24h-24zm41.3-116-20.3-15-20.3 14.6 8-23-20.3-15h24.5l8.5-22.6 7.8 22.7 24.7-.3-19.6 15.3z"/>
<path fill="#fe0" fill-rule="evenodd" d="M336 346.4c-1.2.4-6.2 12.4-9.7 18.2l3.7 1c13.6 4.8 20.4 9.2 26.2 17.5a7.9 7.9 0 0 0 10.2.7s2.8-1 6.4-5c3-4.5 2.2-8-1.4-11.1-11-8-22.9-14-35.4-21.3"/>
<path fill="#000001" fill-rule="evenodd" d="M365.3 372.8a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.6 0zm-21.4-13.6a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0m10.9 7a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0"/>
<path fill="#fe0" fill-rule="evenodd" d="M324.5 363.7c-42.6-24.3-87.3-50.5-130-74.8-18.7-11.7-19.6-33.4-7-49.9 1.2-2.3 2.8-1.8 3.4-.5 1.5 8 6 16.3 11.4 21.5A5288 5288 0 0 1 334 345.6c-3.4 5.8-6 12.3-9.5 18z"/>
<path fill="#ffec00" fill-rule="evenodd" d="m297.2 305.5 17.8 16-16 17.8-17.8-16z"/>
<path fill="none" stroke="#000" stroke-width="3" d="m331.5 348.8-125-75.5m109.6 58.1L274 304.1m18.2 42.7L249.3 322"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-aq" viewBox="0 0 512 512">
<path fill="#3a7dce" d="M0 0h512v512H0z"/>
<path fill="#fff" d="M107.7 240.9c-3.5-7.9-3.5-7.9-3.5-15.7-1.8 0-2.1.4-3.1 0-1-.3-1.4 7.3-4.7 5.8-.5-.7 2.4-6.2-.8-8.4-1-.8.3-5.3-.2-7.2 0 0-4 2.3-7-5.9-1.4-2.1-3.4 2-3.4 2s.9 2.5-.7 3c-2.3-1.8-3.9-.8-6.7-3.3-2.9-2.5.6-5.4-4.8-7.6 3.5-9.8 3.5-7.8 12.2-11.8-5.2-3.9-5.2-3.9-8.7-9.8-5.3-2-7-3.9-12.2-7.8-7-9.8-10.5-29.4-10.5-43.2 4.4-4.6 10.5 15.7 19.2 21.6l12.2 5.9c7 4 8.7 7.8 14 11.8l15.6 5.9c7 5.8 10.5 13.7 15.7 15.6 5.7 0 6.8-3.6 8.6-3.9 10.2-.5 15.5-2 17.5-5.5 2-2.8 7 1.6 21-4.3l-1.8-7.8s3.8-3.5 8.8-2c-.2-3.6-.5-13.1 4.4-17.5-3-3.5-1-6-1-6s2.8-3 3.2-4.6c-1.5-8.7 1.2-8.8 1.9-11.3.6-2.6-2.4-1.7-1.6-5.2.9-3.5 6-4.4 6.6-7.3.7-2.8-1.5-4.3-1.3-5 1-2.7.1-9.2 0-11.7 9.3-2.9 12.4-11.4 15.7-7.9 1.7-11.8 3.5-15.7 14-15.7 1.4-3.6-3.9-6.7-1.8-7.8 3.5-.5 6.1-.3 10.2 5.7 1.3 1.9 1.5-2.8 2.8-3.3 1.4-.5 4.5-.5 5-2.8.4-2.4 1.1-5.5 2.9-9.4 1.5-3.2 2.6 1.2 4 7.4 7.3.3 23.9 2.2 30.9 4.3 5.2 1.6 8.7-1.5 13.7-2.1 3.7 4.2 7.2 1 9.1 10 2.8 4.7 7.3.3 8.3 1.8 5.9 18 26 5.8 27.4 6.1 2.6 0 5.7 8.1 7.7 8 3.3-.7 2.4-3.2 5.2-2.2-.7 6.8 5.7 14.7 5.7 19.7 0 0 1.5.9 3-.6 1.4-1.5 2.7-5.4 4-5.3 3 .5 4.3 1 7.8 1.6 9.4 3.7 14.3 4.5 18 6.3 1.6 3.6 3.3 5.4 6.8 4.7 2.8 2.2.7 5 2.4 5.2 3.5-2 4.7-4.1 8.1-2.2 3.5 2 7 6 8.8 9.8 0 2-1.8 9.8 0 21.6.8 4 1.3 7 5 13.8-1 6.9 4.7 18.5 4.7 21.5 0 3.9-2.8 6-4.5 9.8 7 6 0 15.7-3.5 21.6 26.2 5.9 14 17.7 34.9 11.8-5.3 13.7-3.4 12.6 1.8 26.3-10.4 7.9-.2 10.3-7.2 20-.4.7 4.2 8.6 10.6 8.6-1.7 15.7-7 9.8-5.2 33.3-13.8-.3-8.2 17.6-17.5 15.7.6 11.3 5.3 12.2 3.5 23.6-7 2-7 2-10.4 7.8l-5.3-2c-1.7 9.9-5.2 11.8 0 21.6 0 0-6.7.3-8.7 0-.1 3.4 3 4.3 3.5 7.9-.3 1.4-10 7.6-17.4 7.8-2 4.9 5.2 10 4.8 12.5-8.2 1.7-11.8 13-11.8 13s4.2 2 3.5 4c-2.3-1.9-3.5-2-7-2-1.7.5-6-.1-10 7.6-4.5 1.7-6.6 1-10 6.1-1.5-4.8-3.7 0-6.3 2-2.7 1.8-6.2 6.4-6.7 6.2.1-1.3 1.6-6.2 1.6-6.2l-8.7 2h-1c-.8.1-.6-5.7-2.2-5.5-1.7.3-6.4 7.3-8 7.6-1.6.2-2.1-2.3-3.5-2-1.4.1-4.1 7.4-5 7.6-1 .2-5-4.4-8.3-3.8-17.2 6.8-19.9-13.4-22.6-2-3.6-2.1-3-.9-6.6.2-2.3.7-2.5-3.5-4.6-3.4-4.2.1-4 4.5-6.2 3.2-1.8-9.2-13-7.5-14.1-11.5-.9-4 4.8-4 6.7-6.8 1.4-4-1.5-5.5 4.3-9.4 7.4-5.7 3.1-7.8 4.4-12.1 2.4-6.2 2.4-7.7.4-13.2 0 0-5.8-17.6-7-17.6-3.4-1.1-3.4 6.5-8.5 8.6-10.5 3.9-29-10-32.2-10-3 .1-16.5 3.7-16-4-2 7.5-9.6 1.8-10 1.8-7 0-4.3 6-9 5.8-2.1-.8-23.6-2.2-23.6-2.2v4l-14-8-12.2-3.9c-10.4-3.9-5.2-13.7-22.6-7.8v-11.8h-8.7c3.4-23.5 0-11.7-1.8-33.3l-7 2c-7-10.7 9.7-8.6-5.2-15.8 0 0 .3-11.7-3.5-7.8-.7.5 1.8 5.9 1.8 5.9-14-2-17.5-5.9-17.5-21.6 0 0 11.5 1.9 10.5 0-1.6-3-3.8-22-3.4-23.3-.2-2.6 10.7-9.1 8.6-15.3 1.3-.6 5.3-.6 5.3-.6"/>
<path fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="2.5" d="M595.5 297.6c-.6 1.3-.5 2.6.1 3.6 1.1-1.7.2-2.4 0-3.6zm-476-149.4s-3-.4-2.4 2.3c1-2 2.3-2.2 2.4-2.3zm-.3-6.4c-1.7 0-3.8-.2-3 2.5 1-2.1 3-2.4 3-2.5zm12.7 36.3s2.6-.2 2 2.5c-1-2-2-2.4-2-2.5z" transform="matrix(.86021 0 0 .96774 -50 10)"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 512 512">
<path fill="#74acdf" d="M0 0h512v512H0z"/>
<path fill="#fff" d="M0 170.7h512v170.7H0z"/>
<g id="ar-c" transform="translate(-153.6)scale(1.024)">
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23-.8 8.5 3.8 13.2 5 16.5 1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8-.7-4.6-4.2-6-3.4-16.3.8-10.3-4.2-12.7-3-22"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
</g>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 256 256)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 256 256)"/>
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 256 256)"/>
<circle cx="256" cy="256" r="28.4" fill="#f6b40e" stroke="#85340a" stroke-width="1.5"/>
<path id="ar-h" fill="#843511" stroke-width="1" d="M265.7 250c-2 0-3.8.8-4.9 2.5 2.2 2 7 2.2 10.3-.2a7.5 7.5 0 0 0-5.4-2.4zm0 .4c1.9 0 3.6.8 3.9 1.7-2.2 2.4-5.7 2.2-7.9.4 1-1.5 2.5-2.1 4-2.1"/>
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(19.3)"/>
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
<path fill="#85340a" d="M251.6 260a2 2 0 1 0 2 3c.8.6 1.8.6 2.4.6h.3c.5 0 1.6 0 2.3-.6.4.5 1 .8 1.6.8a2 2 0 0 0 .4-3.9c.5.2.9.7.9 1.3a1.3 1.3 0 0 1-2.7 0 3 3 0 0 1-2.7 1.8 3.3 3.3 0 0 1-2.7-1.8c0 .7-.6 1.3-1.3 1.3a1.3 1.3 0 0 1-.4-2.6zm2.2 5.8c-2.2 0-3 2-5 3.3 1-.5 2-1.3 3.5-2.2 1.5-.9 2.8.2 3.7.2.9 0 2.2-1.1 3.7-.2 1.5.9 2.4 1.7 3.5 2.2-2-1.4-2.8-3.3-5-3.3a6 6 0 0 0-2.2.6c-1-.4-1.8-.6-2.2-.6"/>
<path fill="#85340a" d="M253 268.3c-.8 0-2 .3-3.6.8 4-1 4.8.4 6.6.4 1.7 0 2.6-1.3 6.6-.4-4.4-1.4-5.3-.5-6.6-.5-.9 0-1.5-.3-3-.3"/>
<path fill="#85340a" d="M249.6 269h-.8c4.6.5 2.4 3.1 7.2 3.1 4.8 0 2.6-2.6 7.2-3-4.8-.5-3.3 2.4-7.2 2.4-3.7 0-2.6-2.5-6.4-2.5"/>
<path fill="#85340a" d="M260 276.1a4 4 0 0 0-8 0 4 4 0 0 1 8 0"/>
<path id="ar-e" fill="#85340a" stroke-width="1" d="M238.3 249.9c5-4.4 11.4-5 14.9-1.8a8.6 8.6 0 0 1 1.6 3.7c.5 2.5-.3 5.2-2.3 8 .3 0 .7.1 1 .4 1.7-3.4 2.3-6.8 1.7-10l-.7-2.5c-4.8-4-11.4-4.4-16.2 2.2"/>
<path id="ar-d" fill="#85340a" stroke-width="1" d="M246.2 248.6c2.8 0 3.5.6 4.8 1.7 1.3 1.1 2 .9 2.2 1.1.2.2 0 .9-.4.7-.5-.3-1.4-.7-2.7-1.8-1.3-1-2.6-1-4-1-3.8 0-6 3.2-6.5 3-.4-.2 2.2-3.7 6.6-3.7"/>
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-19.6)"/>
<circle id="ar-f" cx="246.3" cy="252.1" r="2" fill="#85340a" stroke-width="1"/>
<path id="ar-g" fill="#85340a" stroke-width="1" d="M241 253.4c3.7 2.8 7.4 2.6 9.6 1.3 2.2-1.3 2.2-1.8 1.7-1.8-.4 0-.9.5-2.6 1.4-1.8.8-4.4.8-8.8-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,109 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" id="flag-icons-arab" viewBox="0 0 512 512">
<path fill="#006233" d="M0 0v512h512V0Z" class="arab-fil0 arab-str0"/>
<g fill="#fff" fill-rule="evenodd" stroke="#fff">
<path stroke-width=".4" d="M1071.9 2779.7c-25.9 38.9-7.2 64.2 19.5 66 17.6 1.3 54.2-24.9 54.1-55.7l-10-5.6c5.6 15.8-.2 20.8-12.1 31.6-23.5 21.3-71.5 22.8-51.5-36.3z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="M1277.2 2881.7c145.8 4.1 192.2-137 102.2-257.8l-8.9 13.3c5.8 56.3 14.2 111.8 15 169.5-17.6 20.7-43.2 13-48.3-10 .3-31.2-9.9-57.6-22.8-82.8l-7.2 13.3c8.4 20.7 17.5 44 19.4 69.5-41.6 49.9-87.6 60-70.5-5.6-32.9 57.5 16.9 98 73.3 9.5 12.1 60.4 58.9 22.9 61.7 9.9 5.1-39.6 2.5-103.4-7.8-153.8 40.6 70.3 42 121 20.4 154.9-24 37.7-76.2 55.3-126.5 70.1z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="M1359.9 2722.2c-31.2 2.3-47.2-4.1-30.3-27.2 16.7-22.6 32.3-4.6 36.5 25.6 3.9 28.3-54.8 64.4-75.1 64.4-30.7 0-44.9-39.5-16.6-75-36.4 103.6 78.6 43.5 85.5 12.2zm-21.6-24c-3.8-.2-6.6 6.5-4.7 7.8 5.5 3.8 14.2 1.5 15.1-.4 1.9-4.2-5.1-7.2-10.4-7.4z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="M1190.5 2771.1c-30 59-.1 83.4 38.4 76.6 22.4-4.1 50.8-20 67.2-41.7.3-47.8-.4-95.2-4.6-141.5 15-17.9-1.3-17.8-7-37-2.6 11.2-8.9 23.3-2.8 32 4.3 46.7 6.7 94 6.6 142.2-30.2 24.3-52.9 33.3-69.1 33.1-33.5-.3-40.7-28.5-28.7-63.7z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="M1251.8 2786.7c-.5-44.5-1.2-95-5.2-126.1 15.6-17.3-.8-17.7-5.9-37.1-3 11-9.6 23-3.8 31.9 2.6 47.6 5.1 95.2 5.6 142.8 3.6-2.3 7.7-3.2 9.3-11.5z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path stroke-width=".4" d="M1135.4 2784.6c-3.8-4.8-6.5-10.2-9.6-14.9-.5-6.7 4-12.9 4.6-16.3 5.1 7.9 8.1 13.9 12.2 17.8m5.4 3.1c7.5 3 16.7 3 25.2 3.2 32.8.6 67.3-4.8 63.6 39.6a66.2 66.2 0 0 1-65.2 61.9c-41.7-.4-77.3-46.4-13-131.1 6.2-1 14.3.7 21 1.3 11.5.9 23.3-.2 36.8-11-1.6-27.9-1.6-54.3-5-79.5-5.8-8.9.8-20.8 3.8-31.9 5.1 19.4 21.4 19.8 5.9 37.2 3.7 28 4.1 56.5 4.1 73.5-7.8 11.9-13.9 24.5-36.7 29.3-23.3-3.4-33.8-36-58.1-25.2 6.7-29.4 68.4-36.1 74.6-12.9-4.1 24.2-61.7 14.5-77 92.7-4.7 24.1 20.7 46.3 46.8 44.5 25.5-1.7 52.7-19.4 55.4-49.2 2.1-24.9-33-22-47.7-21.7-21.4.5-34.9-2.8-43-7.5m21.9-53.9c3.8-3.6 17.1-6.1 21.9-.3-3.6 2.4-7.1 5-10 8.1-5-2.6-8.3-5.2-11.9-7.8z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="M1194 2650.9a49 49 0 0 1 5.3 21c-2.2 10.4-11.1 20.1-20.3 20.4-5.7.2-12.1-1.4-16.6-10.3-.5-1.1-2.9-3.7-5.2-2.5-10.1 16.6-17.6 23.6-26.7 23.5-18.2-.3-12.8-16.5-29.6-21.5-7-.2-18.5 6.9-24.4 20.8-22.4 63.5-42.8-.2-34.1-29.8 1.3 28.3 8.1 45.1 15.1 44.6 5.1-.5 9.6-12.3 16.1-24.7 5-9.5 17-26.6 29.7-26.6 11.6.3 4.3 21.6 27.5 21.3 11.2-.2 21.5-8.8 31.9-26 2.3-.4 2.9 3.7 3.4 5.1 1.6 5.9 11.8 22.1 25.6 7.3-.7-3.2-.4-8.5-3.9-9.6z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path stroke-width=".4" d="M1266.9 2598.3c-12.3 6.1-21.3.5-26.4-4.9 8.9-1.8 15.8-5 17.8-12-4-9-13.5-12.9-26.9-13-17.9.5-27.1 7.7-28.2 17.6 8.3.3 15.8-2 19 6-14.7 7.2-32 9.8-50.8 9.7-30.8 1.6-35.3-12.3-43.4-24.5-.6-.8-3.3-2.1-4.7-1.9-9.5 0-16.5 33.2-27.2 33.1-10.7-1.4-8.3-21.4-11.4-32.8-2.6 17.9 3.3 84.5 36.4 12.2 1-2.4 2.4-1.7 3.3.3 8.9 20.2 27 27.2 46.5 28.2 16.3.9 37.1-6.2 59.4-18.8 5.9 6.5 10.6 13.9 23 15.3 14.5.7 30-9.8 33.5-22.8 1.8-6.7 2.1-19.9-5-20.1-9.9-.3-17.1 23.7-14.8 45.3.2-.3 1.3-5.4 1.3-5.4m-43.8-28.8c6.5-3 12.8-4.4 17.8 2.2a27.4 27.4 0 0 0-8.4 4c-2.8-2.2-6.6-3.3-9.4-6.2zm47.8 14.9c1.6-7.1 2.5-12.8 8.3-16.5 1.2 7.5 1.4 11.7-8.3 16.5zm39 11c-1.9-6.1-3.8-11.4-4.4-18-1.4-13.4 10.1-21 20.5-19.9 10.7 1.1 17.8 5.1 28 8.6 8 2.7 18.8 4.8 29.1 7.7 5.8 2.6 0 9.4-1.5 10.3-25.8 10.1-44.1 26.1-60.5 26.8-9.8.5-18.5-5.9-26.4-19-.5-25.4-1.4-55.2-3.9-73.9 3.8-3.8 4.6-6.6 6.4-9.7 2 24.7 2.8 50.7 3.3 76.9 2.1 4.5 4.7 8.3 9.4 10.2zm16.5 2c-13.8 3.9-12.1-7.8-13.4-15-1.5-8.4-.5-17.9 10.2-15.5 13.9 3.7 26.6 8.6 38.9 13.8z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path stroke-width=".4" d="m1314.3 2621.3 1.9 9.3h1.5l-.6-8.7" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m1094.2 2718.5 7-7.2 8.1 6.9-7.5 6.7zm17.8-2.4 7.1-7.2 8.1 6.9-7.5 6.7zm-49.5-74.6 7.1-7.2 8.1 6.9-7.5 6.7zm3.2 21.2 7.1-7.2 8 6.9-7.5 6.7zm128.5 35.5 6.5-5.3 6 6.5-6.8 4.8zm-85.8-135.7 4.6-4.7 5.3 4.5-4.9 4.4zm11.7-1.5 4.6-4.8 5.3 4.6-4.9 4.3zm245.6 53.7-4.4 3.7-4.2-4.3 4.6-3.4z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path stroke-width=".4" d="m1158.7 2747.4-.5 7.9 12.6 1.2 10.1-7.6z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
<path d="m1265.2 2599.8 3.7-.8-.4 10.3-2.3.9z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
</g>
<path fill="#fff" d="M256 348c55 0 99.8-40.7 99.8-90.8a87.3 87.3 0 0 0-34.7-68.8 74.9 74.9 0 0 1 20.5 51.3c0 43.5-38.3 78.8-85.6 78.8s-85.6-35.3-85.6-78.8a74.8 74.8 0 0 1 20.6-51.3 87.3 87.3 0 0 0-34.8 68.8c0 50.1 44.8 90.9 99.8 90.9z" class="arab-fil2"/>
<g fill="#fff" stroke="#000" stroke-width="8">
<path d="M-54 1623c-88 44-198 32-291-28-4-2-6 1-2 12 10 29 18 52-12 95-13 19 2 22 24 20 112-11 222-36 275-57zm-2 52c-35 14-95 31-162 43-27 4-26 21 22 27 49 5 112-30 150-61z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M0 1579c12 0 34-5 56-8 41-7 11 56-56 56v21c68 0 139-74 124-107-21-48-79-7-124-7s-103-41-124 7c-15 33 56 107 124 107v-21c-67 0-97-63-56-56 22 3 44 8 56 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M54 1623c88 44 198 32 291-28 4-2 6 1 2 12-10 29-18 52 12 95 13 19-2 22-24 20-112-11-222-36-275-57zm2 52c35 14 94 31 162 43 27 4 26 21-22 27-49 5-112-30-150-61z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M3 1665c2 17 5 54 28 38 31-21 38-37 38-67 0-19-23-47-69-47s-69 28-69 47c0 30 7 46 38 67 23 16 25-21 28-38 1-6 6-4 6 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
</g>
<g fill="#fff" stroke="#000" stroke-width="8">
<path d="M-29 384c-13-74-122-79-139-91-20-13-17 0-10 20 20 52 88 73 119 79 25 4 33 6 30-8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M4 386c11-76-97-112-110-129-15-18-17-7-10 14 13 45 60 98 88 112 23 12 30 17 32 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M93 430c10-91-78-105-101-134-15-18-16-8-11 13 10 46 54 100 81 117 21 13 30 18 31 4z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M66 410c-91-59-155-26-181-29-25-3-33 13 10 37 53 29 127 25 156 14 30-12 21-18 15-22zm137 40c-28-98-93-82-112-94s-21-9-17 13c8 39 75 82 108 95 12 4 27 10 21-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M190 467c-78-63-139-16-163-23-18-5-10 7-3 12 50 35 112 54 160 32 19-8 20-10 6-21zm169 64c1-62-127-88-154-126-16-23-30-11-22 26 12 48 100 101 148 111 29 6 28-4 28-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M355 542c-81-73-149-49-174-56-25-6-35 9 4 39 48 36 122 43 153 36s23-14 17-19zm145 107c-23-106-96-128-114-148-17-20-35-14-20 34 18 57 77 107 108 119 30 13 28 3 26-5z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M499 663c-59-95-136-92-160-105-23-14-39-2-8 39 36 50 110 78 144 80s28-7 24-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M575 776c34-108-44-148-52-166-9-18-18-18-23 1-22 77 49 152 60 167 11 14 13 7 15-2z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M559 806c-27-121-98-114-114-131-17-17-19-5-16 17 8 59 79 99 111 119 10 6 22 13 19-5zm68 142c49-114-9-191-27-208-18-16-29-23-23 0 8 35-20 125 23 191 14 22 16 43 27 17z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M601 971c11-70-29-134-72-159-25-15-26-11-26 10 2 65 63 119 81 149 17 28 16 7 17 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M590 1153c-36-132 39-208 62-223 22-16 36-22 26 3-15 37 1 140-56 205-18 22-25 45-32 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M598 1124c30-115-35-180-55-193-19-13-31-18-22 3 12 32-1 122 49 178 16 19 22 38 28 12z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M561 1070c-54 58-55 143-31 193 15 29 17 27 31 6 38-61 15-149 17-188 1-37-11-17-17-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M650 1162c0 80-49 145-101 165-30 11-30 8-26-16 14-90 83-123 108-152 24-28 19-5 19 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M464 1400c88-80 41-136 45-188 2-28-9-21-19-11-56 55-59 153-47 191 5 17 13 15 21 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M582 1348c-29 88-106 142-171 145-38 2-37-1-24-27 49-94 136-105 175-129 36-22 23 2 20 11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M343 1513c114-57 91-152 112-176 15-17-3-15-12-9-67 39-121 101-122 167 0 25 2 28 22 18z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M187 1619c144 23 211-86 253-96 22-5 6-14-5-15-96-11-218 34-255 84-15 20-15 24 7 27z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M333 1448c-29 95-137 173-218 179-38 3-38-1-24-26 65-118 178-138 218-168 34-26 27 6 24 15zM29 384c13-74 122-79 139-91 20-13 17 0 10 20-20 52-88 73-119 79-25 4-33 6-30-8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-4 386c-11-76 97-112 110-129 15-18 17-7 10 14-13 45-60 98-88 112-23 12-30 17-32 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-93 430c-10-91 78-105 101-134 15-18 16-8 11 13-10 46-54 100-81 117-21 13-30 18-31 4z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-66 410c91-59 155-26 181-29 25-3 33 13-10 37-53 29-127 25-156 14-30-12-21-18-15-22zm-137 40c28-98 93-82 112-94s21-9 17 13c-8 39-75 82-108 95-12 4-27 10-21-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-190 467c78-63 139-16 163-23 18-5 10 7 3 12-50 35-112 54-160 32-19-8-20-10-6-21zm-169 64c-1-62 127-88 154-126 16-23 30-11 22 26-12 48-100 101-148 111-29 6-28-4-28-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-355 542c81-73 149-49 174-56 25-6 35 9-4 39-48 36-122 43-153 36s-23-14-17-19zm-145 107c23-106 96-128 114-148 17-20 35-14 20 34-18 57-77 107-108 119-30 13-28 3-26-5z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-499 663c59-95 136-92 160-105 23-14 39-2 8 39-36 50-110 78-144 80s-28-7-24-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-575 776c-34-108 44-148 52-166 9-18 18-18 23 1 22 77-49 152-60 167-11 14-13 7-15-2z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-559 806c27-121 98-114 114-131 17-17 19-5 16 17-8 59-79 99-111 119-10 6-22 13-19-5zm-68 142c-49-114 9-191 27-208 18-16 29-23 23 0-8 35 20 125-23 191-14 22-16 43-27 17z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-601 971c-11-70 29-134 72-159 25-15 26-11 26 10-2 65-63 119-81 149-17 28-16 7-17 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-590 1153c36-132-39-208-62-223-22-16-36-22-26 3 15 37-1 140 56 205 18 22 24 45 32 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-598 1124c-30-115 35-180 55-193 19-13 31-18 22 3-12 32 1 122-49 178-16 19-22 38-28 12z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-561 1070c54 58 55 143 31 193-15 29-17 27-31 6-38-61-15-149-17-188-1-37 11-17 17-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-650 1162c0 80 49 145 101 165 30 11 30 8 26-16-14-90-83-123-108-152-24-28-19-5-19 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-464 1400c-88-80-41-136-45-188-2-28 9-21 19-11 56 55 59 153 47 191-5 17-13 15-21 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-582 1348c29 88 106 142 171 145 38 2 37-1 24-27-49-94-136-105-175-129-36-22-23 2-20 11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-343 1513c-114-57-91-152-112-176-15-17 3-15 12-9 67 39 121 101 122 167 0 25-2 28-22 18z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-187 1619c-144 23-211-86-253-96-22-5-6-14 5-15 96-11 218 34 255 84 15 20 15 24-7 27z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
<path d="M-333 1448c29 95 137 173 218 179 38 3 38-1 24-26-65-118-178-138-218-168-34-26-27 6-24 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
</g>
<path fill="#006233" d="M298.3 137.4c-4.8-3.1-22.3-1.3-25.5-3.4 6.2 4.8 20.2 1.4 25.5 3.4m42.3 8.2c-3.8-6.1-26-10.2-29.3-15.7 5.8 10.5 23 9.1 29.3 15.7m-3.3 7c-8.2-7.2-27.5-4.2-33.2-8.6 13.5 11.2 21 2.5 33.2 8.7zM289 120.4c5.3 2.5 11.8 5 15 10.9-3.7-4.6-10.5-6.4-16-10.2.3 0 .8-.5 1-.7m82.1 46.9c-3.3-6.9-14.8-14.4-15.8-16.9 3.3 8.9 12.8 11 15.8 16.9m3 12c-10-14.3-25.8-12.7-32-18.2 4.7 5.3 22.8 8.7 32 18.2m23.3 22.1c.7-15.2-11.8-21-12.3-29.6-.2 10.3 12.8 24.2 12.3 29.6m-6.3 8.2c-2.5-13.2-19.5-14.1-22.5-21.8 0 7.2 20 14.8 22.5 21.8m14-7.5c9 10 2.8 25.3 6.5 36.4-4.5-8.2-2.2-28.6-6.5-36.4m-14.7 42.8c13.5 13.2 8 28 13.5 34.4-6.8-9-5.8-26.2-13.5-34.4m28 1.8c-11.5 11.6-4.5 29.1-10.5 37.4 6.7-7.1 5.7-28.8 10.5-37.4m-14.5 0c-1.5-13.4-15.3-20.5-16.5-27.8-1.5 7.3 13.2 18.7 16.5 27.8m-7 32.1c2.2 9.4-6 29.4-3.5 35.5-5.5-10.7 4.7-31 3.5-35.5m17.7 21.4c-5.5 16.6-16.5 15.5-20 25.5 2.5-9.5 17-18.2 20-25.5m-35.7 7.8c-7.3 11.1-1.3 23.9-7.3 31.8 8.5-8 4-22.7 7.3-31.8m17.5 30.5c-8.8 14.8-26.8 13.4-34 24.1 7.2-13.4 29.5-15.7 34-24.1m-31.8-1.9c-15.5 9.8-10.8 20-22.5 31 14.7-11 13.5-23 22.5-31m-7.3 39.7c-15-.5-36.5 17.3-49.5 15.9 13 2.5 37-13.4 49.5-16zm-24.2-16c-1 13.9-40 22.8-44.3 32.1 4.7-12.3 39-21.4 44.3-32zm-88.4-256c-5-4-11-7.1-12.7-11 1.2 5 6.2 8.7 11.2 12 .5-.2 1-.9 1.5-1m-8.5 4c-7.7-3.4-16.7-3.2-20.7-8 2.5 4.8 11 6.7 18.2 9.2.8-.5 1.8-1 2.6-1.2zm-22.5 29.2c4.8-3.2 22.3-1.4 25.5-3.4-6.2 4.7-20.2 1.3-25.5 3.4m-42.3 8.2c3.8-6.2 26-10.3 29.3-15.7-5.8 10.4-23 9-29.3 15.7m3.3 7c8.2-7.3 27.5-4.3 33.3-8.6-13.5 11.1-21 2.5-33.3 8.6m33.3-21.4c4.7-9 18.2-10.2 21.7-15.7-5.2 8.2-16.7 9.6-21.7 15.7m38.5-8c13.8-5.9 27.3-.9 33.8-3.6-8 3.9-27 2-33.8 3.7zm-105.6 44c3.3-6.8 14.8-14.4 15.8-16.9-3.3 9-12.8 11-15.8 16.9m-3 12c10-14.3 25.8-12.7 32-18.2-4.7 5.3-22.7 8.7-32 18.3zm-23.3 22.1c-.7-15.2 11.8-21 12.3-29.6.2 10.3-12.8 24.2-12.3 29.6m6.3 8.2c2.5-13.2 19.5-14 22.5-21.8 0 7.3-20 14.8-22.5 21.8m-14-7.5c-9 10-2.8 25.3-6.5 36.4 4.5-8.1 2.2-28.6 6.5-36.4m14.7 42.8c-13.5 13.2-8 28-13.5 34.4 6.8-8.9 5.8-26.2 13.5-34.4m-28 1.9c11.5 11.6 4.5 29 10.5 37.3-6.7-7-5.7-28.7-10.5-37.3m14.5 0c1.5-13.5 15.3-20.5 16.5-27.8 1.5 7.3-13.2 18.7-16.5 27.8m7 32c-2.2 9.4 6 29.4 3.5 35.6 5.5-10.7-4.7-31-3.5-35.5zm-17.7 21.4c5.5 16.7 16.5 15.5 20 25.5-2.5-9.5-17-18.2-20-25.4zm35.8 7.8c7.2 11.2 1.2 23.9 7.2 31.9-8.5-8-4-22.8-7.2-31.9m-17.6 30.5c8.8 14.8 26.8 13.4 34 24.1-7.2-13.4-29.5-15.7-34-24.1m31.8-1.8c15.5 9.8 10.8 20 22.5 31-14.7-11-13.5-23-22.5-31m7.3 39.6c15-.5 36.5 17.3 49.5 16-13 2.4-37-13.5-49.5-16m24.3-16c1 14 40 22.8 44.2 32.2-4.7-12.3-39-21.4-44.3-32.1zm56.7-236.5c3-12.3 18-14.6 20-21.9-.7 7.8-18.5 16.4-20 22zM280 93.3c-2.2 9.3-18.5 14.6-20.7 21.6.7-9.5 17.5-15 20.7-21.6m-12.7 22c7.7-11.3 23.7-8.3 29.3-15-4 7.8-23.8 8-29.3 15" class="arab-fil0"/>
<path fill="none" stroke="#f7c608" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.9" d="M373.2 256c0 59.2-52.7 107.1-117.7 107.1s-117.6-47.9-117.6-107c0-59.2 52.6-107 117.6-107s117.7 47.8 117.7 107z"/>
<path fill="#f7c608" d="m232.4 363.2-.4 1.3c-.3.9-1.2 1.3-2.2 1.3-2.5-.7-5.6-1.3-8.5-2l2.7-8.3a90.2 90.2 0 0 0 8.5 1.8c1 .2 1.5 1 1.2 2l-.4 1m-20.2-5.1.4-1.3c.3-1 1.2-1.4 2.2-1.1 2.3.8 5.1 1.8 8.3 2.7l-2.7 8.3c-3-.8-5.8-1.6-8.3-2.5-1-.5-1.2-2.2-.9-3"/>
<path fill="#006233" d="m230.8 362.5-.3.9c-.2.6-1 1-1.8.9l-7-1.8 1.9-5.9a87 87 0 0 0 7 1.6c.9.2 1.3.8 1 1.5l-.2.7m-16.8-4.3.3-1c.2-.6 1-.9 1.8-.6l6.9 2.1-2 6a105.5 105.5 0 0 1-7-2c-.6-.4-.9-1.7-.7-2.3"/>
<path fill="#f7c608" d="m200.2 352.8-.8 1.2c-.5.7-1.5 1-2.5.6-2.1-1.2-5-2.6-7.5-4.1l5.1-7.3a85.7 85.7 0 0 0 7.6 4c.9.5 1.1 1.4.6 2.2l-.7 1m-17.8-10.2.8-1.2c.5-.7 1.6-1 2.4-.5 2 1.5 4.4 3.1 7.1 4.7l-5.1 7.3a97 97 0 0 1-7.2-4.5c-.8-.7-.4-2.4 0-3.1"/>
<path fill="#006233" d="m198.9 351.7-.6.8c-.4.6-1.2.7-2 .4l-6.2-3.5 3.7-5.2c2.2 1.4 4.6 2.5 6.3 3.4.7.4 1 1 .5 1.6l-.5.7m-14.8-8.5.6-.8c.4-.5 1.2-.6 2-.2a95.4 95.4 0 0 0 5.9 3.8l-3.7 5.2a68.3 68.3 0 0 1-6-3.7c-.6-.5-.5-1.8-.1-2.3"/>
<path fill="#f7c608" d="m172.6 334.6-1.2.9c-.7.6-1.8.5-2.6 0l-6-5.9 7.3-5.6c2 2.2 4.4 4.3 6 5.7.7.7.6 1.6-.1 2.2l-1 .8m-14-14.3 1.2-1a1.8 1.8 0 0 1 2.5.2 78 78 0 0 0 5.3 6.4l-7.1 5.6a98.8 98.8 0 0 1-5.6-6.1c-.4-.9.4-2.5 1.1-3"/>
<path fill="#006233" d="m171.6 333.2-.8.6c-.5.5-1.3.3-2-.1l-5-5 5.2-4a78.2 78.2 0 0 0 5 4.8c.6.6.6 1.3 0 1.8l-.6.5m-11.6-12 .8-.5c.6-.5 1.4-.4 2 .2a76.5 76.5 0 0 0 4.4 5.2l-5.1 4a67.8 67.8 0 0 1-4.6-5c-.5-.7 0-1.9.6-2.3"/>
<path fill="#f7c608" d="m151.7 310-1.4.6c-.9.4-2 0-2.5-.7-1-2-2.7-4.7-4-7.1l8.7-3.6c1.4 2.7 3 5.3 4.1 7 .4.9 0 1.8-.8 2.1l-1.2.5m-9-17.3 1.4-.6c1-.3 2 0 2.3.8a75.3 75.3 0 0 0 3.2 7.5l-8.6 3.6c-1.3-2.5-2.5-5-3.4-7.4-.2-1 1-2.2 2-2.6"/>
<path fill="#006233" d="m151.2 308.4-1 .4c-.6.3-1.4 0-1.9-.6l-3.2-6 6.2-2.6a72.3 72.3 0 0 0 3.4 6c.3.6 0 1.3-.6 1.6l-.8.4m-7.4-14.4 1-.4c.6-.3 1.4 0 1.7.7.7 1.8 1.6 4 2.7 6.2l-6.2 2.5a78 78 0 0 1-2.9-6c-.1-.8.7-1.8 1.4-2"/>
<path fill="#f7c608" d="m139.2 281.1-1.5.2a2.1 2.1 0 0 1-2.2-1.3l-1.5-7.9 9.4-1.2a68 68 0 0 0 1.7 7.8c.2 1-.4 1.7-1.4 1.8l-1.3.2m-3.2-18.9 1.5-.2c1-.1 1.8.4 2 1.4 0 2.2.2 5 .7 8l-9.4 1.1c-.4-2.7-.9-5.3-1-7.9.1-1 1.7-1.8 2.7-2"/>
<path fill="#006233" d="m139.2 279.5-1 .1c-.7.1-1.3-.4-1.6-1.1-.3-1.9-.9-4.3-1.2-6.6l6.7-.9a68.7 68.7 0 0 0 1.4 6.6c0 .7-.3 1.3-1 1.4l-1 .2m-2.6-15.8 1-.1c.8 0 1.4.4 1.5 1.2.1 1.8.3 4.1.7 6.5l-6.8 1c-.3-2.3-.7-4.6-.8-6.6 0-.8 1.2-1.5 2-1.6"/>
<path fill="#f7c608" d="m136.2 250.2-1.5-.2c-1-.1-1.6-1-1.7-1.9l1-7.9 9.4 1.3a69 69 0 0 0-.7 7.9c-.2.9-1 1.5-2 1.3l-1.3-.1m2.8-19 1.5.2c1 .2 1.6 1 1.4 1.8a73.3 73.3 0 0 0-1.7 7.9l-9.4-1.3a85 85 0 0 1 1.5-7.8c.4-1 2.2-1.4 3.2-1.2"/>
<path fill="#006233" d="m136.8 248.6-1.1-.2c-.7 0-1.2-.7-1.2-1.5l.9-6.5 6.7.8a69.3 69.3 0 0 0-.7 6.7c-.1.7-.7 1.2-1.4 1.1l-1-.1m2.4-15.8 1 .2c.8 0 1.2.7 1 1.4-.4 1.9-1 4.1-1.3 6.5l-6.7-.8c.3-2.3.7-4.5 1.2-6.6.3-.7 1.6-1 2.3-1"/>
<path fill="#f7c608" d="m142.9 219.7-1.3-.6c-1-.3-1.3-1.2-1.1-2.2 1-2 2.1-4.8 3.4-7.3l8.6 3.6a75 75 0 0 0-3.2 7.4c-.4.9-1.4 1.2-2.3.8l-1.1-.4m8.5-17.5 1.3.5c1 .4 1.3 1.3.9 2.1a77.3 77.3 0 0 0-4.1 7l-8.6-3.5a78 78 0 0 1 3.8-7.1c.7-.8 2.6-.8 3.5-.4"/>
<path fill="#006233" d="m144 218.4-1-.5c-.7-.2-1-1-.7-1.7l2.8-6 6.2 2.5a70.7 70.7 0 0 0-2.7 6.1c-.3.7-1 1-1.7.8l-.9-.4m7.1-14.5 1 .4c.7.3.9 1 .6 1.7a76.1 76.1 0 0 0-3.4 5.9l-6.2-2.6a82.2 82.2 0 0 1 3.2-6c.5-.6 2-.6 2.6-.4"/>
<path fill="#f7c608" d="m158.8 192.2-1.2-.9c-.7-.6-.8-1.6-.3-2.4 1.6-1.7 3.5-4.1 5.5-6.2l7.2 5.7a76.9 76.9 0 0 0-5.4 6.3 1.8 1.8 0 0 1-2.4.2l-1-.8m13.6-14.6 1.1 1c.8.5.9 1.5.2 2.2a83.6 83.6 0 0 0-6.1 5.7l-7.2-5.7 6-5.8c.8-.6 2.6 0 3.4.5"/>
<path fill="#006233" d="m160.2 191.1-.8-.6c-.6-.4-.6-1.2-.2-1.9l4.7-5 5.1 4a76.1 76.1 0 0 0-4.5 5.2c-.5.6-1.3.7-1.9.3l-.7-.6m11.3-12.1.9.6c.5.5.5 1.2 0 1.8a85 85 0 0 0-5 4.8l-5.2-4a58 58 0 0 1 4.9-5c.6-.5 2-.1 2.5.3"/>
<path fill="#f7c608" d="m182.5 169.9-.8-1.2c-.6-.8-.3-1.7.4-2.4l7.2-4.5 5.1 7.3a83.2 83.2 0 0 0-7 4.7 1.8 1.8 0 0 1-2.5-.5l-.7-1m17.6-10.5.8 1.2c.6.7.3 1.7-.5 2.2a83.6 83.6 0 0 0-7.7 3.9l-5.1-7.3c2.5-1.5 5-3 7.5-4.1 1-.3 2.6.7 3 1.4"/>
<path fill="#006233" d="m184.2 169.2-.6-.8c-.4-.6-.2-1.3.4-1.8 1.8-1 4-2.5 6-3.8l3.7 5.2-5.9 3.9c-.7.4-1.5.3-1.9-.2l-.5-.7m14.6-8.8.6.9c.4.5.2 1.2-.5 1.6a88.8 88.8 0 0 0-6.4 3.3l-3.6-5.2a96.5 96.5 0 0 1 6.2-3.4c.8-.3 2 .4 2.4 1"/>
<path fill="#f7c608" d="m212.2 154.5-.4-1.3c-.3-.9.2-1.7 1-2.2 2.5-.6 5.5-1.7 8.4-2.5l2.7 8.3a88 88 0 0 0-8.3 2.7c-1 .3-1.9-.2-2.1-1l-.4-1.1m20-5.7.5 1.3c.3 1-.2 1.8-1.2 2a94.8 94.8 0 0 0-8.5 1.8l-2.7-8.3c2.9-.7 5.8-1.5 8.5-2 1 0 2.2 1.3 2.5 2.2"/>
<path fill="#006233" d="m214 154.3-.3-1c-.2-.6.2-1.2 1-1.5 2-.6 4.5-1.4 7-2l1.9 5.9a86.6 86.6 0 0 0-7 2.2c-.8.1-1.5-.1-1.7-.8l-.2-.7m16.7-4.7.3 1c.2.6-.3 1.2-1 1.4a98 98 0 0 0-7.2 1.6l-1.9-6a107 107 0 0 1 7-1.7c1 0 1.9.9 2 1.5"/>
<path fill="#f7c608" d="M245.4 147.4v-1.3c0-1 .8-1.6 1.8-1.9l8.7-.2v8.7a89.5 89.5 0 0 0-8.7.4c-1 0-1.8-.7-1.8-1.6v-1.1m21-.3v1.4c0 1-.7 1.6-1.7 1.6a96 96 0 0 0-8.8-.4V144c3 0 6 0 8.8.2 1 .2 1.7 1.8 1.7 2.7"/>
<path fill="#006233" d="M247.2 147.7v-1c0-.6.6-1.1 1.5-1.3l7.3-.1v6.1a92 92 0 0 0-7.3.4c-.9 0-1.5-.5-1.5-1.2v-.8m17.5-.2v1c0 .7-.6 1.2-1.5 1.2a87.9 87.9 0 0 0-7.2-.4v-6.1l7.2.1c.9.2 1.5 1.3 1.5 2"/>
<path fill="#f7c608" d="m277.3 149 .4-1.4c.2-.8 1.2-1.3 2.2-1.3 2.4.6 5.6 1.2 8.5 2l-2.6 8.3a90.2 90.2 0 0 0-8.6-1.7c-1-.3-1.5-1.1-1.2-2l.4-1.1m20.3 5-.5 1.3c-.2.9-1.2 1.4-2.1 1.1a93.4 93.4 0 0 0-8.3-2.6l2.6-8.3a82 82 0 0 1 8.3 2.4c1 .4 1.2 2.2 1 3"/>
<path fill="#006233" d="m279 149.7.2-1c.2-.6 1-1 1.8-.9 2 .6 4.6 1 7 1.7l-1.8 6a89 89 0 0 0-7.1-1.6c-.8-.2-1.3-.8-1-1.4l.2-.8m16.8 4.2-.3 1c-.2.6-1 .9-1.7.7a92.3 92.3 0 0 0-7-2.2l2-6 6.9 2c.8.4 1 1.7.8 2.3"/>
<path fill="#f7c608" d="m309.5 159 .8-1c.6-.9 1.6-1 2.6-.8l7.5 4-5 7.4a86 86 0 0 0-7.7-3.8 1.6 1.6 0 0 1-.6-2.3l.7-1m18 10-.9 1.2a1.8 1.8 0 0 1-2.4.5 87.8 87.8 0 0 0-7.1-4.6l5-7.3c2.6 1.4 5.1 2.9 7.3 4.4.7.7.4 2.4-.1 3.2"/>
<path fill="#006233" d="m310.9 160.2.6-.8c.4-.6 1.2-.7 2-.4 1.7 1 4.1 2.1 6.2 3.4l-3.6 5.2a84.9 84.9 0 0 0-6.4-3.3c-.7-.4-1-1-.5-1.6l.5-.7m14.8 8.3-.5.8c-.4.6-1.2.7-2 .3a86.9 86.9 0 0 0-6-3.8l3.7-5.2c2.1 1.2 4.3 2.4 6 3.6.7.6.6 1.8.2 2.4"/>
<path fill="#f7c608" d="m337.4 177 1.1-.8c.8-.7 1.8-.6 2.7 0 1.6 1.7 4 3.7 6 5.8l-7.2 5.7a79.8 79.8 0 0 0-6.2-5.7c-.6-.7-.5-1.6.2-2.2l1-.8m14 14.2-1.1 1a1.8 1.8 0 0 1-2.5-.2c-1.4-1.9-3.3-4-5.4-6.3l7.2-5.7c2 2 3.9 4 5.5 6.1.5.8-.3 2.4-1 3"/>
<path fill="#006233" d="m338.3 178.5.8-.7c.6-.4 1.4-.3 2 .1 1.4 1.5 3.4 3.2 5 5l-5 4a78.5 78.5 0 0 0-5.2-4.8c-.5-.5-.5-1.3 0-1.7l.7-.6m11.7 11.9-.8.6c-.6.4-1.4.3-2-.2a80.2 80.2 0 0 0-4.5-5.2l5.1-4c1.7 1.6 3.3 3.3 4.7 5 .4.6-.1 1.8-.6 2.3"/>
<path fill="#f7c608" d="m358.5 201.5 1.4-.6c.9-.4 1.9 0 2.5.7 1 2 2.7 4.6 4 7l-8.7 3.7c-1.3-2.7-3-5.2-4.1-7-.4-.8 0-1.7.8-2.1l1.2-.5m9 17.2-1.3.6c-.9.4-1.9 0-2.3-.8a75.5 75.5 0 0 0-3.3-7.4l8.7-3.6a86.5 86.5 0 0 1 3.4 7.3c.2 1-1 2.2-2 2.6"/>
<path fill="#006233" d="m359 203 1-.4c.6-.2 1.4 0 1.9.7.9 1.7 2.2 3.9 3.2 6l-6.1 2.5a79.4 79.4 0 0 0-3.4-5.8c-.4-.7-.2-1.4.5-1.7l.8-.4m7.6 14.4-1 .4c-.7.3-1.4 0-1.8-.7-.7-1.8-1.6-4-2.7-6.2l6.1-2.6a86.3 86.3 0 0 1 3 6c.1.9-.7 1.8-1.4 2.1"/>
<path fill="#f7c608" d="m371.2 230.3 1.5-.2c1-.2 1.9.4 2.3 1.3l1.5 7.8-9.3 1.3a69.8 69.8 0 0 0-1.9-7.8c-.1-.9.5-1.7 1.5-1.8l1.2-.2m3.4 18.9-1.4.2c-1 .1-1.9-.4-2-1.3l-.8-8 9.3-1.3c.5 2.7 1 5.4 1.1 7.9 0 1-1.7 1.9-2.7 2"/>
<path fill="#006233" d="m371.2 232 1-.2c.8-.1 1.4.4 1.7 1.1l1.3 6.5-6.7 1a68.8 68.8 0 0 0-1.5-6.6c-.1-.7.3-1.3 1-1.4l1-.2m2.7 15.7-1 .2c-.7.1-1.4-.4-1.5-1.2a71.7 71.7 0 0 0-.7-6.5l6.7-1c.4 2.3.8 4.5 1 6.6-.1.8-1.3 1.5-2 1.6"/>
<path fill="#f7c608" d="m374.5 261.2 1.5.2c1 0 1.7.9 1.8 1.8-.4 2.3-.6 5.2-1 8l-9.4-1.2c.5-3 .7-6 .7-8 .1-.9 1-1.5 2-1.3l1.2.1m-2.5 19-1.5-.2c-1-.1-1.7-.9-1.5-1.8.5-2.2 1.2-4.9 1.6-7.8l9.5 1.1c-.4 2.7-.8 5.4-1.5 7.9-.3.9-2.2 1.3-3.2 1.2"/>
<path fill="#006233" d="m374 262.8 1 .1c.8 0 1.2.7 1.3 1.5l-.8 6.6-6.8-.8c.4-2.5.6-5 .7-6.6 0-.8.7-1.3 1.4-1.2h.9m-2.2 15.9-1-.1c-.8-.1-1.2-.8-1-1.5.4-1.9.9-4 1.3-6.6l6.7.9a82 82 0 0 1-1.2 6.5c-.2.8-1.6 1.2-2.3 1"/>
<path fill="#f7c608" d="m368.2 291.7 1.3.6c1 .3 1.3 1.2 1.1 2.2-1 2-2 4.8-3.3 7.3l-8.7-3.5a76 76 0 0 0 3.1-7.5c.4-.8 1.4-1.1 2.3-.8l1.2.5m-8.4 17.6-1.3-.6c-1-.4-1.3-1.3-1-2.1 1.3-2 2.8-4.4 4.1-7l8.7 3.4c-1.2 2.5-2.5 5-3.8 7.2-.6.8-2.5.7-3.5.3"/>
<path fill="#006233" d="m367.1 293 1 .5c.7.3.9 1 .7 1.7l-2.8 6.1-6.2-2.5a70.7 70.7 0 0 0 2.6-6.2c.4-.6 1.1-1 1.8-.7l.8.3m-7 14.6-1-.4c-.6-.2-.8-1-.5-1.7a76 76 0 0 0 3.3-5.9l6.2 2.5a86 86 0 0 1-3.1 6c-.5.7-1.9.7-2.5.5"/>
<path fill="#f7c608" d="m352.5 319.4 1.2.8c.8.6.8 1.6.4 2.4l-5.5 6.2-7.2-5.6c2-2.2 4-4.6 5.3-6.3.6-.7 1.6-.8 2.4-.2l1 .7m-13.5 14.7-1.1-.8c-.8-.6-.9-1.6-.2-2.3a77 77 0 0 0 6-5.8l7.3 5.6a94.1 94.1 0 0 1-5.9 6c-.8.5-2.6 0-3.4-.6"/>
<path fill="#006233" d="m351.1 320.4.9.6c.5.5.5 1.2.1 1.9l-4.6 5.1-5.2-4c1.8-1.8 3.4-3.8 4.5-5.2.5-.6 1.3-.7 1.9-.3l.7.5m-11.2 12.3-.8-.7c-.6-.4-.6-1.1 0-1.7a82 82 0 0 0 5-4.9l5.1 4a77 77 0 0 1-4.8 5c-.7.5-2 .2-2.6-.3"/>
<path fill="#f7c608" d="m329 341.9.8 1.1c.6.8.4 1.7-.3 2.4l-7.2 4.6-5.2-7.3a83 83 0 0 0 7-4.7 1.8 1.8 0 0 1 2.5.4l.6 1m-17.4 10.7-.8-1.2c-.6-.7-.4-1.7.5-2.2a89.9 89.9 0 0 0 7.6-4l5.2 7.3c-2.5 1.4-5 3-7.5 4.1-1 .4-2.5-.6-3-1.4"/>
<path fill="#006233" d="m327.3 342.5.6.9c.4.5.2 1.2-.4 1.8l-6 3.7-3.7-5.1a84.6 84.6 0 0 0 5.9-4c.7-.4 1.5-.3 1.9.3l.5.7m-14.6 8.8-.5-.8c-.4-.6-.2-1.3.5-1.7a88.6 88.6 0 0 0 6.3-3.3l3.7 5.1c-2 1.3-4.2 2.5-6.2 3.5-.8.3-2-.3-2.4-.9"/>
<path fill="#f7c608" d="m299.5 357.4.4 1.4c.3.8-.2 1.7-1 2.1l-8.4 2.6-2.7-8.3a80.4 80.4 0 0 0 8.2-2.7c1-.3 1.9.1 2.2 1l.3 1.1m-20 5.8-.4-1.3c-.3-.9.2-1.8 1.1-2 2.5-.4 5.5-1 8.6-1.9l2.7 8.3c-2.9.8-5.7 1.6-8.4 2-1 .1-2.3-1.2-2.6-2"/>
<path fill="#006233" d="m297.7 357.7.3.9c.2.6-.2 1.2-1 1.6-2 .6-4.5 1.4-7 2l-1.9-5.8a87.7 87.7 0 0 0 6.9-2.3c.8-.2 1.5.1 1.8.7l.2.8m-16.7 4.8-.3-1c-.2-.6.3-1.2 1-1.4a87.9 87.9 0 0 0 7.1-1.6l2 5.9a106 106 0 0 1-7 1.7c-.9.1-1.8-.8-2-1.4"/>
<path fill="#f7c608" d="M266.3 364.8v1.4c0 1-.7 1.6-1.7 1.8-2.5 0-5.7.3-8.8.3v-8.6a87.7 87.7 0 0 0 8.7-.5c1 0 1.8.6 1.8 1.5v1.2m-21 .4v-1.4c0-.9.7-1.6 1.7-1.6 2.5.2 5.5.4 8.7.4l.1 8.6a100.6 100.6 0 0 1-8.7-.1c-1-.2-1.8-1.8-1.8-2.7"/>
<path fill="#006233" d="M264.5 364.6v1c0 .6-.6 1-1.4 1.3l-7.3.2v-6.2c2.6 0 5.3-.2 7.2-.4.9 0 1.5.5 1.5 1.1v.8m-17.5.4v-1c0-.7.6-1.2 1.4-1.2 2.1.2 4.6.3 7.3.3l.1 6.2-7.3-.1c-.8-.2-1.5-1.3-1.5-2"/>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-as" viewBox="0 0 512 512">
<path fill="#006" d="M0 0h512v512H0Z"/>
<path fill="#bd1021" d="M0 256 512 0v512Z"/>
<path fill="#fff" d="m41.4 256 470-228.6v457.2"/>
<path d="M334.9 288.5c5.4.3 5.2 5.7 5.2 5.7l19.3.5c2.5-6.8 5-6 9.7-2.6a35 35 0 0 0 9.3 4.5c2-9.6 15.6-7.7 15.6-7.7 5.8-13.9 6.3-13.7 2.8-15.5a11.4 11.4 0 0 1-4.9-4.7 28 28 0 0 1-5.4-13.3c-.4-3.5-4.5 1.7-5.2.7-.7-1.2-6.8-.5-6.8-.5 1.5 1.6-3.6.6-3.6.6.5.5 0 2 0 2-.5-.7-4.4-1.4-4.4-1.4a6.5 6.5 0 0 1-1.2 1.7c-2.1-.8-6.3-.7-6.3-.7a21.4 21.4 0 0 0-11.7 3c-1.7 1-8 4-13 9-5.1 5-8 4.3-8 4.3-1.5 5.6-13.6 12.3-13.6 12.3-2 1.7-8.1 2.5-11.2 0-3.1-2.6 0-7.4 0-7.4 1.2-2 2.3-2 2.4-9.5.1-5 5.3-9.2 10.7-15 6.6-7.3 15.9-19.3 15.9-19.3 0 3.7 2 4.3 2 4.3 1.8-3.7 4.4-6.7 4.4-6.7.2.3.6.5.6.5 2-2.8 3.3-3.9 3.3-3.9-.7-.3-6.5 0-11.8 4.7-5.4 4.7-9 3.1-9 3.1-3.7-1.2-4-4-4-4-2.8-11.8 7.9-20 7.9-20-14.3-3.5-4-21.7 13.8-29.5 17.7-7.7 17.5-11.2 17.5-11.2a13.9 13.9 0 0 1 2 3.3c0-.1 1.4-2 11.7-6.6a76 76 0 0 0 15-8.5c1.4 2.6 1.2 4.4 1.2 4.4 28-9.8 55.5-32.4 55.5-32.4.8 1.9.4 4.8.4 4.8 4.6-4.3 21-14 21-14 .3 6.2-4.8 8.6-4.8 8.6a8.2 8.2 0 0 1 .8 2.5 384 384 0 0 0 15.4-10.2c4.6 4 .5 10.4.5 10.4 1.6-.2 2.7-1.6 2.7-1.6 1.3 6.8-6.3 12.8-6.3 12.8a10 10 0 0 0 3.6-1.4c-1.5 7.4-15.5 15.6-15.5 15.6 2 1.8.1 4.2-1.6 5.4-1.7 1-4.6 3.4-3.7 4.4.9 1 7.1-3.5 7.1-3.5 1 3.1-6.9 9.3-6.9 9.3 5.6.7 21-6.3 21-6.3-1.3 6-7 10.6-14.3 13.3-7.1 2.7-6.7 3.1-6.7 3.1 1.2 1 11-1.9 11-1.9-2.9 6.6-13.2 11.2-13.2 11.2 2.8 2.5 6.7-.4 10.6-3a62 62 0 0 1 15-6.9c5.7-2 9.8-.5 9.8-.5 5-1.5 9 .7 9 .7 9.2.7 10.2 4 10.2 4 1 .3 1.8.7 4.2 2.5 2.4 1.7 2.2 7.1 2.1 9.8 0 2.6-.8 2.6-1.3 3.3-.4.8-.5 1.6-.5 2.6s-2.4 7.4-16.7 7.4h-21.8c-1.2 0-2.6.7-2.6.7-6 3-2.8-2-10 3.8-7.2 6-10.9 5-10.9 5a96.2 96.2 0 0 1-13 11.2c-4.3 2.8-3.6 2.5.2 4.1s9.4 0 9.4 0c-3.6 2.5-1 3.6-1 3.6 4.6-3 7.6-1.9 7.6-1.9 1.5 4.2-4 10.8-4 10.8 2.1.2 6.1 0 6.1 0-1 2.8-4.9 5.9-7.8 6.8-3 1-2.5 1.4-1.6 3 .8 1.9.2 3.7.2 3.7-5.2-3.5-5.3-.4-5.3-.4-.6 4.1-.5 10.2-.5 10.2-3.6-1.8-3.8.6-3.8.6a23 23 0 0 1-5.4 8.1c-.1-2.3-2.3-3-2.3-3-2.3 4.5-6.6 7.2-6.6 7.2-.5 3.8.6 9.1.6 9.1-2.8-.5-3.8-.5-4.2.1-.4.7.6 1 .6 1l35.6.9c.5 0 2.6.3 2.6 4 .1 4-3 4.2-3 4.2l-39-1s.2 1.1-1.8 2.2-1.3-1.2-2 3.6c-.5 4.7-8.2-.4-8.2-.4-1.3 1.9-4.3 4.3-4.3 4.3-1.8-5.3-3.6-7-6.4-2.4-2.7 4.6 5.1 3.8 5.1 3.8l48.2-7c2.5-.2 5.1 0 6.3 3.4 1 3.5-5.6 4-5.6 4l-46.9 5.2c-1 2.7-4.9 2.6-4.9 2.6.4 2.6-2.4 4.3-3.8 5.2-1.5 1-6 .6-6 .6-5.4 3.7-8 .8-8 .8-3.6 1.5-5.8.8-8.7-.4-3-1.3-2.7-4.8-2.7-4.8l-29.6 3a7.2 7.2 0 0 0-2.3 1.5c1 1.3-2.2 4.4-2.2 4.4 1 .6 2.6 2.3 2.9 6 .2 4-4.8 4.6-2.3 7.4 2.4 2.7 7.1.4 12.3-2 5-2.5 10-2.2 12.2-2.2 2.2 0 8.3 1.6 12.1 3 3.8 1.2 5.1.4 5.4-1.5.2-2 2-2.5 2-2.5-.5 1.9.5 2.8.5 2.8 1.5 0 4-1.4 4-1.4-.2 1.5-2.1 2.4-2.1 2.4-3.7 2.4 1.4 1.5 1.4 1.5a50.3 50.3 0 0 1 16.5-1.6 130.7 130.7 0 0 1 15.2 5.6c.4-1.3.2-4.4.2-4.4a9 9 0 0 1 4.4 2.8c1.3-1.3.5-3.6.5-3.6 10.2 5.8-2.2 8.4-5.5 9.6-3.3 1-3.3 2.4-3.3 2.4 2.8-.8 4.7-1.2 7-1.4 2.2-.1 1.4 0 6.9-1 5.5-1.2 8.3 1.2 8.3 1.2-4.6.2-6 1.6-6 1.6 3 1.9.2 3.6.2 3.6-4.1-5.2-7.7.2-7.7.2a16.3 16.3 0 0 1 6.8 1.4c.9.6 3 1.7 5.7 3 3.8 1.6 3 .6 6 1.6 3 1.2 1.8 4 1.8 4a7.3 7.3 0 0 0-4-3.2c-.2 3.1-3.3 3.7-3.3 3.7 3.9-4.2-4.4-6.1-8.3-6-3.8 0-6.7 2.5-6.7 2.5 7.8 7.4 13.2 5 13.2 5-1 2.6-7.4 1.5-7.4 1.5 3 2.3 2.6 3.9 2.6 3.9-1.6-1.6-4.1-.8-9.8-4.4-5.5-3.6-10.5-2.4-10.5-2.4 5.5 5.7-2 9.2-2 9.2-2.8 1.7 1.2 3.7 1.2 3.7-3.5.7-4-2.8-4-2.8-1.8-.4-4.4 1.8-4.4 1.8.2-3.5 4.9-1.7 5-5.4.1-3.7-4.3-6.6-17.5-4.8-13.2 1.9-17-2.4-17-2.4-1.2 0-1.3 1.2-1.3 1.2 2 2.1 3 3 2.7 4.4-.4 1.5.7 2 .7 2-2.5-.2-2.6-3-2.6-3 0 1.2-.5 1.2-1.3 2.5s0 2.8 0 2.8c-1.1-.9-3-2-1.2-4.5 1.3-2-2.8-4.5-2.8-4.5-1.7-1.7-6-.1-6-.1-9 1.7-14.2-4-14.2-4-1 0-3-.5-3-.5-9.5 4.2-17.9-5-17.9-5-7.1 1.4-10.4-2-12.5-5.5a12.3 12.3 0 0 0-5.6-5.4c-2.8-1.6-5.5-6.6-2.8-9.2 2.2-2.3 1.6-2.8 1.6-2.8-3.8-6.3 6.5-8.2 6.8-9.8.2-2.1 2.4-3.5 4.7-3.7 2.4-.1 2.4 0 4-1.4 1.4-1.7 4.3.3 4.3.3.7-.4 5.8-4.4 10.3-2.4 4.6 2 8.4.7 8.4.7 3.2-.7 29.9-4.3 29.9-4.3 1.6-2.7 3-5.8 10.3-7.4s12.8-6.4 12.8-6.4c-1.3-1.4-3.5-1.3-4.6-1.4-1.1-.1-3.4-2.2-3.4-2.2-1.4.7-2 .4-11.7 6.3-8.7 5.2-9-5.2-9-5.2h-18.7c-.3 4-3.3 5.6-3.3 5.6l-7 .3c-3.7-2-3.7-8.8-3.7-8.8a65.2 65.2 0 0 0-32.2 7.7c-23.4-11.9-41.7-14.7-41.7-14.7 28.4-3 43.5-11 43.5-11a67.5 67.5 0 0 0 30.4 9.7c.5-5.7 4.4-7.1 4.4-7.1z"/>
<path fill="#ffc221" d="M301 335.7c-5.9 3.4-4.8 5.3-4.3 6.4.5 1 .6 2.1-1 3.8-1.7 1.6-1.5 2.2-1.5 2.2.3 5.7 4.3 7 6 8.4 1.5 1.1 3.9 4.9 3.9 4.9a9.3 9.3 0 0 0 8.7 4.4c2.4 0 2.2-.2 1-1.3l-3.6-3a18.8 18.8 0 0 1 6.2 4.5c6 6.6 11.6 5.7 14 5.4 2.5-.3 2.1-1.8 2.1-1.8-.1-.2-2.4-.4-2.4-.4-9.2-.8-11.9-6.8-11.9-6.8a25.7 25.7 0 0 0 16.7 6.4c2.6-.1 2.4.7 1.9.8-.6.1-1.3 0-2.6-.1-1.2 0-1.2.2-1 .8.4.4 1.5.5 3 .4 1.4 0 .2.2 4 3 3.8 3 13 .6 13 .6-6-1.5-6.8-4.3-6.8-4.3-8.2 1-11.5-3.9-11.5-3.9a34.7 34.7 0 0 0-5.9-3.7c-4.6-2-5.3-6.2-5.3-6.2 1.3 2 3.8 4 7 5 3.3.8 4.1 1.2 4.1 1.2a4 4 0 0 1-2.4-.2c-3.2-1-1.4.3-1.4.3 3.6 2.9 4.5 2.6 4.5 2.6 9.3 1 4.7-2.7 4.7-2.7 6.5 1.6 7.7-.9 7.7-.9 1.4 3 6.4 1.8 6.4 1.8-6.7 3.3-1.7 2.3-1.7 2.3 6.8-1.2 8.2.5 8.2.5 1.7 1.7 3.6 1.5 3.6 1.5s1.2 0 3.7.5c2.6.5 6.6 2.7 10.3 2.3 3.6-.5 4.3.6 4.3.6-.8-.2-2.4-.5-5.2.8-2.9 1.3-7.9 1.7-15.2 0s-7.8-1.4-7.8-1.4a10 10 0 0 1 3.6 4.3c.3 1.2 1.6 1.3 1.6 1.3.5-1.6 2.6-2.3 2.6-2.3 1.5 1.3 5.4 3 5.4 3 .4-.8 0-1.4 0-1.4 2.7 2.7 6 1.9 6 1.9.8-.6.6-2.2.6-2.2 1.1 0 1.3.7 2 1.3 1 .4 3.4.1 3.4.1-1-.4-1.7-1.8-1.7-1.8 3.8-2.5 11.7-1.5 11.7-1.5 5.7 1.1 5 4.9 5 4.9a11 11 0 0 1 2.8 2.3c.5-1.4 0-2.7 0-2.7 2.7 1.2 3.2 4.3 3.2 4.3 3-3.5-2.9-7.4-2.9-7.4 2.9-.4 6-.1 8 .1a14.7 14.7 0 0 1 7 3.3 19 19 0 0 0 6.2 2.7c0-.8-2.2-2.1-2.7-2.4-.5-.2-.7-1-.7-1 2 .4 3.3.2 3.3.2a47.2 47.2 0 0 1-8.7-6.2c2.6.3 4.1-1.3 4.1-1.3-5.5 0-5.7-1.3-5.7-1.3.7.1 3.2.8 6.5.1s7.8 0 7.8 0c-2.4-3.8-11.5-3.1-14.5-3-3 .2-4-.2-4-.2.4-.2 1-.7 3.2-.8 2.3 0 4.6.3 7.2-1.6 2.5-1.7 6.1-1.1 6.1-1.1-.8-1.8-5-2.4-8.6 0-3.6 2.2-7 1.6-7 1.6 5.8-.8 7.4-2.9 7.4-2.9-1.6-.5-2.6.1-6 .8-3.5.7-4.4-.5-4.4-.5 3.7-2.2 6.4-3.1 6.4-3.1-3.2-.8-6.2-2.2-6.2-2.2-3.3 3.2-6 5-12.4 1.7-6.4-3.5-9.8-3-9.8-3a15 15 0 0 1 15.7.6c4.2 2.5 5.5.5 5.5.5-1.4-.8-1.2-1.6-1.2-1.6 10.2 5.2 14.7 2 17 .5 2.2-1.6-1.1-3.6-1.1-3.6-.2 3.2-4.4 4.9-7.7 3.7-3.3-1.1-6.3-2.5-11-4.6-4.8-2-10.6-.9-16.2.3-5.6 1.1-6.2.6-6.8.2-.5-.5-.7-1.8-3.6-.7-2.8 1.2-9.4-1.9-13.5-2.9a25 25 0 0 0-16.5 2.7c-5.8 3.3-8.7 2.5-10.4 1.7-1.7-.8-2.9-3-1-5s2-2.4 1.9-5.2c-.2-2.8-3-4.5-3-4.5 2.6-2.6 3.2-3.3 2.3-4.4-.8-1.1.5-1.1 2-1.8 1.5-.6.8-.7.5-1.6-.4-.8-1.2-.6-1.2-.6-3.4.1-5.3-.9-5.3-.9-5.5-2.5-10.8 2.5-10.8 2.5-3.1-2.5-3.9-.8-4.5-.3-.4.6-1.6 1-3 1.2-1.4.1-3.5.6-4.1 2 0 0-.7 1 0 2 0 0 .9 1.4-.6 3-1.6 1.6-2.2 1.9-1.6 3.4.4 1.5.3 2.7-.4 3.6 0 0-.7-.8-.5-1.8.2-1 .2-1.7 0-2.2 0 0-1.6 1.5-1.8 2.6 0 0-.7-1.7 1.6-4 2.2-2.2 3.3-3.3 2.6-4.2-.5-.7-2.1.4-2.5.7z"/>
<path d="M307.5 360.6s-2.8-2.2-2.5-5.2c.3-3 .3-3.2 0-4 0 0-.6.3-.5 1.5l-.2 2.4s-1.5-2.4-2.2-2.9c0 0 .6-2.6-.2-3.7s-1.6-1.2-2.5-.8c-1.4.4-2.3 1.6 2 5.2 0 0 1.6 1.3 2.7 4 1.1 2.9 3 3.3 3.3 3.5zm14-8.3s-.3-1.6 1.3-4.6a6.5 6.5 0 0 0 .2-6c-.3-.8-.5-.5 1-1.8 2-1.6-.7-3.6 2.5-6.4 0 0 2-1.7 2.4-2.4 0 0-3.2 1.7-5.4 2.6-2.2.9-10.3 4.9-8.5 7.6 1.8 2.7 1.6 2.8 1.3 4 0 0-5-2.6-3.1-6.8 0 0 .7-1.6 2.7-3.6 2-1.7.9.4 4.6-1.8 0 0 3-1.8 4.6-4.1 0 0-2 1.2-2.7 1.4 0 0-4.4.8-6.2 2.6-1.7 2-5.5 5.1-4.3 8.7 0 0-4.3-.5-5.3-5 0 0-8.1 9.9 9 14.6 0 0 3 .9 5.9 1"/>
<path fill="#ffc221" d="M396.2 318.9c6.4-.9 43.2-6.5 46.6-6.9 3.6-.3 5-.8 6.2 2.2 1.4 3.2-5 3.3-5 3.3l-44 5c-2 .2-2.5-.6-2.5-.6l-1.7-2.2s-.5-.6.4-.8"/>
<path fill="#5a3719" d="M306.4 339s-4.8 9.9 13.9 12.3c0 0 0-1 .8-2.7.8-1.5 2.4-4.7.8-6.7-1.5-2 1.3-1 1.6-3.7.5-2.6-.2-2.3 1.1-4 0 0-5.8 2.1-8 4.7-2.3 2.6 3 4.5 0 7.4 0 0-2.8-1-4.3-3.9 0 0-3.6.1-6-3.5z"/>
<path d="M324.7 351.2s4.6 4 10 3.8c5.5-.2 7.9-1.6 9.3-3.8 0 0 1 1.6 1 2.7 0 0 4.8-3.9 13-.4s5.6 2.4 7.4 2.7c0 0-3.5-.6-11.4 3-8.2 3.7-29.6 2.4-29.4-8z"/>
<path fill="#5a3719" d="M317 333.5s2 .4 4-2c0 0-2.7.6-4 2m-15.7 18.6s-3.8-3-1.4-3.6c0 0 1.8-.4 1.4 3.6"/>
<path d="M385.1 371s2.2-3.7 8-3.7c6 0 6.6 2.8 14 3.3 0 0-8.9 2.5-15.1.3a8.9 8.9 0 0 0-6.9.1"/>
<path fill="#5a3719" d="M326.6 353.9s9 5.3 16.8-.5c0 0 .6.6 1.2 2 0 0 6-5.7 16.6.5 0 0-1.3-.2-6.3 2-6.5 2.8-22.9 4.7-28.3-4.1z"/>
<path d="M360.8 359.3s8.5 1 15.6.5c4.4-.2 9.2-1 6.9.4-2.5 1.5-1.3 1.7 8.9.8 10-1-.1 1.9 6.8 2.8 0 0-16.9 8.6-38.2-4.5"/>
<path fill="#5a3719" d="M383 353.9s5-1.8 9.7.3c4.5 2.2 3.8 2.4 6.9 2.7 0 0-2.1 3-7.2.6a31 31 0 0 0-9.4-3.6m5.6 15.2s4.9-2.4 10.3.1c.6.4 2 1 3.5 1.3 0 0-4.1 1.3-8.4 0a30 30 0 0 0-5.4-1.4m-24.2-8.7s11 1 16.8-.2c0 0-6.8 3.3 10.4 1.8 0 0 3.7-.4 3.3.2-.5.6-.8 1 1.1 1.6 0 0-12.7 5.8-31.6-3.4"/>
<path d="M310 351.2s.1.8 2.5 1.9c2.5 1 3.8 3 4.3 4a6 6 0 0 0 3.7 3.1s-8.6 1.8-12.3-2.7c0 0-3-3.1 1.7-6.3"/>
<path fill="#5a3719" d="M377.2 370.9s-3.3-.3-8-1.8c-4.6-1.7-5.8-.3-8.4-2.2-2.6-2-7.9-.8-8.8-.6-1 0-3.8 0-.4-2.3 0 0-2.7 0-3.8-1.5 0 0-1.2 1.3-6 .8 0 0 2.2 3.3-6.3 2.3a11 11 0 0 0 11.8 3.3s-.6 2.6 3.3 3.6c4 1 4.7 1.7 6.8 2.5 0 0 .2-1.6-5-5.4 0 0 2.8-.1 6.8.9s13.1 3.2 18 .4m2.1 3.9s.9 2 3.5 1.5c2.5-.5 6.7-1.2 10.8.9 0 0 .8-3.5-7.5-3.7 0 0-5.1.2-6.8 1.3m-69.6-22.4s-3.2 2.6-.3 5.3c2.6 2.5 6.7 2.3 8.7 2.2 0 0-1.3-.6-2.2-2.2-1.1-1.5-1.1-2.6-3.3-3.6-2.2-1-2.5-1.2-2.9-1.7m-3.2-13.5s-4.9 10 13.9 12.4c0 0 0-1 .8-2.7.6-1.5 2.3-4.7.8-6.7-1.6-2 1.2-1 1.6-3.7.4-2.6-.3-2.3 1-4 0 0-5.8 2.1-8 4.7-2.3 2.6 3 4.5 0 7.4 0 0-2.8-1-4.3-3.8 0 0-3.6 0-6-3.6z"/>
<path d="M355.4 362s4-.7 13.9 3c10 3.7 14.1 2.8 15.6 2.8 0 0-5.6 2.9-14.3-1-7.7-3.4-8.2-2-15.2-4.8"/>
<path fill="#ffc221" d="M417.8 359.8s2.2-.1 3.6.4c0 0 .8-.7 3-1 0 0-1.4-1.2-6.6.6m-6.8-5.6s2.2 0 3-1.2c0 0-1.3-1.4-3-2.2 0 0 .4 1.7 0 3.4m-76.5-25.4s-.5-1.2 2-1.5l33.3-4.8s1.6 0 1.9 1c.2 1.3-.2 2.1-7.7 3-7.5.8-27.3 3.3-27.3 3.3s-2 .4-2.2-1"/>
<path fill="#ffc221" d="M364.7 327.4s0 4.4 4.5 5c4.3.7 5.8-.2 7-2.5.3-.6 1.6-5.2-.3-5.5a7 7 0 0 0-3.1.3c-1.5.7-2.9 1.4-2.5 2.1 1.2 1.7 1.4 2 1 2.2-1.1.3-1.9-.7-2.1-1.4-.2-.8.6-1.3-2.3-.8-1.2.1-2.2.1-2.2.6"/>
<path fill="#ffc221" d="M383.3 324c2.2.3 2 5.1-.6 7.3-3 2.4-5.7 1.5-5.7 1.5-1.6-.6-1.3-.4-.2-2.1 1-1.7 1.6-3.9 1-5.4-.2-.5.3-1 1-1 0 0 2.2-.5 4.5-.3"/>
<path fill="#ffc221" d="M385.3 324.5s1.6 2-.4 5.8c0 0-1 1.1 1.1 1 2-.2 6.6-2.3 6-5.1 0 0 0-.7-1.3-.7-1.2.1-.2-.6.3-.8.5 0 2-.7-2-3.3 0 0-.6-.5-1.4-.3-.6.3-2.7 1-2.7 2.4 0 .5.4 1 .4 1"/>
<path fill="#ffc221" d="M389.6 321s3.3 2.3 3.2 3c0 .6-.3 1.6.6 1.4.8 0 4.2-.8 3.3-3-1-2.3-2-3-3.5-3.7-1.6-.5-2 .2-3.4 1.3 0 0-1 .6-.2 1m-17.1 3.2s.5-1.6-2.4-2.3c0 0 1.2-1 3.7-.5 2.3.5 2.1 2.2 2.1 2.3 0 0-1.9 0-3.4.5m6.2-.4s3.2-.6 4.7-.4c0 0-1.6-3.6-6-2.5 0 0 1.6 2 1.3 2.9m5.6-.8s0-1.2 2.8-2.3c0 0-1.3-1.3-3.3-1.1-2.1 0-2.6.8-2.6.8s2.5.9 3.1 2.5zm1-4s1.9.6 3 1.5c0 0 1.6-1.8 3-2.2 0 0-2.7-1.4-6 .8z"/>
<path fill="#5a3719" d="M294 310.3s7.7-6.5 12-5.7c4.3.8 2.2.3 6.8-.5 4.8-.7 9.6-1.2 11.6-1 0 0-5.8-4-15.9-4 0 0-7.1 2.5-12 5.8 0 0-9.6-5.3-19.4-2.2 0 0 10.7 4 17 7.6z"/>
<path fill="#ffc221" d="m375.6 321.6 1.3-.2s2.1 2.6.6 2.6c-1.3 0-.9-.3-1-1a2.5 2.5 0 0 0-1-1.4zm-9.7.2s-.8 1 .7.8c1.8-.3 1.5 0 3.3-1.3 0 0 1.3-1.3 3.5-.5 0 0 1.8.6 3.3-.1 1.6-.8 1.9-.7 2.7-.7.8.1.8.3 1.8-.5 1.2-.8 3-.2 4.2-1.2 1.2-1 2.7-.1 0-2 0 0-.6-.6-.5-1 0 0 1 .3 1.9 1 .8.7 2 .5 2.3.3 0 0 .2-2.4 2.5-4.6 2.4-2.2 2.4-2.3 1-2.3s-3.7-.6-4.5 0-7.7 5.1-11.8 5.9c-4 .8-7.7 2-10.4 6.2m-108-25s12.5 3.2 15.2 4.5c0 0 .7-2-5-3.6 0 0 13.8-.4 28.2 6.2 0 0 7-6 29.6-4.2 0 0 0-2 .2-3.5 0 0-15.8-.5-30.3-9.3 0 0-13.1 6.4-37.9 10zm69 6c-.7-12.7 4-13.9 4-13.9s2.4.1 4.8.5c0 0-3.8 4.6-2.8 13.7 0 0 .4 1.4-2.8 1.4S327 303 327 303z"/>
<path fill="#5a3719" d="M330.3 311.2s-2.4-2.4-2.6-5.2c0 0 0-.7 2.3-.7 2.4.1 2.7-.1 3.3 1.3.5 1.3 2 4.1 2.4 4.5z"/>
<path fill="#ffc221" d="M335.4 304.9a30.2 30.2 0 0 1-.3-4.9c.2-7 1.3-6.4 1.8-5.5h2.5s-1.8-8-4-3.3a20.3 20.3 0 0 0-1.6 11.2c.2 2.1.4 3.4.7 4.2z"/>
<path fill="#5a3719" d="M362.8 304.9s4.6.8-2.5 2.4c0 0 .3 8.7 8.7 2.6 0 0 5.1-3.2 8.5-4.5 0 0 1.8-.5 1.6-1.9 0 0 .2-1.6-1.6-1.2 0 0-1.5 0-2.5-.3 0 0-1-1.2-1.6-.8-.7.5-2.3.2-1 1.8 1.2 1.4 1.6 1 2.2.6.6-.4 3.3-1.4.8.8s-4.4-1.3-5.2-2zm-23.5 1h-2s-1.2 1.6-2-1l-.7 1.7s2.4 9.3 4.7-.7"/>
<path fill="#ffc221" d="M336.4 295.1s-1.1 6.2.3 9.8l22.5.5s-.3-4.3 0-10.3H356s-.5 5 0 8h-.6s-.4-4.2 0-8h-2.6s-.4 4.6-.1 8h-.5s-.4-4 0-8h-2.5s-.5 4.1 0 8h-.7s-.5-4.1 0-8h-2.9s-.6 3.9 0 8h-.5s-.7-3.9 0-8h-2.9s-.6 4.4 0 8h-.6s-.4-4.2.1-8h-2.7s-.7 3.7 0 8h-.7s-.4-3.2.2-8zm23.7 11.1s-.5-11 1.5-13.8c2.1-2.8 2.6-2.3 6.2 0s8.2 4.8 9 5c.7.3 1.8.6 1.8 2.6s.3 2.6-2.8 0c-.4-.3-1.9-1.6-3-2-2.7-.8.7.6 1.7 2.1.8 1.2 1.6 1-.6 1.6a233.6 233.6 0 0 0-13.8 4.5"/>
<path d="M368 298.3s-1.7-2 .5-2.4c2-.5 2.2 3.2 2.6 5.4.3 2.3-2.6-2.2-3-2.9zm-3 9.7s-2.4.9-.8 1.6c1.6.8 6-2.9 4.5-2.6-1.6.2-3.7 1-3.7 1m3.3-3.4s2.1-.2 1.6.7c-.3 1-1.1.4-1.4.1-.4-.1-1.8-.7-.2-.8"/>
<path fill="#ffc221" d="M379.7 301.7s.5 4.2 4.1 5.3c0 0 2.2.5 1.7-1.3l-.6-2.2c-.3-.7-1.8-1.1-2-1.2-.1 0-.3-.5.7-.1 1 .3 1.1.4 1-.4 0-.7-.6-.4-1.5-.8-.3-.2 0-.5.4-.4.4 0 1.3.4 1.3-1 0 0 .2-.9-.9-.9-1.2 0-1.1-.6-.8-.7.4 0 1.6.8 2-.7.4-1.4-1.7-.5-1.4-1.3.3-.8 1.8.4 1.8-.5.2-.8 1.4-1.2-.7-1.5-.9-.1 0-.6 1-.4 1.2.1 1.9-1.2 2.5-1.7.8-.5 4.5-2.7-.6-2-5 .7-6.5 3.2-6.7 3.7a13.9 13.9 0 0 0-1.3 8.1"/>
<path fill="#ffc221" d="M391.4 305c.7-.1 1.3 0 1.5.4.8 1.7-1 1.2-2.1 2.4s-1.2 1-2.6.6c-1.5-.5-2-2.7-2-2.7-.2-.8.4-.8 1.1-.7 0 0 2.5.3 4.1 0m-5.4-.9s0 .4 1 .5c.7 0 3.2.3 4.8-.2 0 0 .4 0 .2-1 0 0 0-.7-1.3-.4-1.4.2-3.3 0-4-.1-.6-.2-1 0-.7 1.1zm-.2-3s0 1.2 1.2 1.4h3.7c.6 0 1.5-.3 1.6-1 0-.9.3-1.4-1.4-1s-3.6.1-3.8 0c-.2 0-1.3-.3-1.3.5zm.6-2.8s-.4.7-.3 1.2c0 .6 1 .9 2.7.9 1.7 0 3.3-.3 3.4-.9.1-.6.5-1.3-.8-1a12 12 0 0 1-3.8 0c-.7-.3-1.2-.4-1.2-.2"/>
<path fill="#5a3719" d="M450.2 305.1s0 1.1.9 2.4l-48.2-1.4s.6-.4.8-2.3z"/>
<path fill="#ffc221" d="M386.8 296.4s-.4 1.2.4 1.4c.8.3 2.3.5 4.3.2 0 0 1 0 1.3-1 .4-1 .3-.5-2.4-1 0 0-.9-.3 1.6-.3 0 0 1.5 0 1.6-.2.3-.2 2.2-1.8-.3-1.6-2.4 0-1.2-.5 0-.5 1.3 0 1.6.3 2 0s0-.2-.6-.9-.2-.5.3 0c.5.3.8.4 1.3 0 .6-.6-.4-1.3 0-1.2.4 0 .7.9 2.2.2 1.7-.9 3.8-.5 4.4 0 .5.4 2.2 1 3.2 0s-1.2-2-.3-2c1-.2 1.6.2 2-.6.4-.8-1.5-1.4.3-2 1.7-.4.2-5.3-.3-5.7 0 0-2 1.1-4.1 4.5-2.2 3.5-3.5 5.6-6.4 4.5-4-1.6-6.3.7-7 1-1 .7 2.2 1 .3 1-1.8 0-1.8.2-2 .5-.1.2 0 .5.4.6.3 0 1 .6-.1.6-1.2 0-2-.2-1.6 1 0 0 0 .2.6.3.7 0 1 .8-.3.8-.8 0-.8.3-.8.4m4.4 12s-.8.6.3.7c1.2 0 1.8.3 2.2-.3s2-.4 1-1.3c-1.2-.9-1.9-.4-3.5 1z"/>
<path fill="#ffc221" d="M396.2 293.9s4-3.8 7.3-1.2l3.8 3s.4.3-.5 1.1c-1 .9 0 .9 1 .3 1-.5 1 0 1.5.6.5.5 1.2.8-.4.8H404s-2.2.2-1-.8c1-.9.8-2 .3-2-.6 0 0 .6-.4 1s-1.1.8-2 .8-1.4.6-.2 1c1.3.5-.1.8-.8.8s-3.7.2-.6.6c3.2.4-.3.3 2 1.6 2.7 1.5.8 4.6-.2 4.9 0 0-1 .6.3.4 1.4-.2 2.1-.3 1.1.4-.9.8-2.8 3.1-5.4 1.3 0 0-1.3-.5.9-.7 2.2 0-1.8-.6-2.5-1-.6-.4-3-3-1.5-2.7 1.6.3 1-.6 0-.9-.9-.3-1-1.7 0-1.5 1 .4 2.2 1 3.1.9.9 0 .6-.4-1.2-.9-1.8-.6-2.5-.7-2.1-2.2.4-1.5 2.4.6 2-.5-.5-1.2-2.3-.6-1.4-2s1.2-.9 1.6-.7c.4.1 1 0 0-.8-.9-.6 0-1.4.2-1.6"/>
<path d="M399 295s0-.5.8-.4c.7 0 .5-.3.7-.5.2 0 2 .6.3 1.2-.6.2-1.6 0-1.7-.3z"/>
<path fill="#ffc221" d="M403 299.2s-1.4.7-.2 2.1c1 1.3 1 1.7 1 2.5-.1.8 46.4 1.3 46.4 1.3s-.1-3 1.8-4.7z"/>
<path fill="#5a3719" d="M450.9 304.3s.2-2.6 1.6-3.4c.8-.5 1.8-.2 2.2 1.8.7 2.9-1.8 5.4-2.9 4.3-1-1-.8-2.7-.8-2.7z"/>
<path fill="#7b3c20" d="M397.6 315.1s3.1-2.7 3.6-3.8c0 0 8.4 6 7.9.5 0 0 0-1.5.2-2.9 0 0 3 .4 3.5-2l-7.8-.3s-.8-.2-2 1.1-3.7 2.7-6 1.5c0 0-1-.8-2-.1-1 .7-1 .8-.2 1.6s2.5 3.1 2.8 4.4m18-16.4-4.6-.1s-1.6-2.4-4.9-5c0 0-1-.4.8-1.9 2-1.5 2.5-3 2.5-3.8 0-.6 0-1.8.7-1 .6.9 5.3 5.2 6 4 .7-1.3.9-1.9.9-2.3.2-.4.3-1.6 1-.3.7 1.2 1.1.9 1.2 4 0 0 0 3.3.6 4.4 0 0-6-1.9-4.1 2zm-19.8-9.8s3.5 2 5.2-.6c1.6-2.6 2.8-3 1.5-5.7-1.3-2.4 0-3.6 1-4.7 1-1 1.9-.8 2-5 .1-4 3-5.3 4.2-6.6 1.3-1.2 4.4-3.1-.4-4-4.7-.7-14.3-3.2-16.7-6.9-2.5-3.7-3.6-1.5-3.6-1.4 0 .2-.8 2.9 1.6 7.8s4.5 8 7 9.6c2.3 1.7 4.4 2.5 3.2 5.8s-3.2 9.1-5 11.8z"/>
<path fill="#5a3719" d="M408.7 278.5s.5 8.5 6.7 11.5c0 0 1.4-3.3.8-6.6 0 0 2 .2 2.6 1.1 0 0 0-2.4-2.8-3.3-3-.9-1.5-6.4-.5-7 1-.6.7-1.8 0-2.8-.7-1-.8-2.4 1.6-1.8 2.4.6 2-.6.5-1.8-1.4-1.2-1.3-2.7.7-2.7s5.5-2 3.4-2.6c-2-.6-2.7-1.3 0-2 2.9-1 4.3-1.9 2.2-2.1-2.2-.3-3.6-1-1.5-1.3 2-.4-.3-2.5-2.7-2.6-2.5-.1-7.5.8-3.5-2.4s-5.7-.8-1.7-3c4-2-1.4-1.2-2.2-1.2-.7 0-.7 0-.4-1 .3-1.1-.5-1.7-1.8-1-1.1.7-1.1.7-1-.8 0-1.5-1.4-.4-2.3 0-1 .4-3.1 2-4.1 1.2-.8-1-1.3-1.9-4-.3-2.9 1.7-2.2.3-2.1-.5 0-.8 1-3.6-2.7-.5s-.8-3.3-3.7-1.2c-3 2.2-3.3 2.6-3.8 1.7-.4-1-1-1.8-4.3.3-3.3 2-.8-1.3-.4-2 .5-.8 1.9-5.5-1-1.8 0 0-1.4 2.6-4.5-2 0 0-3.2 4.6-4.1 2.5-.9-2-1.7-2.2-2.8-.9-1 1.3-.3 0-.7-1.2-.5-1-.8-3.1-6.3.8-5.4 4.1 2 1.2-2.2 3s-14.3 7.5-5.1 6.2c9.3-1.3-4.5 3.6-1.3 4.5 3.2.9 2.2 3.7 14.3.4 12-3.3 10-.5 16.2-3.2 6.2-2.6-1.4.9 6.8.8 8.3-.3 1.4 0 3 1.6 1.7 1.7 8.6 5.7 15 6.5 6.6.6 8.2-1.8 6.4 1-2 2.8-2.6 3.9-3.7 5-1 .8-4.2 3.1-4.3 7 0 3.9-5 4.5-3.2 8.9z"/>
<path fill="#5a3719" d="M419.4 288s-1.5-1.1-1.5-3c0 0 1 .2 1.5.8 0 0 3.7-4.2-.9-5.7-4.4-1.6-2.2-5.6-.6-5.6 1.5 0 1.8-.4.5-2.2-1.3-1.6-1.2-1.8 1.4-2.2 2.4-.4 2.2-1 1-1.6a8.2 8.2 0 0 1-2-1.6s7.2-3.1 4.9-4.6c-2.4-1.4 0-1 2-2.5 2.2-1.5 2.5-1.8 2.7-2.4 0 0-2 .2-3.6 0 0 0 1.8-1 0-2.5s-2.4-2.7-5.4-2c-2.8.6-1.8-.3-.8-1.5 1.2-1.2.7-1.9-1.4-2.2 0 0 .3-1.2 1.8-2.7 0 0-3.9.3-5.2-.4 0 0 1.6-1 1.6-2.4 0 0-2 .8-4.8.5 0 0 1.7-1.4 1.7-2.6 0 0-4.8 1-7 2.7 0 0-.4 0-.7-.6-.4-.4-.7-1-5.9.6 0 0 .6-2.3 1.9-3.3 1.2-.8 1-2.6-7 2.4 0 0-1.1-.6-2-3 0 0-1.8 2.4-3.1 3.2 0 0-1.2.5-1-1 .2-1.7-.8-.6-1.6 0-.8.3-1.4 1.5-1-1.7s-1.2-3.9-1.2-3.9-2.4 3.6-3.9 4c0 0-2.6-2.6-3.6-4.3-.9-1.6-.9-2.3-1.8.7-1 3-2 3.2-2 3.2s-1.7-1.4-1.9-2.2c0 0-.2.9-.8 1.2 0 0-1.4-1.7-1.3-4 0 0-8.7 4.8-9.8 7.7 0 0-8.2-.5-11.5.1 0 0 .8-2.6 3-4 0 0-2.2-.2-2.3-2.4 0 0 1.7.2 2.8 0 1-.3-1.5-3.4 1.2-3.5 2.7 0 4.4 1.3 3.3-2.3-1.3-3.6-.8-3.6-.8-3.6s4.8 2.8 5.5 2c.8-.6-.6-2 3.6-1.4 4.2.8 3-1.6 4.7-1.8 1.6 0 2.4 1 1.4-6.5s5.1 3.7 1-7.7c0 0-1-3.5-3.6-5 0 0-.6 2.5-3.3.4-2.9-2.3-8.4-3-6-4.8 2.3-1.8 3.4-4 2.7-5.5 0 0-2.8 2.8-7.5.8-3.8-1.7-4.6 1.3-8.4.5 0 0 0-1 3.2-3.6 3.3-2.6-1.8.8-3.8 1.3-2 .5-2.6 0 1.6-3.3 4.3-3.3 12.8-9 11.6-13.9 0 0 2 2.6 7.3.7 5.2-1.8 9.2-2.5 10.7-5.3 1.6-2.6 5.7-5.3 6.8-6 1.1-.6 2.5-1 .9 1.6-1.7 2.6-4.3 7-11.5 10-7.3 3-10 5.1-11.4 6.8-1.3 1.5-8 5-3.6 4.5 4.3-.7 11.7 0 8.2-1-3.4-1-7.4.6-4.2-2.3s3.8-3.8 8.4-5.8c4.7-2 9.8-6.4 9.3-1.6-.5 4.6-9.2 9.7-11.3 11.2-2.1 1.6-1.3 1.4-1.3 2 0 .6-.3 1.9-1.2 2.4-.9.6-.6 1.3-.4 2.6.3 1.4-.2 1.9.4 2.1.7.2 1.3.3 1.5 1.2.3 1 .7 1.1 2 1 1.2-.1 2 0 2 .8.2.7 1.4 1.7 1.5-.5.1-2.3 1-2.7-1.3-1.7-2.3 1-2.8.7-2.7-.4 0-1-.3-.8-1.2-.9-1 0-1.3-1.4.4-2.3 1.7-.9 1.7 0 3.8-1.8 2.1-1.8 2.1-2.2 2.5-3 .3-.9-3 2.4-4.7 3.1-1.6.8-1.1-.5-.8-2.3.2-1.8 4.2-4.3 6-4.3 1.9 0 6 1 4.3 3.6-1.8 2.5-6.9 5.5-4.8 5.7 2.2.2 2.6-.6 3.9.4 1.2 1.2 0 3.5-.5 4.7a9 9 0 0 1-2.4 3s-2.3-4.2-2.2-.9c0 3.3-.5 4.4 0 4.5.5.2 3 1.9 3.8 1.9s-4.4 2.4-2.2 2.6c2.2.2 5.8-1 7-3.3 0 0-4.6-1-6.3-2.7 0 0 5.1-1.2 3.7-6.2 0 0 5.2 1.4 3 3.8-2.3 2.3-3.7 2-1.7 2.6 2 .6 2.8 1.2 2.8 1.2s1.4.7.6 1.7c-.8 1.2-.8 2.8 0 2.7.5 0 2.7-1 1-2-2-1.2 2-1 .3-2-1.6-1-2.1-1.1-2.5-1.6-.5-.4 21-13 10.1-8.3 0 0 2.3-5 5.5-5 3 0 3.3 2.5 1.6 4.5-1.9 1.9-3 4.9-7.2 5.5 0 0 6 3-1.1 7.8 0 0-1.6.7-1 1.2.6.6 4.8-1.8 5.4-3.2a5.9 5.9 0 0 1 3.1-3.2c1.6-.8 9.6-6.2 12-10.3 2.4-4 3-4.1 7.6-7.9s3.9-3 4.5-3.9c.6-.9.8-2.4 3-3.6 2-1.2 10.4-5.8 13-7.7 2.6-1.9 8-5.4 10.2-8.3 2.3-2.9 8.5-6.6 10-6 1.6.8 0 3-3.7 5.7s-12.6 10-14.1 11.2a47.7 47.7 0 0 1-12 7c-2.8.4-2.5 1.4-4.2 3.3s-5.7 5.7-7 6.7c-1.3 1.2-4.5 3.3-4.7 4.9-.1 1.5.6 1.7-2 4a50.5 50.5 0 0 1-12.7 8.7s4.8 1.6 2 4.9c-2.9 3.2-2.7 2.7-2.9 3 0 0 7.2-1.2 2.2 4.5 0 0-1.1 1.7 1.2 0 2.4-2 1.4-4.4 1.1-4.7 0 0 3.9-2.5 8.4-2.5 4.4 0 4.2-.4.2-1.5 0 0 2.9-3.5 5.2-1.7 2.5 1.6 1.7 2.7-.8 4-2.6 1.4-6.3 1.8-9 3.4 0 0 5.2 1 8-1.1 2.8-2.2 3-1.1 3.3-.7.4.4.7 1-.5 2.9-1.3 1.8-1.4 1.8-1.3 2.2 0 .5-.2 1.7-2.7 2-2.4.5-3.7 1.6-2.8 2.8.8 1.2.8 4.1-1.3 3.9-2.1-.3-1.6-2-2.4-2.7-.8-.6-2-1.6-5.8.3-3.8 2-4-.4-3.9-1.7 0 0-2.4 2.2-4.5.3-2-2-.2-2.8 1-3.8s6-3.1 3.1-2.7c-3 .3-7.2.5-8.1-1.6-1.1-2.3 2-2 2.6-1.8.5.1 2.4 1.8 2.6-.3 0-2.3 3.2-2.5 2.1-3-1-.3-2.6 1-3 1.6 0 0-2.2-3.1-5.8-2.2-3.6 1.1 1.1.7 2 .9 1 .1.4 2-2.8 4.9-3.2 2.8-1.8 2 .6 2 2.5 0 8.3 0 5 2.7-3.5 2.9-4.9 4.2-6.6 3.8-1.8-.5.1-1.7 1-2.3.8-.4 1.2-1.3-.5-.6-1.7.6-2.2.7-3.6-1.6-1.3-2.5-.8-1.7-.2-3.3.6-1.6 1.9-3.2.3-2.6-1.6.6-1.4.6-1.3-1.2.1-1.8-1.8-2.2-1.8-2.2s.9 1.8.1 3c-.6 1.1-.7 1.5.4 1.8 1.2.3 2.2 1.3.7 2.3-1.6 1-1.3.9-.4 1.5 1 .7 2.4 1.4.9 2.9-1.5 1.4-.3 1 .5 1s2.4.6 2.4 2c0 1.5 0 1.8 2.4.5 2.4-1.4 7.2-1.2 7.2.6-.1 2-.7 2.5 2 .8 2.5-1.6 3.6 1.5 5.4 0 1.7-1.5 2.9-3 5-.4 2 2.6 1.3 3.3-1.2 5.2-2.4 1.8 1.3.4 3.1-.6s7.2-1.6 10.3-.2c3 1.3 3.9 1 6 0 2.4-.8 3.5-1 6.9 1.2a13 13 0 0 0 7.8 2.6s-3.7 1.5-8 1.7c-4.2.4-6.4 1-7.2 1.7 0 0 2.5 1.7 3 3.5 0 0 2.8-.4 4.1.1 0 0-.6 2 1 3.1 1.8 1.1 3 1.5 1.7 3s2 .8.1 2.8c-1.8 2.2-2.3 3.1-2.4 4.9 0 1.6.4 1.8-1.2 2-1.6.2.3 2-.5 4.3-.7 2-5.3 1.8-5.1 7.7 0 0 1.3-2.8 4-5.4 2.7-2.4 2.8-2.7 2.7-4.2 0-1.5-.1-1.2 1.4-2.3 1.3-1.2-.7-2.2.8-3.9 1.4-1.6.1-1.3 1.9-2.9 1.6-1.6-1.6-1.8.2-3.5 1.6-1.6-4.3-3.7-2.5-4.7 1.7-1.1 4.6-2.7-5.3-2.5 0 0 2.4-4 10.7-3.1 0 0-2 1.6-2.3 3.1l1.6.7s-.4 1.1-2 2.4c0 0 4.5 2.5 5.2 4 0 0-2.7.8-3.4 2 0 0 1.2 1.4 1.6 3 0 0-3-.3-3.4 2-.4 2.2-1.3.7-1.3 2s0 1.9-1 2.1c-1 .2-.2 1.3 0 2 .1.8.5 2.5.4 3 0 0-1.6 0-2.3.2 0 0 .5 3.3-1.3 3.7-1.8.5 1 1.2-1 1.5-1.9.3-1.6.5-4 4.7 0 0 2-1.1 4-2.5 2.2-1.4 0-1 3.4-4.3 3.4-3.5 2.8-3.7 2.5-5.4-.3-1.7-.3-3.1.9-4.8 1.3-1.6 1.6-3.5 6.1-3.2 0 0-1.3-3-3-3.7 0 0 2.2-1.5 4.4-1.6 0 0-2-2.5-6-4.7 0 0 3.3-2.9 4.1-4.2 0 0-1.5.3-2.8 0 0 0 .6-1.3 3.6-3.3 0 0 1.7 1.5 1.5 3.1 0 0 5.2-2.9 8-2.5 0 0 1.5 3.6-5.6 10.5 0 0 4.4.4 6.3.2 0 0-1 3.3-6.4 5.3-5.3 2 1.1 4.4-4.3 4-5.4-.4-3.8 1.4-3.6 4.1.1 2.8.3 5.7.2 6.4 0 0-4.3-1.4-4.2 2.8 0 4.2-2.3 5.1-2.7 5.5 0 0-1.3-1-3.2-1.8 0 0-2.7 5.1-7 8.1z"/>
<path fill="#7b3c20" d="M413.2 235.2s1.4-.2 4 1.4c2.4 1.6 4.9-1.7 2.2-2.5-2.9-.8 0-1.9 2.4.2 2.6 2 3.6 1 4.5.3 1-.7 2-1.2.3-2.3-1.7-1.2 1.2-.6 2.5.3 1.3.8.8 1.6.6 1.8-.2.3-.3 3 2.2.5 2.6-2.6 3.9-5.1 3.8-6.4 0 0 1.4.9 1.6 2.5.2 1.6 2.2-.8 2.8-1.7.7-.8 1.7-3.1 1.6-4.8 0 0 1.7 2.7 4.3 0 2.5-2.6 1.5-1 4.5-1.8a18.8 18.8 0 0 0 9-5.5c2.1-2.6 2.3-.8 5-1.5s8.1-4.4 8.6-6.5c.5-2 .4-3.3-.3-2.5-.8.6-.5 0-1.6-.6-1.3-.8-3 .9-3 .9s1.7 1.3.4 1.9c-1.3.5-2.4 2.4-5 1.6-2.4-.7-5 2.4-5 2.4s2.2 1.7-.8 2.8c-2.8 1.2-2.4 1.5-4.1.3 0 0-3.1 3.9-5 4.7 0 0-.7 0-1.2-.8 0 0-2 2.3-3 2.7 0 0-1.4-1.2-2.5-1.6 0 0-2.5 3-4.5 4 0 0-.6-1.2-1.9-2 0 0-.6 4-4.9 6.3 0 0 .3-1-1.9-2.5 0 0-5.3 4.6-7.3 5.1-2.1.4-.3-1 0-1.6.3-.6 1.6-2.5-1-3.3s-2 .6-2.6.8c-.6.3-.6-.5-2.4-.2-1.6.2-1.4 1-2.1 1.3-.8.2-3.8-.5-3.6 1.4.2 2 1.6 3.4-1 4.5-2.7 1.2 1 1 4.4.4"/>
<path fill="#5a3719" d="M423.9 229.7s.6-2.6-1.6-3.8c0 0 14-2.2 3.4-7.6 0 0 12.7-2.5 9.7-6.5s-6-3.3-6.3-3.1c-.4 0 2.7-2.4 3.5-2 .8.3 11 4.1 8.3.8-2.5-3.3-2.3-3.1-2.7-4.1 0 0 3.3 0 8.4 4.9 0 0 1-1 1-3.1 0 0 3.5 1 4.7 2.1 0 0 .6-1.2.3-2 0 0 3.2 1.7 4.3 3.5 0 0 1.4-1.2 1.6-2.7 0 0 3.1 1.3 4 2.3 0 0 1-1.4.6-3.3 0 0 5.1 1.5 5.9-1.6 0 0 5.1 1 1.8 3-4.4 2.7-.5-.6-5 2.5-3.4 2.5-5.3 5.3-7 4.7-1.2-.5-2.7 3-4.3 1.4-1.6-1.8-1.6-1-2.9.8a26.1 26.1 0 0 1-3 3.7s-.8-.5-1.6-1.3c0 0-1 1.7-2.2 3 0 0-1-1.4-2.7-2.2 0 0-2.6 3-4.1 4 0 0-1.6-1.5-3.1-2 0 0-.3 4-3.4 6 0 0-.6-1.2-2.7-2 0 0-1.5 2.4-4.9 4.5z"/>
<path fill="#5a3719" d="M416.5 223.5s-1.7 1.3-.6 2.7c1.1 1.4 1.2-.3 2.6-.3 1.4-.2 18.6-3.2 3-7.8 0 0 .6-.7 3.3-.8 2.7-.4 12.5-3 8-6.5-4.6-3.4-8.5 1.2-4.6-3 3-3.3.6-4.9.6-4.9l-11 7.2c-2 1-5 3.3-1.5 4.3 3.4 1 5.7-3.7 6-2.6.3 1-6.9 5-5.8 6.9 1 1.8.8 3.4 2.5 3 1.8-.3 6.6.9 2.7.8-3.9-.2-5.2 1-5.2 1"/>
<path d="M422.7 214.8s-1.6 1.2.5.6 6.3-1.5 5.6-2.5c-.9-1.2-3.6.1-6 2z"/>
<path fill="#7b3c20" d="M450.4 196.9s8-.3 11.3 2c3.3 2.2 4.9 3.7 5.9 4 0 0-.2 3-5.3.8 0 0 .3 1.5-.3 3 0 0-1.9-1.4-4-1.8 0 0-.5 1.1-1.1 1.8 0 0-2.3-2.3-5-3l-.8 1.6s-2.8-1.7-5-1.7c0 0 .5 1.7 0 2.5 0 0-5.7-4.7-10.9-4.1 0 0 2.5 3.7 4.1 5.5 0 0-10.5-.8-8.7-6.5 1.7-5.6-.1-4.2 6.7-4.1z"/>
<path fill="#5a3719" d="M401.3 212.5s-1.2 1 0 1.8 5.3-2.2 5.9-2.6c.6-.5 2-.4 0 1.2s-4 3.2-5.5 4.9c0 0 7-2 11.5-5.8 4.5-4-.2-1.4 7.5-5.1 7.7-3.8 11.7-9.7 7.6-9-4.1.6-7.9 5.3-11.1 7.1-3.2 1.8-5 2-4.5 1s2.8-.7 7.3-4.2c4.4-3.6 3.4-3.2 3.4-4.5 0-1.3-1.6-4.4 5-7.9 6.7-3.5 27.4-15.5 29.2-19.8 0 0-6 .7-14 6.6a74.3 74.3 0 0 1-14.4 9.4c-2.2.8-2 .2-3.3 2a183.2 183.2 0 0 1-10.8 10.4c-1.4 1-1.9 1.8-2 4 0 1.1-9 8-11.7 10.5z"/>
<path fill="#5a3719" d="M428.7 196.7s-1.7.6-3.1 0c-1.5-.8-1-4 2.6-5.9a53.3 53.3 0 0 1 13.4-5.1s-.5 4-10.8 7.6c0 0 .7 2-2.1 3.4"/>
<path fill="#aa5323" d="M432.3 194s.3 1 0 1.9c0 0 19 1.8 29-9.8 0 0-13.6 1.3-19 4.5 0 0 3.5-4.2 13.6-7.7s14.3-8 15.2-10.3c0 0-13 4.5-19 4.5 0 0-1.2 0-2.4.6-1.2.7-9.3 6.6-11.5 7.6 0 0 4.6-.4 6.2-1.8 0 0-3.2 8.5-12.1 10.5"/>
<path fill="#5a3719" d="M395.7 204.9s-2.5 1.8-1.5 2.6c1 .9 2.8 1 6.5-2 3.8-3.3 12.9-11 7.1-11.3 0 0-7.3-.5-7 4 .2 4.5-4.8 6.4-5.1 6.7m-17-2.7s4.9 2.9 3 5.2c0 0 14.9-12.6 10.6-15.4-4-2.7-7.3 2.5-6.3 3s3.1-.4 2.4.5a82.5 82.5 0 0 1-9.7 6.7m-3.9-4s3.3 1 3.5 2.4c0 1.3 9.8-6.9 7.2-10.4-1.2-1.5-6.5-2.2-6.8 1-.3 3 4.9-.4 3 1.8-2.2 3-6.1 4.7-6.9 5.2m34.8-6.9s-2 1.5-.1 2.4c1.9.9 3-.5 4-1.3.9-.9 5.5-4.3 6.5-6.4 1.2-2.2 2.7-2.9 4.4-4 1.7-1 13.5-7 20.8-13.5 7.3-6.5 4.2-4.8 11.7-9s12.5-8 14.1-12.6c0 0-3.5 1.2-6.5 3.2-3.1 2-10 6.3-11.5 7-1.4.5-3.2.6-4.3 1.7-1 1.1-1 2.4-4.6 5.4-3.7 3-22.4 16.2-24.7 18.1a357 357 0 0 0-9.8 9"/>
<path fill="#aa5323" d="M394.6 195.7s2-1.1 5.9-.8c3.8.2 19-14.6 23.4-17.5a364.4 364.4 0 0 0 20-14.7c2-1.9 2.3-3.8 3.9-4.9 1.6-1 3.1-.9 6.8-3 3.7-2.1 21.6-12.8 20.6-19 0 0-26.7 15.9-32.8 21.1a400 400 0 0 1-26.3 18.9c-3 2-5.4 5.2-10.5 9.4-5.1 4-10.2 7.6-11 10.5"/>
<path fill="#aa5323" d="M389 190s4.9-.5 5.6 2c0 0 10.5-7.3 13-10.3 2.4-3-.9-1.3 5.1-5.2a634 634 0 0 0 28.9-20.4c2.8-2.5 8.3-5.8 12.6-8.8 4.4-2.9 21.3-11.2 19.4-18l-15.2 10.1c-2.8 1.9-4 .8-6.8 3-3 2.3-9.2 6.7-10.3 8.2a172 172 0 0 1-15.3 11.8c-4.7 3-15 9.1-20.1 13.5a643 643 0 0 1-17 14z"/>
<path fill="#aa5323" d="M373.7 188.6s2.4 0 3.3 1c0 0 4.5-4.1 9.4 0 0 0 18-12.3 19.8-15.3 1.8-3 4.8-3.1 11.7-8.2 7-5.1 11.4-7.3 16-10.8 4.7-3.7 8.7-7.8 12-10 3.3-2.1 11.8-7.7 10.4-12.4 0 0-6.9 3.8-11.3 8.7-4.5 5-4 .8-8.6 4.8A88.2 88.2 0 0 1 419 159c-5.8 2.9-2.3 2.6-6.6 5.3-4.1 2.7-3.8 2.3-5.4 2.7a10.6 10.6 0 0 0-5.4 3.2 53.6 53.6 0 0 1-10.3 6.9 113.6 113.6 0 0 0-17.7 11.6z"/>
<path fill="#aa5323" d="M379 179.3s-.9-2 .8-3.5c1.7-1.4 5-5.2 5.4-7.5.5-2.5.1-2 5-4.1a200.7 200.7 0 0 0 40.8-23c2-1.6 7-5 8.9-6.6 0 0 .9 2.6-1.3 4.5a237 237 0 0 1-23 15.8c-2.3 1.3-8 4.2-10.1 6-2 1.6-1.7 2.1-10.9 6.6-9.1 4.4-9.5 5-9.3 5.3.3.4 4.4-1.4 6.4-2.6 2-1.1 9.4-4.6 11.7-6.4 2.2-1.8 6-4.5 7.6-5.4 1.6-.8 15-9 19-11.9 3.8-2.8 4.9-3.7 5.7-3.2.8.4 2.2.4.5 2s-7.2 6.6-9.3 8c-2.1 1.3-8.7 5.2-10.5 6.1-1.8 1-2.5 2.7-3.6 3.5-1 .8-4 2.8-7.6 3.6s-4.2 3.6-6.6 5.2c-2.4 1.5-19.3 10.6-19.8 11 0 0 1-1 .3-3.4z"/>
<path fill="#aa5323" d="M437.5 141.3s-.9.8-.4 1.3c.6.7 3 2.3 6-.6a113.6 113.6 0 0 1 13.4-10.8c2.4-1.5 3.7-2.9 3.6-5 0 0-12.1 6.5-22.6 15.1m16.7-1s1.8-3.2 6.4-6.1c4.6-3 11.5-7.2 12.3-8.2 0 0 1.6 1.8-1.9 4.1a332.5 332.5 0 0 0-11.4 7.5 15 15 0 0 1-5.4 2.6z"/>
<path fill="#7b3c20" d="M361.4 174s-5 2.7-3.2 4.4c1.8 1.6 4.5 1.1 5.7.6 1-.4 3.2-1 3.5-1 .3-.1 4.7-1.4 5.9-3.5 1-2 4-4.4 6.4-6 2.4-1.7 3.3-3.5 2.9-4.7 0 0-20 9.4-21.2 10.2m-30.2 23.4s3.6-2.1 8.5-.8c0 0-.2-1.1-1-1.8 0 0 6-1.5 7.3-4.2 1.3-2.7 1.6-2 2.7-2.8 1.2-.8 9.2-7.3 8.3-8.7-1-1.4-1.1-3.2-1.8-3.9 0 0-1.6 2.3-9.5 6-7.8 3.9-16.5 6.7-22.9 15.2-6.3 8.6-5.6 13.5 2.1 15.7 0 0 5.4-3.4 18.7-2.3 13.3 1.3 17.8 6.2 18.7 7.1.8 1 3.5 4.2.9 9.8 0 0 2.7 1.1 2.8-1.4.3-2.6.4-2 1-1.7.7.5 1.4.5 1-1.4-.2-2-1-6.3-2.4-7.8-1.1-1.5.3-.8 1-.6.8.4 3.6 2.7 2-1.6-1.7-4-2.2-2-2.3-1.9 0 .3-.3 1.3-4-1.4a28.6 28.6 0 0 0-9.4-4.8c-2.4-.6-.7-.6.8-1.1 1.5-.6 3.3-.8 4-2.5 0 0-1.4.4-4-.6a14 14 0 0 0-12.3 2.2s1.3-5-2.6-4.7c-4 .3-6.6.2-10.8 3.5 0 0-.2-5 3.7-7.6 4-2.6 3.4-1 5.6-1.7 2.2-.6 2.4-2.8 1.4-3.6 0 0 5.2 1 13.8-6.2 0 0-4.7 6-10.3 7.3 0 0-.9 3.3-6 4-5 .6-4.9 3.6-4.9 4.3z"/>
<path fill="#5a3719" d="M316.6 227s2.4-15.1 16.6-16.2c12.2-1 16.2.6 18.6 1.4s8.5 2.5 6.1 4.5c-2.3 1.9-3.6 1.6-3.6 1.6s2.6-3 .2-3.6c-2.5-.4-2.6 1-3 2.2-.4 1.4-.4 2.8-1.7 3.8 0 0-1.2-1.4-3-.2s-.2 1.3.5 1.1c.7-.2 1.6-.6 1.4.6-.2 1-1.1 3-4 4.5-3 1.4-2.9 1.4-6.3 2-3.5.5-6.7 1.9-11.1 5.6-4.5 3.9-9.3 2.6-10.3-1.6-.8-3.7-.4-5.7-.4-5.7"/>
<path d="M332.7 226.4s1.3-3-1-4.3c0 0-7.4 1.3-9.7-.9 0 0 8-.5 13-2.5 5-1.8 3.6-3.2 1.9-3.6-1.8-.3-5 .5-5.3 2 0 0-1-1.5.3-2.7 1.3-1.1 3.2-1.3 5-.8 1.8.5 3.3 1.3 9.1-1.7 0 0 3.4.8 3.5 3 0 2.3-.2 3.2-.6 3.5-.2.4-.6 1-1.3 1-.7 0-1.7-.2-2.5 1.3-.8 1.6-1.4 3-2.9 4 0 0 1.7-4.9-2.6-6 0 0-3.5 2-6.2 2.2 0 0 3.5 3.2-.8 5.5z"/>
<path fill="#5a3719" d="M340.4 217.4s-1.6-1.7.5-2c2.2 0 5 1.5 4.4 2.9-.5 1.3-3 1.2-4.9-.9"/>
<path fill="#fff" d="M461.4 193.7s-4 1.1-.3 3.6c3.6 2.4 5.4 4.5 8 5.1 2.7.7 5.4 1.6 5.4 4.3 0 2.6-.6 3.7-2 5.5-1.5 1.9.8 2.6 2.7 1.6l4.8-2.3c1.2-.8 3.3-.7 1.5.3-2 1-4 1.6-1.5 1.6 2.4.1 17.3.4 20.4-.6 3.1-1 7.2-1.3 7.5-5.3 0 0 .2-1.8 1.3-2.6 1.2-.8 2-2.5.3-1.4-1.7 1.3-3 1.8-3.3 1.5-.3-.4-.5-.6.8-1.2 1.2-.6 1.9 0 3-1.6 1.2-1.8 1.1-1.5.5-2.2-.6-.6-2-1-1.3-1.8.7-.9 1.3-3.3-1.4-2-2.7 1.5-8.1 5.2-10.7 5.8-2.4.6-4.3 1.3-7.6 2a30 30 0 0 0-9 3.4c-3.6 2-3.3-1.2-2.7-1.6 0 0 1.4 2.5 5-.7 3.6-3.1 2.4-.2 11.3-3.1 8.8-2.9 6.7-3.4 10.1-5.1 3.6-1.8 6.8-2 4.4-4.4-2.5-2.4-2.7-2.6-5.8 0a36.8 36.8 0 0 1-17 7s20-8.6 18-9.8a22.1 22.1 0 0 0-5.7-2.7c-1.4-.4-1.8-.7-5 .8-3.2 1.4-3.7 1.7-4.6 1.8-1 0-3.7.6-7.5 2.6s-5.8 2.8-8.5 4.3c0 0 1.8-3.6 9.8-6 7.8-2.3 11.8-4.4 11-4.8s-2.9-.9-4.3-.5c-1.5.2-1-.2-6 1.8-4.7 1.8-2.7 1.4-6.5 2.3-4 .8-5.5 1.6-7.4 2.4 0 0 .8-1 3.4-2 1.4-.4-1.5-1 2.3-1.1h1a34.3 34.3 0 0 0 9.2-3.4c-.8-.2-5.4-.7-10.2 1.6-4.7 2.1-2.6 1.4-4.2 1.6-1.6.4-5.2 2.6-6.4 3.6-1.2 1-2.8 1.7-2.8 1.7"/>
<path fill="#5a3719" d="M344.1 215.3s1.9.6 2.4 1.9c.5 1.3 1.6-.6 1.6-1.2 0-.5-1.2-3.2-3.2-2-2 1.2-1 1.2-.8 1.3"/>
<path fill="#7b3c20" d="M339 241.1s3.9-1.8 7.3-1.6c0 0-1.4-4.7 1-4 2.3.9 1.6.6 2.1.6 0 0 .2-3.1-.5-4.4 0 0 2.5.6 4.9.6 0 0-2.3-4.4.2-7.5a7.4 7.4 0 0 0 4.5 3.7v-2.6s1.8-.2 3.3.5c1.4.8 2.6-8-1.7-10 0 0-1 1.7-5 2.5s-4 1.6-5.6 4.7-3 3-6.5 5.2a17.8 17.8 0 0 0-5.5 7s1.7 2 1.5 5.3"/>
<path fill="#999" d="M472.5 189.7c1.4-.2-1.5-1 2.3-1.1h1a34.3 34.3 0 0 0 9.2-3.4c-.8-.2-5.4-.7-10.2 1.5s-2.6 1.5-4.2 1.8a22 22 0 0 0-6.4 3.5c-1.2 1-2.8 1.7-2.8 1.7s-4 1.1-.3 3.6c3.6 2.4 5.4 4.5 8 5.1 2.7.7 5.4 1.6 5.4 4.3a7.4 7.4 0 0 1-2 5.5c-1.5 1.9.8 2.6 2.7 1.6l4.8-2.3c1.2-.8 3.3-.7 1.4.3-2 1-3.9 1.6-1.4 1.6 2.4.1 17.3.4 20.4-.6 3.1-1 7.2-1.3 7.5-5.3 0 0 .2-1.8 1.3-2.6 1.2-.8 2-2.5.3-1.4-1.7 1.3-3 1.8-3.3 1.5-.3-.4-.5-.6.8-1.2 1.2-.6 1.9 0 3-1.6 1.2-1.8 1.1-1.5.5-2.2-.4-.3-.9-.5-1.1-.9 0 0-1-.8-2-.1a29.4 29.4 0 0 1-7.4 2.8c-1.8.2-3.8 1-7 2.6s-8.8 5-9.7 1.8l-2.8 1.2c-3.7 1.9-3.4-.8-2.7-1.6 0 0-2 2.2-1.9.3 0-2 1.3-1.7 3.5-2.3 2.1-.7 5.5-2 4.1-3.2-1.4-1.1-2.9 1.2-4.4 1.9-1.6.7-4.7 1.3-5.2-1-.5-2.2-.5-3.8-4.6-4-4.2-.2-4.1-2.9-3-4 1.3-1.1 2.2-3 6.2-3.8"/>
<path d="M485.9 210s6.5-2.9 12.5-4.3 1.3.2.3.5-10.4 3.4-12.6 4.5c-2.2 1-1.8.1-.2-.5zm1.4 1.5s7.4-2.5 8.8-1.5c1.4 1 .2.6-1.4.8-1.6.2-6 1-7.2 1-1.2 0-.2-.3-.2-.3m11.9-2.6s1.4-.3 1.5.3c.1.5-.6.6-1.4.5-.8-.2-1.4-.6-.1-.8"/>
<path fill="#fff" d="M305 273s-.3-6.5 3-9.8 19-19.7 21.4-24.3c0 0 2 1.4 2.1 4 0 0 2.7-4.5 4.8-6.3 0 0 1.9 2 1.6 5.8 0 0 3.8-2 9.7-2 0 0-2.3 2.4-2.3 4 0 0 8.1-1 12.5-.2 0 0-11.3 6.2-8.2 6.8 3.3.6 6.5 0 6.5 0s-3.6 3.6-9.3 4.3c0 0 7.3 0 8.8 1.6 0 0-7.1 1-12.8 5.4 0 0-.6-.3-.6-1.9 0 0-.2 1.5-1.8 2.9-1.7 1.3-5.5 4.2-7 5.7-1.4 1.4-4 4.3-7 4.2 0 0 .6-2.3-1.5-3a6 6 0 0 0-6.3 1.6s-7.6.2-10.1.5c0 0 1.7-2.7 3.3-2.7 1.6 0 8 1 8.6-3.4.5-4.3-4.1-3.2-2.4-5.7 1.8-2.6 1.4-2.5 1.5-2.8 0 0-1.5.8-2.3 3.1a11.4 11.4 0 0 1-4.5 6.4 16 16 0 0 0-5.2 5.3s-1.4.2-2.6.5z"/>
<path fill="#fff" d="M312.3 269s.2-.7 2.4-1.2c2.3-.4 2.5-1.4 2.1-2-.3-.3-1.6-.3.5-2.9 0 0 .8.3 1.3.9.6.6 3 5.8-6.3 5.3z"/>
<path fill="#999" d="M307 264.5c0 4.3 5.7 2.7 5.7 2.7a22.9 22.9 0 0 0-4.1 3.7c.4-2-3.2-2.6-3.2-2.6a13 13 0 0 1 1.6-3.8m20.6-23 1.8-2.6s2 1.4 2.1 4c0 0 2.7-4.5 4.8-6.3 0 0 1.9 2 1.6 5.8 0 0 3.8-2 9.7-2 0 0-2.3 2.4-2.3 4 0 0 8.1-1 12.5-.2 0 0-11.3 6.2-8.2 6.8 3.3.6 6.5 0 6.5 0s-3.6 3.6-9.3 4.3c0 0 7.3 0 8.8 1.6 0 0-2.2.3-5 1.3 0 0-2-2-8.1-1.6 0 0 4.7-2.7 8.5-3.6 0 0-1.6-2.1-4.3-.2 0 0-5.1-3.5-.8-6.5 0 0-3-.6-5 .8 0 0 0-2.5 2.2-3.5 0 0-5.7-1-7 3.2 0 0-1.2-1.7-.6-3.6 0 0-3.5 2-5.1 4.2 0 0-.6-4.3-2.8-5.9m-11 27.3c-1 .3-2.5.4-4.3.3 0 0 .1-.6 1.4-1 0 0 .3.7 2.8.7"/>
<path d="M327 252.6s2.6 2 3.5 3.2c0 0 2.4-1.6 3.2-3 0 0 2 1.2 2.5 3 0 0 1.4-.8 1.6-2 0 0 3.3.6 4.4 1.6 0 0 .5-3 0-5.1 0 0 2.3.2 3.7.8 0 0-1.3-2.1 5.3-4.8 0 0-5 1.1-7 3.2 0 0-2 .2-3-.4v4.7s-1.2-.6-3.7-1.2c0 0-.6 1.1-1 1.4 0 0-1.7-1.4-2.3-2.9 0 0-2.5 2.2-3.3 3.1 0 0-2.4-1.6-4-1.6z"/>
<path fill="#ffc221" d="M312 285.2s1 .5 3.4-1.5 9.3-6.3 9.9-9.8c.7-3.5-2.2-3.7-4.4-2.7-2.3 1-1.3 3-1.2 3.6 0 .6.2 3-3.5 6.4l-4.3 4z"/>
<path fill="#ffc221" d="M311.2 286.2s-5.5-2.3-.6-4.7 7.1-3.1 7.7-5.2c.6-2 .3-1.7-1.6-.9-1.8.9-8.8 4.1-9.8 1.1 0 0 2.8 1.1 6.4-.6 3.6-1.8 6.6-2.3 4.3-3-2.3-.7-10.6.2-11.9.6-1.4.4-1 .3-1.3 1.6-.2 1.4-1.7 4.3-2.3 5-.5.9-2 4.4.6 6a9.2 9.2 0 0 0 8.5.1"/>
<path d="M309 274.3s-1.2.2-1 .7c.3.4.6.4 1 .4.5 0 1.2-.3 1.3-.5 0-.4-.8-.8-1.2-.6z"/>
<path fill="#fff" d="M310.8 285s-2.5-1.2.4-2.8c3-1.6 6-3.2 6.5-3.8 0 0-1.4 2-6.8 6.6z"/>
</svg>

After

Width:  |  Height:  |  Size: 32 KiB

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