Merge main into embedded-vnc

This commit is contained in:
Viktor Liu
2026-07-30 09:58:02 +02:00
411 changed files with 28931 additions and 6702 deletions
@@ -18,6 +18,9 @@ type CopyToClipboardProps = {
className?: string;
iconClassName?: string;
alwaysShowIcon?: boolean;
// wrap lets long content (a shell command, a path) break across lines
// instead of being truncated to one line.
wrap?: boolean;
variant?: CopyToClipboardVariant;
"aria-label"?: string;
tabIndex?: number;
@@ -32,6 +35,7 @@ export const CopyToClipboard = ({
className,
iconClassName,
alwaysShowIcon = false,
wrap = false,
variant = "default",
"aria-label": ariaLabel,
tabIndex = 0,
@@ -83,7 +87,8 @@ export const CopyToClipboard = ({
>
<span
className={cn(
"relative min-w-0 truncate",
"relative min-w-0",
wrap ? "whitespace-pre-wrap break-all" : "truncate",
"[&_*]:transition-colors",
VARIANT_HOVER[variant],
)}
@@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Version } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
@@ -12,7 +15,26 @@ function openUrl(url: string) {
export const DaemonOutdatedOverlay = () => {
const { t } = useTranslation();
const { isDaemonOutdated } = useStatus();
const { status, isDaemonOutdated } = useStatus();
const [guiVersion, setGuiVersion] = useState<string>("-");
const clientVersion = status?.daemonVersion ?? "—";
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
useEffect(() => {
if (!isDaemonOutdated) return;
let cancelled = false;
Version.GUI()
.then((v) => {
if (!cancelled) setGuiVersion(v);
})
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
return () => {
cancelled = true;
};
}, [isDaemonOutdated]);
if (!isDaemonOutdated) return null;
@@ -38,10 +60,37 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p className={"text-sm font-semibold text-nb-gray-100"}>
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: clientVersion })
)}
</p>
<p className={"text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
{t("daemon.outdated.download")}
</Button>
</div>
</div>
@@ -28,6 +28,7 @@ type ProfileContextValue = {
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
switchProfileNoConnect: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
@@ -112,6 +113,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
// Manage-profiles variant: switches without connecting, so the user can
// still adjust the management URL before bringing the connection up.
const switchProfileNoConnect = useCallback(
async (id: string) => {
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
@@ -158,6 +169,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -171,6 +183,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -14,7 +14,7 @@ import type { Config } from "@bindings/services/models.js";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext.tsx";
import { SettingsSkeleton } from "@/modules/settings/SettingsSkeleton.tsx";
import { errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
import { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
const SAVE_DEBOUNCE_MS = 400;
@@ -68,6 +68,21 @@ const useSettingsState = () => {
loadedRef.current = loaded;
}, [loaded]);
// reload re-reads the daemon's config, which is authoritative. Used on
// mount, on the daemon's config_changed event, and to undo an optimistic
// update the daemon then rejected.
const reload = useCallback(
async (profileName: string) => {
try {
const data = await SettingsSvc.GetConfig({ profileName, username });
setLoaded({ profileName, data });
} catch (e) {
console.warn("[SettingsContext] reload after rejected save failed", e);
}
},
[username],
);
useEffect(() => {
if (!profileLoaded || !activeProfileId) return;
let cancelled = false;
@@ -133,13 +148,20 @@ const useSettingsState = () => {
username,
});
} catch (e) {
// The optimistic update is wrong now: the daemon refused it
// (a change that needs elevated privileges, an MDM-managed
// field, ...). Snap the controls back to what it actually
// holds before reporting, so the UI never shows a value the
// daemon does not have.
await reload(profileName);
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: errorMessage(e),
Command: errorCommand(e),
});
}
},
[username],
[username, reload],
);
const setField = useCallback(
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { Settings as SettingsSvc } from "@bindings/services";
import { Privilege } from "@bindings/services/models.js";
// usePrivilege reports whether this UI process may perform the changes the daemon
// restricts to root/administrator. It is answered in-process from our own token
// with the daemon's own rule, so there is no round-trip and it works while the
// daemon is down.
//
// null means "not known yet", which includes the read having failed. Callers must
// treat that as "do not restrict": the daemon enforces this regardless, so the
// only thing a wrong guess here costs is a control that looks unavailable when it
// is not, or a save that fails with the daemon's own guidance.
export const usePrivilege = (): Privilege | null => {
const [privilege, setPrivilege] = useState<Privilege | null>(null);
useEffect(() => {
let cancelled = false;
SettingsSvc.Privilege()
.then((p) => {
if (!cancelled) setPrivilege(p);
})
.catch((e: unknown) => {
console.warn("[usePrivilege] read failed, not restricting controls", e);
});
return () => {
cancelled = true;
};
}, []);
return privilege;
};
+14 -7
View File
@@ -51,7 +51,14 @@ async function runSsoLogin(
if (uri) await openBrowserLoginUri(uri);
const cancelPromise = buildSsoCancelPromise(state, signal);
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
// Combine wait + up in Go so the connection comes up the moment SSO
// completes. During SSO the tray window is hidden and the webview is
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "" },
{ profileName: "", username: "" },
);
try {
await Promise.race([waitPromise, cancelPromise]);
@@ -89,13 +96,13 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
if (signal?.aborted) state.cancelled = true;
if (!state.cancelled && result.needsSsoLogin) {
// runSsoLogin brings the connection up in Go once SSO completes.
await runSsoLogin(result, state, signal);
}
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
} else {
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
}
}
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
+20 -7
View File
@@ -1,6 +1,6 @@
import { WindowManager } from "@bindings/services";
type ClassifiedError = { short: string; long: string };
type ClassifiedError = { short: string; long: string; command: string };
const asObject = (v: unknown): Record<string, unknown> | null =>
v && typeof v === "object" ? (v as Record<string, unknown>) : null;
@@ -22,20 +22,24 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
return asObject(obj.cause) ?? parseJsonObject(obj.message);
};
// Read { short, long } from wherever the classified error sits in the envelope
// Read { short, long, command } from wherever the classified error sits in the envelope
const toClassifiedError = (v: unknown): ClassifiedError | null => {
const o = asObject(v);
if (!o) return null;
const short = typeof o.short === "string" ? o.short : "";
const long = typeof o.long === "string" ? o.long : "";
return short || long ? { short, long } : null;
const command = typeof o.command === "string" ? o.command : "";
return short || long ? { short, long, command } : null;
};
const classify = (e: unknown): ClassifiedError | null => {
const envelope = toWailsEnvelope(e);
return toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
};
export const formatErrorMessage = (e: unknown): string => {
const envelope = toWailsEnvelope(e);
// Prefer the structured { short, long } the daemon classifier produced.
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
const classified = classify(e);
if (classified) {
const { short, long } = classified;
if (short && long && long !== short) return `${short} Details: ${long}`;
@@ -44,17 +48,26 @@ export const formatErrorMessage = (e: unknown): string => {
}
// Unclassified (a service returned the raw daemon error)
const envelope = toWailsEnvelope(e);
const message = envelope?.message;
if (typeof message === "string" && message) return message;
if (e instanceof Error) return e.message;
return String(e);
};
// errorCommand returns a command the user can run to complete an operation the
// daemon refused, when the error carries one (a change that needs elevated
// privileges). Empty for every other error.
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
export type ErrorDialogOptions = {
Title: string;
Message: string;
// Command is shown for copying below the message. Defaults to the one the
// error carries, so callers only pass it to override.
Command?: string;
};
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
return WindowManager.OpenError(options.Title, options.Message);
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
}
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { DownloadIcon, NotepadText } from "lucide-react";
import { Update as UpdateSvc } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
@@ -14,6 +15,12 @@ function openUrl(url: string) {
});
}
function openInstallerDownload() {
UpdateSvc.DownloadURL()
.then(openUrl)
.catch(() => openUrl(GITHUB_RELEASES));
}
export function UpdateVersionCard() {
const { t } = useTranslation();
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
@@ -37,11 +44,7 @@ export function UpdateVersionCard() {
{t("update.card.installNow")}
</Button>
) : (
<Button
variant={"primary"}
size={"xs"}
onClick={() => openUrl(GITHUB_RELEASES)}
>
<Button variant={"primary"} size={"xs"} onClick={openInstallerDownload}>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
</Button>
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { AlertCircleIcon } from "lucide-react";
import { Button } from "@/components/buttons/Button";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
@@ -12,14 +13,22 @@ import { WindowManager } from "@bindings/services";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
const WINDOW_WIDTH = 380;
// A command needs the room to wrap at a sensible number of characters instead of
// breaking every few words.
const WINDOW_WIDTH_WITH_COMMAND = 460;
export default function ErrorDialog() {
const { t } = useTranslation();
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [params] = useSearchParams();
const title = params.get("title") || t("window.title.error");
const message = params.get("message") || "";
// Set when the daemon refused an operation that needs elevated privileges:
// the command that performs it, offered for copying.
const command = params.get("command") || "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(
command ? WINDOW_WIDTH_WITH_COMMAND : WINDOW_WIDTH,
);
const close = useCallback(() => {
WindowManager.CloseError().catch(console.error);
@@ -37,15 +46,37 @@ export default function ErrorDialog() {
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
<div className={"flex flex-col items-center gap-1"}>
<div className={"flex w-full flex-col items-center gap-1"}>
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
{title}
</DialogHeading>
{message && (
<DialogDescription className={"text-balance"}>
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
{/* select-text: the message often names a path, a flag or an
address the user needs to act on. */}
<span className={"select-text whitespace-pre-wrap break-words"}>
{message}
</span>
</DialogDescription>
)}
{command && (
<CopyToClipboard
message={command}
alwaysShowIcon
wrap
variant={"bright"}
className={
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
}
aria-label={t("common.copy")}
>
<code
className={"select-text break-all font-mono text-xs text-nb-gray-200"}
>
{command}
</code>
</CopyToClipboard>
)}
</div>
<DialogActions>
@@ -45,7 +45,7 @@ export function ProfilesTab() {
activeProfileId,
loaded,
username,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -100,7 +100,7 @@ export function ProfilesTab() {
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
};
const handleDeregister = async (id: string, name: string) => {
@@ -129,14 +129,13 @@ export function ProfilesTab() {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
// not-yet-active profile before the switch makes it current.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(id);
await switchProfileNoConnect(id);
});
};
@@ -73,6 +73,13 @@ export default function SessionExpirationDialog() {
let offCancel: (() => void) | undefined;
// Return the dialog to its interactive state and dismiss the browser popup
const resetDialog = () => {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
};
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
@@ -105,25 +112,22 @@ export default function SessionExpirationDialog() {
if (outcome.kind === "cancel") {
waitPromise.cancel?.();
waitPromise.catch(() => {});
resetDialog();
return;
}
// Another surface owns this flow; keep the dialog open to retry.
if (outcome.result.preempted) {
resetDialog();
return;
}
// Close before the popup so the restore can't flash this window back.
WindowManager.CloseSessionExpiration().catch(console.error);
WindowManager.CloseRenewFlow().catch(console.error);
} catch (e) {
resetDialog();
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
}
}, [busy, t]);
@@ -139,12 +143,11 @@ export default function SessionExpirationDialog() {
});
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import { HelpText } from "@/components/typography/HelpText";
import { Input } from "@/components/inputs/Input";
@@ -6,12 +7,50 @@ import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useId, useState } from "react";
import { usePrivilege } from "@/hooks/usePrivilege.ts";
import { Privilege } from "@bindings/services/models.js";
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const privilege = usePrivilege();
const isSSHServerEnabled = config.serverSshAllowed;
// The daemon restricts only the direction that hands out shells from a process
// running as root. So for an unprivileged user a guarded control is either
// unavailable (it is off and only they could turn it on) or a one-way switch
// (it is on, they may turn it off, but not back on) — say which, either way.
//
// A null privilege means we could not determine it: leave the control alone
// rather than greying it out with nothing to explain why. The daemon enforces
// this regardless, and a rejected save reports its own guidance.
const guarded = (
guardedDirectionActive: boolean,
command: (p: Privilege) => string,
// inverted marks a control whose guarded direction is switching it off, so
// the one-way warning has to read the other way round.
inverted = false,
) => {
if (!privilege || privilege.privileged) {
return { disabled: false, hint: undefined };
}
const hint = (
<PrivilegeHint
actor={privilege.actor}
command={command(privilege)}
oneWay={guardedDirectionActive}
inverted={inverted}
/>
);
return { disabled: !guardedDirectionActive, hint };
};
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
// Inverted control: the guarded direction is switching authentication off, so
// it is the already-disabled state that is the one-way one.
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
@@ -46,9 +85,11 @@ export function SettingsSSH() {
<FancyToggleSwitch
value={config.serverSshAllowed}
onChange={(v) => setField("serverSshAllowed", v)}
disabled={sshServer.disabled}
label={t("settings.ssh.server.label")}
helpText={t("settings.ssh.server.help")}
/>
{sshServer.hint}
</SectionGroup>
<SectionGroup
@@ -58,9 +99,11 @@ export function SettingsSSH() {
<FancyToggleSwitch
value={config.enableSshRoot}
onChange={(v) => setField("enableSshRoot", v)}
disabled={sshRoot.disabled}
label={t("settings.ssh.root.label")}
helpText={t("settings.ssh.root.help")}
/>
{sshRoot.hint}
<FancyToggleSwitch
value={config.enableSshSftp}
onChange={(v) => setField("enableSshSftp", v)}
@@ -88,9 +131,11 @@ export function SettingsSSH() {
<FancyToggleSwitch
value={!config.disableSshAuth}
onChange={(v) => setField("disableSshAuth", !v)}
disabled={sshAuth.disabled}
label={t("settings.ssh.jwt.label")}
helpText={t("settings.ssh.jwt.help")}
/>
{sshAuth.hint}
<div
className={cn(
"flex items-center justify-between gap-6",
@@ -117,3 +162,42 @@ export function SettingsSSH() {
</>
);
}
// PrivilegeHint explains what an unprivileged user can and cannot do with a
// guarded control, and offers the command that does it with the privileges the
// daemon requires. oneWay covers the control being in the guarded state already:
// switching it back is the part that needs privileges.
function PrivilegeHint({
actor,
command,
oneWay,
inverted,
}: {
actor: string;
command: string;
oneWay: boolean;
inverted: boolean;
}): ReactNode {
const { t } = useTranslation();
if (!command) return null;
return (
<div
className={
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
}
>
<span>
{!oneWay
? t("settings.ssh.privilege.hint", { actor })
: inverted
? t("settings.ssh.privilege.oneWayInverted", { actor })
: t("settings.ssh.privilege.oneWay", { actor })}
</span>
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
{command}
</code>
</CopyToClipboard>
</div>
);
}
@@ -22,6 +22,9 @@ type WelcomeStepTrayProps = {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
return (
<>
@@ -36,9 +39,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>