add general settings

This commit is contained in:
Eduard Gert
2026-05-07 16:47:52 +02:00
parent 559da5d5b9
commit 70a755fbae
21 changed files with 450 additions and 139 deletions

View File

@@ -0,0 +1,7 @@
dist
build
node_modules
pnpm-lock.yaml
wailsjs
*.min.js
*.min.css

View File

@@ -0,0 +1,10 @@
{
"tabWidth": 4,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

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

View File

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

View File

@@ -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(
<React.StrictMode>

View File

@@ -4,9 +4,7 @@ import { ButtonHTMLAttributes, forwardRef } from "react";
export type ButtonVariants = VariantProps<typeof buttonVariants>;
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariants {
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, 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<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "default",
rounded = true,
border = 1,
size = "md",
stopPropagation = true,
type = "button",
children,
className,
onClick,
disabled,
...props
},
ref,
) {
return (
<button
ref={ref}
type={type}
disabled={disabled}
className={classNames(
buttonVariants({
variant,
rounded,
border: border ? 1 : 0,
size,
}),
className,
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
onClick?.(e);
}}
{...props}
>
{children}
</button>
);
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
variant = "default",
rounded = true,
border = 1,
size = "md",
stopPropagation = true,
type = "button",
children,
className,
onClick,
disabled,
...props
},
);
ref,
) {
return (
<button
ref={ref}
type={type}
disabled={disabled}
className={classNames(
buttonVariants({
variant,
rounded,
border: border ? 1 : 0,
size,
}),
className,
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
onClick?.(e);
}}
{...props}
>
{children}
</button>
);
});
export default Button;

View File

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

View File

@@ -10,7 +10,7 @@ type Props = {
export const HelpText = ({ children, margin = true, className }: Props) => (
<span
className={cn(
"text-[.8rem] dark:text-nb-gray-300 block font-light tracking-wide",
"text-[.81rem] dark:text-nb-gray-300 block font-light tracking-wide",
margin && "mb-2",
className,
)}

View File

@@ -1,5 +1,5 @@
import { cva, VariantProps } from "class-variance-authority";
import { AlertCircle, Eye, EyeOff } from "lucide-react";
import { Eye, EyeOff } from "lucide-react";
import {
forwardRef,
InputHTMLAttributes,
@@ -93,9 +93,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const suffix = passwordToggle || customSuffix;
return (
<div className="flex flex-col">
<div className="flex flex-col w-full min-w-0">
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
<div className={cn("flex relative h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<div
className={cn(
@@ -104,7 +104,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(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<HTMLInputElement, InputProps>(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<HTMLInputElement, InputProps>(function Input(
</div>
{error && (
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
<AlertCircle size={13} />
{error}
</span>
)}

View File

@@ -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<typeof LabelPrimitive.Root> &

View File

@@ -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 (
<RadioGroup.Item
value={value}
className={cn(
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
"outline-none cursor-default",
active
? "text-nb-gray-100"
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
)}
>
{active && (
<motion.span
layoutId={layoutId}
className={"absolute inset-0 rounded-md bg-nb-gray-800"}
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<span
className={"relative inline-flex items-center justify-center gap-1"}
>
{children}
</span>
</RadioGroup.Item>
);
};

View File

@@ -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<SwitchItemGroupContextValue | null>(
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 (
<SwitchItemGroupContext.Provider value={{ value, layoutId }}>
<RadioGroup.Root
value={value}
onValueChange={onChange}
className={cn(
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-920 p-1",
className,
)}
>
{children}
</RadioGroup.Root>
</SwitchItemGroupContext.Provider>
);
};

View File

@@ -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<
) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
"peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
className,
switchVariants({ size, variant }),
)}

View File

@@ -1,4 +1,5 @@
import { ReactNode } from "react";
import { cn } from "@/lib/cn.ts";
type Props = {
children: ReactNode;
@@ -7,7 +8,11 @@ type Props = {
export const MainRightSide = ({ children }: Props) => {
return (
<div
className={"wails-no-draggable flex-1 min-h-0 min-w-0 flex flex-col bg-nb-gray-935 rounded-xl rounded-br-2xl border border-nb-gray-910"}
className={cn(
"wails-no-draggable",
"bg-nb-gray-935 border border-nb-gray-910",
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl ",
)}
>
{children}
</div>

View File

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

View File

@@ -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 (
<SwitchItemGroup value={value} onChange={(v) => onChange(v as ManagementMode)}>
<SwitchItem value={ManagementMode.Cloud}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
Cloud
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted}>Self-hosted</SwitchItem>
</SwitchItemGroup>
);
};

View File

@@ -15,36 +15,34 @@ export const Settings = () => {
const [active, setActive] = useState("general");
return (
<VerticalTabs
value={active}
onValueChange={setActive}
className={"wails-draggable p-4"}
>
<VerticalTabs value={active} onValueChange={setActive} className={"p-4"}>
<SettingsNavigationTriggers />
<MainRightSide>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
<div className={"py-8 px-7"}>
<SettingsProvider>
<VerticalTabs.Content value={"general"}>
<SettingsGeneral />
</VerticalTabs.Content>
<VerticalTabs.Content value={"network"}>
<SettingsNetwork />
</VerticalTabs.Content>
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>
<VerticalTabs.Content value={"advanced"}>
<SettingsAdvanced />
</VerticalTabs.Content>
<VerticalTabs.Content value={"troubleshooting"}>
<SettingsTroubleshooting />
</VerticalTabs.Content>
<VerticalTabs.Content value={"about"}>
<SettingsAbout />
</VerticalTabs.Content>
</SettingsProvider>
</div>
</MainRightSide>
</VerticalTabs>
);

View File

@@ -16,6 +16,7 @@ const SAVE_DEBOUNCE_MS = 400;
type SettingsContextValue = {
config: Config;
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
saveNow: () => Promise<void>;
};
@@ -98,27 +99,37 @@ const useSettingsState = () => {
await save(config);
}, [config, save]);
return { config, error, setField, saveNow };
const saveField = useCallback(
async <K extends keyof Config>(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 && (
<p className={"px-6 py-2 text-sm text-red-500"}>{error}</p>
)}
{error && <p className={"pb-6 text-sm text-red-500"}>{error}</p>}
<div className={"flex-1 min-h-0 overflow-y-auto"}>
{!config ? (
<div className={"p-6 text-sm text-nb-gray-500"}>
Loading
</div>
<div className={"p-6 text-sm text-nb-gray-500"}>Loading</div>
) : (
<SettingsContext.Provider
value={{ config, setField, saveNow }}
value={{ config, setField, saveField, saveNow }}
>
<div className={"px-6 py-5"}>{children}</div>
{children}
</SettingsContext.Provider>
)}
</div>

View File

@@ -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<HTMLInputElement>(null);
const prevMode = useRef(mode);
useEffect(() => {
if (
prevMode.current === ManagementMode.Cloud &&
mode === ManagementMode.SelfHosted
) {
inputRef.current?.focus();
}
prevMode.current = mode;
}, [mode]);
return (
<>
<SectionGroup title={"General"}>
<FancyToggleSwitch
value={!config.disableAutoConnect}
onChange={(v) => 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."}
/>
<FancyToggleSwitch
value={!config.disableNotifications}
onChange={(v) => 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."}
/>
</SectionGroup>
<SectionGroup title={"Connection"}>
<div>
<Label as={"div"}>Management Server</Label>
<HelpText>
The NetBird management server this client connects to.
Saving will reconnect to apply the new server.
</HelpText>
<div className={"flex items-center gap-2"}>
<div className={"flex-1"}>
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>Management Server</Label>
<HelpText>
Connect to NetBird Cloud or your own self-hosted management server.
Changes will reconnect the client.
</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}>
<Input
value={config.managementUrl}
onChange={(e) =>
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
}
/>
<Button
variant={"primary"}
size={"md"}
disabled={!canSave}
onClick={() => save()}
>
Save
</Button>
</div>
<Button
variant={"primary"}
size={"md"}
onClick={() => saveNow()}
>
Save
</Button>
</div>
)}
</div>
</SectionGroup>
</>

View File

@@ -1,20 +1,10 @@
import type { ReactNode } from "react";
export const SectionGroup = ({
title,
children,
}: {
title: string;
children: ReactNode;
}) => (
<section className={"mb-8"}>
<h2
className={
"text-xs uppercase tracking-wider text-nb-gray-400 mb-3 font-semibold"
}
>
export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => (
<section className={"mb-8 px-1"}>
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title}
</h2>
<div className={"flex flex-col gap-4"}>{children}</div>
<div className={"flex flex-col gap-5"}>{children}</div>
</section>
);

View File

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