update ssh and advanced settings tabs

This commit is contained in:
Eduard Gert
2026-05-08 10:57:31 +02:00
parent adeaa49cda
commit 3953fee5a4
7 changed files with 281 additions and 107 deletions

View File

@@ -1,10 +1,11 @@
import { cva, VariantProps } from "class-variance-authority";
import { Eye, EyeOff } from "lucide-react";
import { ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react";
import {
forwardRef,
InputHTMLAttributes,
ReactNode,
useId,
useRef,
useState,
} from "react";
import { cn } from "@/lib/cn";
@@ -73,17 +74,44 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
const [showPassword, setShowPassword] = useState(false);
const isPasswordType = type === "password";
const inputType = isPasswordType && showPassword ? "text" : type;
const isNumber = type === "number";
const reactId = useId();
const inputId =
id ?? (label ? `input-${reactId}` : undefined);
const internalRef = useRef<HTMLInputElement | null>(null);
const setRefs = (el: HTMLInputElement | null) => {
internalRef.current = el;
if (typeof ref === "function") ref(el);
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
};
const stepBy = (delta: 1 | -1) => {
const el = internalRef.current;
if (!el || el.disabled || el.readOnly) return;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
const stepAttr = el.step !== "" ? Number(el.step) : 1;
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
const min = el.min !== "" ? Number(el.min) : -Infinity;
const max = el.max !== "" ? Number(el.max) : Infinity;
const current = el.value === "" ? 0 : Number(el.value);
let next = (Number.isFinite(current) ? current : 0) + delta * step;
if (next < min) next = min;
if (next > max) next = max;
setter?.call(el, String(next));
el.dispatchEvent(new Event("input", { bubbles: true }));
};
const passwordToggle =
isPasswordType && showPasswordToggle ? (
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="hover:text-white transition-all"
className="hover:text-white transition-all pointer-events-auto"
aria-label="Toggle password visibility"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
@@ -91,6 +119,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
) : null;
const suffix = passwordToggle || customSuffix;
const showStepper = isNumber;
return (
<div className="flex flex-col w-full min-w-0">
@@ -125,37 +154,75 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
</div>
)}
<input
id={inputId}
type={inputType}
ref={ref}
{...props}
className={cn(
inputVariants({
variant: error ? "error" : variant,
}),
"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",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-16",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
className,
)}
/>
<div className="relative flex flex-grow min-w-0">
<input
id={inputId}
type={inputType}
ref={setRefs}
{...props}
className={cn(
inputVariants({
variant: error ? "error" : variant,
}),
"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",
customPrefix && "!border-l-0 !rounded-l-none",
suffix && "!pr-9",
icon && "!pl-10",
"border",
props.readOnly &&
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
showStepper &&
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
className,
)}
/>
{suffix && (
{suffix && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
props.disabled && "opacity-30",
)}
>
{suffix}
</div>
)}
</div>
{showStepper && (
<div
className={cn(
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-4 leading-[0] select-none",
props.disabled && "opacity-30",
"flex flex-col h-[40px] shrink-0 overflow-hidden",
"border border-l-0 rounded-r-md",
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
props.disabled && "opacity-40 pointer-events-none",
)}
>
{suffix}
<button
type="button"
tabIndex={-1}
aria-label="Increase"
onClick={() => stepBy(1)}
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
>
<ChevronUp size={12} />
</button>
<button
type="button"
tabIndex={-1}
aria-label="Decrease"
onClick={() => stepBy(-1)}
className={cn(
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
"border-t border-neutral-200 dark:border-nb-gray-700",
)}
>
<ChevronDown size={12} />
</button>
</div>
)}
</div>

View File

@@ -11,7 +11,7 @@ export const MainRightSide = ({ children }: Props) => {
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 ",
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
)}
>
{children}

View File

@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";
import Button from "@/components/Button";
import { HelpText } from "@/components/HelpText";
import { Input } from "@/components/Input";
import { Label } from "@/components/Label";
@@ -5,53 +7,111 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
export function SettingsAdvanced() {
const { config, setField } = useSettings();
const { config, saveFields } = useSettings();
const [draft, setDraft] = useState({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
const [saving, setSaving] = useState(false);
// Re-sync the draft when the underlying config changes from elsewhere (e.g. reload).
useEffect(() => {
setDraft({
interfaceName: config.interfaceName,
wireguardPort: config.wireguardPort,
mtu: config.mtu,
preSharedKey: config.preSharedKey,
});
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
const isDirty =
draft.interfaceName !== config.interfaceName ||
draft.wireguardPort !== config.wireguardPort ||
draft.mtu !== config.mtu ||
draft.preSharedKey !== config.preSharedKey;
const handleSave = async () => {
if (!isDirty || saving) return;
setSaving(true);
try {
await saveFields(draft);
} finally {
setSaving(false);
}
};
return (
<>
<SectionGroup title={"Security"}>
<div>
<Label as={"div"}>Pre-shared key</Label>
<HelpText>
Optional WireGuard pre-shared key for an extra layer of
symmetric encryption. Must match the value configured
on every peer in the network.
</HelpText>
<Input
type={"password"}
showPasswordToggle
value={config.preSharedKey}
onChange={(e) =>
setField("preSharedKey", e.target.value)
}
/>
</div>
</SectionGroup>
<SectionGroup title={"Interface"}>
<Input
label={"Name"}
value={config.interfaceName}
onChange={(e) => setField("interfaceName", e.target.value)}
value={draft.interfaceName}
onChange={(e) => setDraft((d) => ({ ...d, interfaceName: e.target.value }))}
/>
<div className={"grid grid-cols-2 gap-4"}>
<Input
label={"WireGuard Port"}
label={"Port"}
type={"number"}
value={config.wireguardPort}
value={draft.wireguardPort}
onChange={(e) =>
setField("wireguardPort", Number(e.target.value))
setDraft((d) => ({
...d,
wireguardPort: Number(e.target.value),
}))
}
/>
<Input
label={"MTU"}
type={"number"}
value={config.mtu}
value={draft.mtu}
onChange={(e) =>
setField("mtu", Number(e.target.value))
setDraft((d) => ({
...d,
mtu: Number(e.target.value),
}))
}
/>
</div>
</SectionGroup>
<SectionGroup title={"Security"}>
<div>
<Label as={"div"}>Pre-shared Key</Label>
<HelpText>
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
NetBird Setup Key. Set the same value on every peer, otherwise they can't
connect to each other.
</HelpText>
<Input
type={"password"}
showPasswordToggle
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={draft.preSharedKey}
onChange={(e) =>
setDraft((d) => ({
...d,
preSharedKey: e.target.value,
}))
}
/>
</div>
</SectionGroup>
<div className={"absolute bottom-0 left-0 w-full"}>
<div className={"w-full flex justify-end px-8 py-5 border-t border-nb-gray-910"}>
<Button
variant={"primary"}
size={"md"}
disabled={!isDirty || saving}
onClick={handleSave}
>
Save Changes
</Button>
</div>
</div>
</>
);
}

View File

@@ -17,6 +17,7 @@ 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>;
saveFields: (partial: Partial<Config>) => Promise<void>;
saveNow: () => Promise<void>;
};
@@ -113,11 +114,26 @@ const useSettingsState = () => {
[config, save],
);
return { config, error, setField, saveField, saveNow };
const saveFields = useCallback(
async (partial: Partial<Config>) => {
if (!config) return;
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const next = { ...config, ...partial };
setConfig(next);
await save(next);
},
[config, save],
);
return { config, error, setField, saveField, saveFields, saveNow };
};
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const { config, error, setField, saveField, saveNow } = useSettingsState();
const { config, error, setField, saveField, saveFields, saveNow } =
useSettingsState();
return (
<>
@@ -127,7 +143,13 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
<div className={"p-6 text-sm text-nb-gray-500"}>Loading</div>
) : (
<SettingsContext.Provider
value={{ config, setField, saveField, saveNow }}
value={{
config,
setField,
saveField,
saveFields,
saveNow,
}}
>
{children}
</SettingsContext.Provider>

View File

@@ -5,100 +5,116 @@ import { Label } from "@/components/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useState } from "react";
export function SettingsSSH() {
const { config, setField } = useSettings();
const sshOff = !config.serverSshAllowed;
const isSSHServerEnabled = config.serverSshAllowed;
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
// Keep the local input in sync when the config changes from elsewhere
useEffect(() => {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}, [config.sshJwtCacheTtl]);
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setJwtTtlInput(v);
if (v === "") return;
const n = Number(v);
if (Number.isFinite(n) && n >= 0) {
setField("sshJwtCacheTtl", n);
}
};
const handleJwtTtlBlur = () => {
if (jwtTtlInput === "") {
setJwtTtlInput("0");
setField("sshJwtCacheTtl", 0);
return;
}
const n = Number(jwtTtlInput);
if (!Number.isFinite(n) || n < 0) {
setJwtTtlInput(String(config.sshJwtCacheTtl));
}
};
return (
<>
<SectionGroup title={"Server"}>
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
label={"Allow SSH"}
label={"Enable SSH Server"}
helpText={
"Run the NetBird SSH server on this host so other peers can connect to it."
}
/>
</SectionGroup>
<SectionGroup title={"Capabilities"}>
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
label={"Allow root login"}
label={"Allow Root Login"}
helpText={
"Permit incoming SSH sessions to authenticate as root."
"Let peers sign in as the root user. Disable to require a non-privileged account."
}
disabled={sshOff}
/>
<FancyToggleSwitch
value={config.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
label={"Enable SFTP"}
helpText={"Allow file transfers over the NetBird SSH server."}
disabled={sshOff}
label={"Allow SFTP"}
helpText={"Transfer files securely using native SFTP or SCP clients."}
/>
<FancyToggleSwitch
value={config.enableSshLocalPortForwarding}
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
label={"Local port forwarding"}
label={"Local Port Forwarding"}
helpText={
"Allow clients to forward local ports through this host."
"Let connecting peers tunnel local ports to services reachable from this host."
}
disabled={sshOff}
/>
<FancyToggleSwitch
value={config.enableSshRemotePortForwarding}
onChange={(v) =>
setField("enableSshRemotePortForwarding", v)
}
label={"Remote port forwarding"}
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
label={"Remote Port Forwarding"}
helpText={
"Allow clients to expose remote ports back through this host."
"Let connecting peers expose ports on this host back to their own machine."
}
disabled={sshOff}
/>
</SectionGroup>
<SectionGroup title={"Authentication"}>
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
<FancyToggleSwitch
value={config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", v)}
label={"Disable SSH auth"}
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
label={"Enable JWT Authentication"}
helpText={
"Skip JWT authentication for incoming SSH sessions. Insecure — diagnostics only."
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
}
disabled={sshOff}
/>
<div
className={cn(
"flex items-center gap-6",
sshOff && "opacity-50 pointer-events-none",
"flex items-center gap-6 justify-between",
config.disableSshAuth && "opacity-50 pointer-events-none",
)}
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>JWT cache TTL</Label>
<Label as={"div"}>JWT Cache TTL (Seconds)</Label>
<HelpText margin={false}>
How long verified JWTs are cached before
re-validation. Shorter values increase load on the
management server; longer values delay revocation.
How long this client caches a JWT before prompting again on outgoing SSH
connections. Set to 0 to disable caching and authenticate on every
connection.
</HelpText>
</div>
<div className={"w-32 shrink-0"}>
<div className={"w-40 shrink-0"}>
<Input
type={"number"}
value={config.sshJwtCacheTtl}
onChange={(e) =>
setField(
"sshJwtCacheTtl",
Number(e.target.value),
)
}
customSuffix={
<span className={"text-nb-gray-400"}>s</span>
}
disabled={sshOff}
min={0}
value={jwtTtlInput}
onChange={handleJwtTtlChange}
onBlur={handleJwtTtlBlur}
customSuffix={"Seconds"}
/>
</div>
</div>

View File

@@ -1,7 +1,16 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/cn";
export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => (
<section className={"mb-8 px-1"}>
export const SectionGroup = ({
title,
children,
disabled = false,
}: {
title: string;
children: ReactNode;
disabled?: boolean;
}) => (
<section className={cn("mb-8 px-1", disabled && "opacity-50 pointer-events-none")}>
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title}
</h2>

View File

@@ -102,10 +102,10 @@ func main() {
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "NetBird",
Width: 880,
Height: 520,
MinWidth: 880,
MinHeight: 520,
Width: 925,
MinWidth: 925,
Height: 600,
MinHeight: 600,
Hidden: false,
BackgroundColour: application.NewRGB(24, 26, 29),
URL: "/",