From 70a755fbae8dfafc3cf63cda3ad6d78510adafff Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Thu, 7 May 2026 16:47:52 +0200 Subject: [PATCH] add general settings --- client/ui-wails/frontend/.prettierignore | 7 ++ client/ui-wails/frontend/.prettierrc | 10 ++ client/ui-wails/frontend/package.json | 6 +- client/ui-wails/frontend/pnpm-lock.yaml | 41 +++++++++ client/ui-wails/frontend/src/app.tsx | 3 + .../frontend/src/components/Button.tsx | 92 +++++++++---------- .../src/components/FancyToggleSwitch.tsx | 2 +- .../frontend/src/components/HelpText.tsx | 2 +- .../frontend/src/components/Input.tsx | 11 +-- .../frontend/src/components/Label.tsx | 2 +- .../frontend/src/components/SwitchItem.tsx | 41 +++++++++ .../src/components/SwitchItemGroup.tsx | 46 ++++++++++ .../frontend/src/components/ToggleSwitch.tsx | 8 +- .../frontend/src/layouts/MainRightSide.tsx | 7 +- client/ui-wails/frontend/src/lib/welcome.ts | 23 +++++ .../settings/ManagementServerSwitch.tsx | 21 +++++ .../src/modules/settings/Settings.tsx | 54 ++++++----- .../src/modules/settings/SettingsContext.tsx | 31 +++++-- .../src/modules/settings/SettingsGeneral.tsx | 78 ++++++++++------ .../src/modules/settings/SettingsSection.tsx | 18 +--- .../src/modules/settings/useManagementUrl.ts | 86 +++++++++++++++++ 21 files changed, 450 insertions(+), 139 deletions(-) create mode 100644 client/ui-wails/frontend/.prettierignore create mode 100644 client/ui-wails/frontend/.prettierrc create mode 100644 client/ui-wails/frontend/src/components/SwitchItem.tsx create mode 100644 client/ui-wails/frontend/src/components/SwitchItemGroup.tsx create mode 100644 client/ui-wails/frontend/src/lib/welcome.ts create mode 100644 client/ui-wails/frontend/src/modules/settings/ManagementServerSwitch.tsx create mode 100644 client/ui-wails/frontend/src/modules/settings/useManagementUrl.ts diff --git a/client/ui-wails/frontend/.prettierignore b/client/ui-wails/frontend/.prettierignore new file mode 100644 index 000000000..c78cb7cc3 --- /dev/null +++ b/client/ui-wails/frontend/.prettierignore @@ -0,0 +1,7 @@ +dist +build +node_modules +pnpm-lock.yaml +wailsjs +*.min.js +*.min.css diff --git a/client/ui-wails/frontend/.prettierrc b/client/ui-wails/frontend/.prettierrc new file mode 100644 index 000000000..75232daca --- /dev/null +++ b/client/ui-wails/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/client/ui-wails/frontend/package.json b/client/ui-wails/frontend/package.json index e3ae53e97..68b744a1d 100644 --- a/client/ui-wails/frontend/package.json +++ b/client/ui-wails/frontend/package.json @@ -8,13 +8,16 @@ "build:dev": "tsc && vite build --minify false --mode development", "build": "tsc && vite build --mode production", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@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-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -41,6 +44,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.1", + "prettier": "^3.8.3", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.3", diff --git a/client/ui-wails/frontend/pnpm-lock.yaml b/client/ui-wails/frontend/pnpm-lock.yaml index e28fbb174..1e17913ea 100644 --- a/client/ui-wails/frontend/pnpm-lock.yaml +++ b/client/ui-wails/frontend/pnpm-lock.yaml @@ -17,6 +17,9 @@ dependencies: '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) @@ -91,6 +94,9 @@ devDependencies: postcss: specifier: ^8.5.1 version: 8.5.12 + prettier: + specifier: ^3.8.3 + version: 3.8.3 tailwindcss: specifier: ^3.4.17 version: 3.4.19 @@ -1024,6 +1030,35 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -2151,6 +2186,12 @@ packages: source-map-js: 1.2.1 dev: true + /prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + dev: true + /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true diff --git a/client/ui-wails/frontend/src/app.tsx b/client/ui-wails/frontend/src/app.tsx index b5e7bef82..783898142 100644 --- a/client/ui-wails/frontend/src/app.tsx +++ b/client/ui-wails/frontend/src/app.tsx @@ -10,6 +10,9 @@ import { Main } from "@/layouts/Main.tsx"; import { Settings } from "@/modules/settings/Settings.tsx"; import { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; +import { welcome } from "@/lib/welcome"; + +welcome(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/client/ui-wails/frontend/src/components/Button.tsx b/client/ui-wails/frontend/src/components/Button.tsx index 1d4c9135a..5d52db8ba 100644 --- a/client/ui-wails/frontend/src/components/Button.tsx +++ b/client/ui-wails/frontend/src/components/Button.tsx @@ -4,9 +4,7 @@ import { ButtonHTMLAttributes, forwardRef } from "react"; export type ButtonVariants = VariantProps; -export interface ButtonProps - extends ButtonHTMLAttributes, - ButtonVariants { +export interface ButtonProps extends ButtonHTMLAttributes, ButtonVariants { disabled?: boolean; stopPropagation?: boolean; } @@ -26,7 +24,7 @@ export const buttonVariants = cva( "dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50", ], primary: [ - "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", + "dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-900 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80", "enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500", ], secondary: [ @@ -88,9 +86,9 @@ export const buttonVariants = cva( size: { xs: "text-xs py-2 px-4", xs2: "text-[0.78rem] py-2 px-4", - sm: "text-sm py-2.5 px-4", - md: "text-md py-2.5 px-4", - lg: "text-lg py-2.5 px-4", + sm: "text-sm py-[9px] px-4", + md: "text-md py-[9px] px-4", + lg: "text-lg py-[9px] px-4", }, rounded: { true: "rounded-md", @@ -105,47 +103,45 @@ export const buttonVariants = cva( }, ); -export const Button = forwardRef( - function Button( - { - variant = "default", - rounded = true, - border = 1, - size = "md", - stopPropagation = true, - type = "button", - children, - className, - onClick, - disabled, - ...props - }, - ref, - ) { - return ( - - ); +export const Button = forwardRef(function Button( + { + variant = "default", + rounded = true, + border = 1, + size = "md", + stopPropagation = true, + type = "button", + children, + className, + onClick, + disabled, + ...props }, -); + ref, +) { + return ( + + ); +}); export default Button; diff --git a/client/ui-wails/frontend/src/components/FancyToggleSwitch.tsx b/client/ui-wails/frontend/src/components/FancyToggleSwitch.tsx index 5d78a7353..ede2d2592 100644 --- a/client/ui-wails/frontend/src/components/FancyToggleSwitch.tsx +++ b/client/ui-wails/frontend/src/components/FancyToggleSwitch.tsx @@ -50,7 +50,7 @@ export default function FancyToggleSwitch({ role={"switch"} aria-checked={value} className={cn( - "cursor-pointer transition-all duration-300 relative z-[1]", + "cursor-default transition-all duration-300 relative z-[1]", "inline-block text-left w-full", disabled && "opacity-50 pointer-events-none", className, diff --git a/client/ui-wails/frontend/src/components/HelpText.tsx b/client/ui-wails/frontend/src/components/HelpText.tsx index ef321349b..7f70a352d 100644 --- a/client/ui-wails/frontend/src/components/HelpText.tsx +++ b/client/ui-wails/frontend/src/components/HelpText.tsx @@ -10,7 +10,7 @@ type Props = { export const HelpText = ({ children, margin = true, className }: Props) => ( (function Input( const suffix = passwordToggle || customSuffix; return ( -
+
{label && } -
+
{customPrefix && (
(function Input( ? "error" : "default", }), - "flex h-[42px] w-auto rounded-l-md bg-white px-3 py-2 text-sm", + "flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm", "border items-center whitespace-nowrap", props.disabled && "opacity-40", prefixClassName, @@ -134,7 +134,7 @@ export const Input = forwardRef(function Input( inputVariants({ variant: error ? "error" : variant, }), - "flex h-[42px] w-full rounded-md bg-white px-3 py-2 text-sm", + "flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm", "file:bg-transparent file:text-sm file:font-medium file:border-0", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", "disabled:cursor-not-allowed disabled:opacity-40", @@ -161,7 +161,6 @@ export const Input = forwardRef(function Input(
{error && ( - {error} )} diff --git a/client/ui-wails/frontend/src/components/Label.tsx b/client/ui-wails/frontend/src/components/Label.tsx index 3850d5234..c62ab496a 100644 --- a/client/ui-wails/frontend/src/components/Label.tsx +++ b/client/ui-wails/frontend/src/components/Label.tsx @@ -4,7 +4,7 @@ import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; import { cn } from "@/lib/cn"; const labelVariants = cva( - "text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-200 flex items-center gap-2", + "text-sm font-medium tracking-wider leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 mb-1.5 inline-block dark:text-nb-gray-100 flex items-center gap-2", ); type LabelProps = ComponentPropsWithoutRef & diff --git a/client/ui-wails/frontend/src/components/SwitchItem.tsx b/client/ui-wails/frontend/src/components/SwitchItem.tsx new file mode 100644 index 000000000..0a50abf13 --- /dev/null +++ b/client/ui-wails/frontend/src/components/SwitchItem.tsx @@ -0,0 +1,41 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { motion } from "framer-motion"; +import { ReactNode } from "react"; +import { cn } from "@/lib/cn"; +import { useSwitchItemGroup } from "@/components/SwitchItemGroup"; + +type Props = { + value: string; + children: ReactNode; +}; + +export const SwitchItem = ({ value, children }: Props) => { + const { value: activeValue, layoutId } = useSwitchItemGroup(); + const active = activeValue === value; + + return ( + + {active && ( + + )} + + {children} + + + ); +}; diff --git a/client/ui-wails/frontend/src/components/SwitchItemGroup.tsx b/client/ui-wails/frontend/src/components/SwitchItemGroup.tsx new file mode 100644 index 000000000..364d3d134 --- /dev/null +++ b/client/ui-wails/frontend/src/components/SwitchItemGroup.tsx @@ -0,0 +1,46 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { createContext, ReactNode, useContext, useId } from "react"; +import { cn } from "@/lib/cn"; + +type SwitchItemGroupContextValue = { + value: string; + layoutId: string; +}; + +const SwitchItemGroupContext = createContext( + null, +); + +export const useSwitchItemGroup = () => { + const ctx = useContext(SwitchItemGroupContext); + if (!ctx) { + throw new Error("SwitchItem must be used inside a SwitchItemGroup"); + } + return ctx; +}; + +type Props = { + value: string; + onChange: (value: string) => void; + children: ReactNode; + className?: string; +}; + +export const SwitchItemGroup = ({ value, onChange, children, className }: Props) => { + const layoutId = useId(); + + return ( + + + {children} + + + ); +}; diff --git a/client/ui-wails/frontend/src/components/ToggleSwitch.tsx b/client/ui-wails/frontend/src/components/ToggleSwitch.tsx index b8b709c1a..3bc2d0436 100644 --- a/client/ui-wails/frontend/src/components/ToggleSwitch.tsx +++ b/client/ui-wails/frontend/src/components/ToggleSwitch.tsx @@ -16,15 +16,21 @@ const switchVariants = cva("", { variant: { default: [ "dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", "data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200", + "data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300", ], "red-green": [ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", ], red: [ "dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700", + "dark:data-[state=checked]:hover:bg-red-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600", "data-[state=checked]:bg-red-500 data-[state=unchecked]:bg-red-200", + "data-[state=checked]:hover:bg-red-400 data-[state=unchecked]:hover:bg-red-300", ], }, "thumb-size": { @@ -45,7 +51,7 @@ const ToggleSwitch = React.forwardRef< ) => ( { return (
{children}
diff --git a/client/ui-wails/frontend/src/lib/welcome.ts b/client/ui-wails/frontend/src/lib/welcome.ts new file mode 100644 index 000000000..6d46c6b64 --- /dev/null +++ b/client/ui-wails/frontend/src/lib/welcome.ts @@ -0,0 +1,23 @@ +const ART = ` + _ __ __ ____ _ __ ______ __ __ __ + / | / /__ / /_/ __ )(_)________/ / / ____/___ ___ / /_ / / / / + / |/ / _ \\/ __/ __ / / ___/ __ / / / __/ __ \`__ \\/ __ \\/ /_/ / + / /| / __/ /_/ /_/ / / / / /_/ / / /_/ / / / / / / /_/ / __ / +/_/ |_/\\___/\\__/_____/_/_/ \\__,_/ \\____/_/ /_/ /_/_.___/_/ /_/ +`; + +export function welcome() { + const message = `%c${ART}%c +NetBird — The Only Secure Access Platform You'll Ever Need. + +WEBSITE: https://netbird.io/ +WE'RE HIRING: https://careers.netbird.io/ +OPEN SOURCE: https://github.com/netbirdio/netbird +`; + + console.log( + message, + "color: #f68330; font-family: monospace; font-weight: normal; line-height: 1;", + "color: #f5f5f5; font-family: monospace; font-weight: normal; line-height: 1.4;", + ); +} diff --git a/client/ui-wails/frontend/src/modules/settings/ManagementServerSwitch.tsx b/client/ui-wails/frontend/src/modules/settings/ManagementServerSwitch.tsx new file mode 100644 index 000000000..a99d510c4 --- /dev/null +++ b/client/ui-wails/frontend/src/modules/settings/ManagementServerSwitch.tsx @@ -0,0 +1,21 @@ +import netbirdLogo from "@/assets/logos/netbird.svg"; +import { SwitchItem } from "@/components/SwitchItem"; +import { SwitchItemGroup } from "@/components/SwitchItemGroup"; +import { ManagementMode } from "@/modules/settings/useManagementUrl.ts"; + +type Props = { + value: ManagementMode; + onChange: (mode: ManagementMode) => void; +}; + +export const ManagementServerSwitch = ({ value, onChange }: Props) => { + return ( + onChange(v as ManagementMode)}> + + {""} + Cloud + + Self-hosted + + ); +}; diff --git a/client/ui-wails/frontend/src/modules/settings/Settings.tsx b/client/ui-wails/frontend/src/modules/settings/Settings.tsx index dd49e51b8..de0e8aba3 100644 --- a/client/ui-wails/frontend/src/modules/settings/Settings.tsx +++ b/client/ui-wails/frontend/src/modules/settings/Settings.tsx @@ -15,36 +15,34 @@ export const Settings = () => { const [active, setActive] = useState("general"); return ( - + - - - - - - - - - - - - - - - - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + + + + + +
); diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx index db9bfa68a..781182828 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx @@ -16,6 +16,7 @@ const SAVE_DEBOUNCE_MS = 400; type SettingsContextValue = { config: Config; setField: (k: K, v: Config[K]) => void; + saveField: (k: K, v: Config[K]) => Promise; saveNow: () => Promise; }; @@ -98,27 +99,37 @@ const useSettingsState = () => { await save(config); }, [config, save]); - return { config, error, setField, saveNow }; + const saveField = useCallback( + async (k: K, v: Config[K]) => { + if (!config) return; + if (saveTimer.current) { + clearTimeout(saveTimer.current); + saveTimer.current = null; + } + const next = { ...config, [k]: v }; + setConfig(next); + await save(next); + }, + [config, save], + ); + + return { config, error, setField, saveField, saveNow }; }; export const SettingsProvider = ({ children }: { children: ReactNode }) => { - const { config, error, setField, saveNow } = useSettingsState(); + const { config, error, setField, saveField, saveNow } = useSettingsState(); return ( <> - {error && ( -

{error}

- )} + {error &&

{error}

}
{!config ? ( -
- Loading… -
+
Loading…
) : ( -
{children}
+ {children}
)}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx index e0ae9e8e9..615f537e8 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsGeneral.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { Button } from "@/components/Button"; import FancyToggleSwitch from "@/components/FancyToggleSwitch"; import { HelpText } from "@/components/HelpText"; @@ -5,54 +6,77 @@ import { Input } from "@/components/Input"; import { Label } from "@/components/Label"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; import { useSettings } from "@/modules/settings/SettingsContext.tsx"; +import { ManagementServerSwitch } from "@/modules/settings/ManagementServerSwitch.tsx"; +import { ManagementMode, useManagementUrl } from "@/modules/settings/useManagementUrl.ts"; export function SettingsGeneral() { - const { config, setField, saveNow } = useSettings(); + const { config, setField } = useSettings(); + const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl(); + + const inputRef = useRef(null); + const prevMode = useRef(mode); + useEffect(() => { + if ( + prevMode.current === ManagementMode.Cloud && + mode === ManagementMode.SelfHosted + ) { + inputRef.current?.focus(); + } + prevMode.current = mode; + }, [mode]); + return ( <> setField("disableAutoConnect", !v)} - label={"Connect on startup"} - helpText={ - "Automatically connect to NetBird when the app launches." - } + label={"Connect on Startup"} + helpText={"Automatically establish a connection when the service starts."} /> setField("disableNotifications", !v)} - label={"Show notifications"} - helpText={ - "Show desktop notifications for connection events and updates." - } + label={"Desktop Notifications"} + helpText={"Show desktop notifications for new updates and connection events."} />
- - - The NetBird management server this client connects to. - Saving will reconnect to apply the new server. - -
-
+
+
+ + + Connect to NetBird Cloud or your own self-hosted management server. + Changes will reconnect the client. + +
+ +
+ {mode === ManagementMode.SelfHosted && ( +
- setField("managementUrl", e.target.value) + ref={inputRef} + value={displayUrl} + onChange={(e) => setUrl(e.target.value)} + placeholder={"https://netbird.selfhosted.com:443"} + error={ + showError + ? "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443" + : undefined } /> +
- -
+ )}
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx index c961af55f..7e6ee3c7b 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx @@ -1,20 +1,10 @@ import type { ReactNode } from "react"; -export const SectionGroup = ({ - title, - children, -}: { - title: string; - children: ReactNode; -}) => ( -
-

+export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => ( +
+

{title}

-
{children}
+
{children}
); diff --git a/client/ui-wails/frontend/src/modules/settings/useManagementUrl.ts b/client/ui-wails/frontend/src/modules/settings/useManagementUrl.ts new file mode 100644 index 000000000..497ee6c94 --- /dev/null +++ b/client/ui-wails/frontend/src/modules/settings/useManagementUrl.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import { useSettings } from "@/modules/settings/SettingsContext.tsx"; + +export enum ManagementMode { + Cloud = "cloud", + SelfHosted = "selfhosted", +} + +export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443"; + +function normalizeManagementUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +const URL_PATTERN = new RegExp( + "^(https?:\\/\\/)?" + + "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|localhost|" + + "((\\d{1,3}\\.){3}\\d{1,3}))" + + "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + + "(\\?[;&a-z\\d%_.~+=-]*)?" + + "(\\#[-a-z\\d_]*)?$", + "i", +); + +function isValidManagementUrl(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed) return false; + return URL_PATTERN.test(trimmed); +} + +function modeFromUrl(url: string): ManagementMode { + return url === CLOUD_MANAGEMENT_URL ? ManagementMode.Cloud : ManagementMode.SelfHosted; +} + +export function useManagementUrl() { + const { config, saveField } = useSettings(); + const [mode, setModeState] = useState( + modeFromUrl(config.managementUrl), + ); + const [url, setUrl] = useState( + config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl, + ); + + useEffect(() => { + setModeState(modeFromUrl(config.managementUrl)); + if (config.managementUrl !== CLOUD_MANAGEMENT_URL) { + setUrl(config.managementUrl); + } + }, [config.managementUrl]); + + const setMode = (next: ManagementMode) => { + setModeState(next); + if ( + next === ManagementMode.Cloud && + config.managementUrl !== CLOUD_MANAGEMENT_URL + ) { + void saveField("managementUrl", CLOUD_MANAGEMENT_URL); + } + }; + + const normalizedUrl = normalizeManagementUrl(url); + const urlValid = isValidManagementUrl(url); + const targetUrl = + mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : normalizedUrl; + const dirty = targetUrl !== config.managementUrl; + const showError = + mode === ManagementMode.SelfHosted && url.trim() !== "" && !urlValid; + const canSave = dirty && (mode === ManagementMode.Cloud || urlValid); + const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url; + + const save = () => saveField("managementUrl", targetUrl); + + return { + mode, + setMode, + url, + setUrl, + displayUrl, + showError, + canSave, + save, + }; +}