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,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,
};
}