diff --git a/client/ui-wails/frontend/src/components/Input.tsx b/client/ui-wails/frontend/src/components/Input.tsx index 0d7c4d61b..0f6738c0e 100644 --- a/client/ui-wails/frontend/src/components/Input.tsx +++ b/client/ui-wails/frontend/src/components/Input.tsx @@ -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(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(null); + const setRefs = (el: HTMLInputElement | null) => { + internalRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) (ref as React.MutableRefObject).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 ? ( + )} diff --git a/client/ui-wails/frontend/src/layouts/MainRightSide.tsx b/client/ui-wails/frontend/src/layouts/MainRightSide.tsx index 2306a29ce..85791f57c 100644 --- a/client/ui-wails/frontend/src/layouts/MainRightSide.tsx +++ b/client/ui-wails/frontend/src/layouts/MainRightSide.tsx @@ -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} diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx index 86fb1bae1..5c1f58a8b 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -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 ( <> - -
- - - Optional WireGuard pre-shared key for an extra layer of - symmetric encryption. Must match the value configured - on every peer in the network. - - - setField("preSharedKey", e.target.value) - } - /> -
-
- setField("interfaceName", e.target.value)} + value={draft.interfaceName} + onChange={(e) => setDraft((d) => ({ ...d, interfaceName: e.target.value }))} />
- setField("wireguardPort", Number(e.target.value)) + setDraft((d) => ({ + ...d, + wireguardPort: Number(e.target.value), + })) } /> - setField("mtu", Number(e.target.value)) + setDraft((d) => ({ + ...d, + mtu: Number(e.target.value), + })) } />
+ + +
+ + + 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. + + + setDraft((d) => ({ + ...d, + preSharedKey: e.target.value, + })) + } + /> +
+
+ +
+
+ +
+
); } diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx index 781182828..7e6bbe77a 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsContext.tsx @@ -17,6 +17,7 @@ type SettingsContextValue = { config: Config; setField: (k: K, v: Config[K]) => void; saveField: (k: K, v: Config[K]) => Promise; + saveFields: (partial: Partial) => Promise; saveNow: () => Promise; }; @@ -113,11 +114,26 @@ const useSettingsState = () => { [config, save], ); - return { config, error, setField, saveField, saveNow }; + const saveFields = useCallback( + async (partial: Partial) => { + 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 }) => {
Loading…
) : ( {children} diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx index 95cd593ff..094745bfe 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsSSH.tsx @@ -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) => { + 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 ( <> 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." } /> - + 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} /> 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."} /> 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} /> - 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} /> - + 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} />
- + - 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.
-
+
- setField( - "sshJwtCacheTtl", - Number(e.target.value), - ) - } - customSuffix={ - s - } - disabled={sshOff} + min={0} + value={jwtTtlInput} + onChange={handleJwtTtlChange} + onBlur={handleJwtTtlBlur} + customSuffix={"Seconds"} />
diff --git a/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx index 7e6ee3c7b..344401217 100644 --- a/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx +++ b/client/ui-wails/frontend/src/modules/settings/SettingsSection.tsx @@ -1,7 +1,16 @@ import type { ReactNode } from "react"; +import { cn } from "@/lib/cn"; -export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => ( -
+export const SectionGroup = ({ + title, + children, + disabled = false, +}: { + title: string; + children: ReactNode; + disabled?: boolean; +}) => ( +

{title}

diff --git a/client/ui-wails/main.go b/client/ui-wails/main.go index 4ae597ac5..62d44ca73 100644 --- a/client/ui-wails/main.go +++ b/client/ui-wails/main.go @@ -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: "/",