mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-14 20:59:54 +00:00
add general settings
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user