mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-05 13:09:07 +02:00
[Client] Surface readable Authz errors and add profile claim command (#7540)
* [client] Surface error messages for IPC authz in UI (#7553)
This commit is contained in:
@@ -11,7 +11,7 @@ import { Label } from "@/components/typography/Label";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { loadLanguages } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor } from "@/lib/errors";
|
||||
|
||||
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
|
||||
|
||||
@@ -66,10 +66,7 @@ export function LanguagePicker() {
|
||||
try {
|
||||
await Preferences.SetLanguage(code as LanguageCode);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("settings.error.saveTitle"), e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,7 @@ import { Events } from "@wailsio/runtime";
|
||||
import { Update as UpdateSvc, WindowManager } from "@bindings/services";
|
||||
import type { State as UpdateState } from "@bindings/updater/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
};
|
||||
import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors";
|
||||
|
||||
type ClientVersionContextValue = {
|
||||
updateAvailable: boolean;
|
||||
@@ -61,10 +56,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled || isDaemonUnavailable(e)) return;
|
||||
void errorDialog({
|
||||
Title: i18next.t("update.error.loadStateTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
void errorDialogFor(i18next.t("update.error.loadStateTitle"), e);
|
||||
});
|
||||
const off = Events.On(EVENT_UPDATE_STATE, (ev: { data: UpdateState }) => {
|
||||
if (ev?.data) setState(ev.data);
|
||||
@@ -90,10 +82,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) =>
|
||||
.catch(async (e) => {
|
||||
if (isDaemonUnavailable(e)) return;
|
||||
WindowManager.CloseInstallProgress().catch(console.error);
|
||||
await errorDialog({
|
||||
Title: i18next.t("update.error.triggerTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("update.error.triggerTitle"), e);
|
||||
})
|
||||
.finally(() => setUpdating(false));
|
||||
}, [state.version]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, useState, type ReactNode
|
||||
import { Connection as ConnectionSvc, Debug as DebugSvc } from "@bindings/services";
|
||||
import type { DebugBundleResult } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialogFor } from "@/lib/errors.ts";
|
||||
import { startConnection } from "@/lib/connection.ts";
|
||||
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
@@ -260,10 +260,7 @@ const useDebugBundle = () => {
|
||||
}
|
||||
await cleanupBestEffort(pcap, level, false);
|
||||
setStage({ kind: "idle" });
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.debugBundleTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.error.debugBundleTitle"), e);
|
||||
} finally {
|
||||
if (abortRef.current === ctrl) abortRef.current = null;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Events } from "@wailsio/runtime";
|
||||
import { Connection, ProfileSwitcher, Profiles as ProfilesSvc } from "@bindings/services";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor, isDaemonUnavailable } from "@/lib/errors";
|
||||
|
||||
const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
@@ -65,23 +65,27 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
ProfilesSvc.List(u),
|
||||
]);
|
||||
setUsername(u);
|
||||
setActiveProfile(active.profileName || "default");
|
||||
// An empty name means the daemon would not disclose it: the active
|
||||
// profile belongs to another user. Falling back to "default" would
|
||||
// name the wrong profile, so say what it is instead.
|
||||
const activeName = active.profileName
|
||||
? active.profileName
|
||||
: active.id
|
||||
? i18next.t("profile.ownedByAnother")
|
||||
: "default";
|
||||
setActiveProfile(activeName);
|
||||
setActiveProfileId(active.id || "default");
|
||||
setProfiles(list);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("code = Unavailable")) {
|
||||
if (isDaemonUnavailable(e)) {
|
||||
retryRef.current = setTimeout(() => {
|
||||
void refresh();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
setLoaded(true);
|
||||
await errorDialog({
|
||||
Title: i18next.t("profile.error.loadTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("profile.error.loadTitle"), e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Autostart, Settings as SettingsSvc, Version } from "@bindings/services";
|
||||
import { Autostart, Settings as SettingsSvc } from "@bindings/services";
|
||||
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 { errorCommand, errorDialog, formatErrorMessage as errorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialogFor } from "@/lib/errors.ts";
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
|
||||
@@ -29,7 +29,6 @@ export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAut
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
guiVersion: string;
|
||||
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>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
@@ -66,7 +65,6 @@ type LoadedConfig = { profileName: string; data: Config };
|
||||
const useSettingsState = () => {
|
||||
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
|
||||
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||
// Set when the daemon's config changed while a save was pending, so the read
|
||||
@@ -116,10 +114,7 @@ const useSettingsState = () => {
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.loadTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.error.loadTitle"), e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,16 +133,6 @@ const useSettingsState = () => {
|
||||
};
|
||||
}, [profileLoaded, activeProfileId, username]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Version.GUI().then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
@@ -177,11 +162,7 @@ const useSettingsState = () => {
|
||||
// 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),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
|
||||
}
|
||||
},
|
||||
[username, reload],
|
||||
@@ -268,11 +249,7 @@ const useSettingsState = () => {
|
||||
// through here at all; this is a prompt that could not be raised,
|
||||
// which carries the command that would have done it.
|
||||
await reload(cur.profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
|
||||
return;
|
||||
}
|
||||
// Either the change went through or the user declined it. The daemon
|
||||
@@ -303,7 +280,6 @@ const useSettingsState = () => {
|
||||
|
||||
return {
|
||||
config: loaded?.data ?? null,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
@@ -313,15 +289,13 @@ const useSettingsState = () => {
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||
const { config, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() =>
|
||||
config
|
||||
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||
: null,
|
||||
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||
config ? { config, setField, saveField, saveFields, saveGuardedField, saveNow } : null,
|
||||
[config, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
@@ -361,10 +335,7 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode })
|
||||
await Autostart.SetEnabled(enabled);
|
||||
} catch (e) {
|
||||
setAutostart((s) => (s ? { ...s, enabled: !enabled } : s));
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.general.autostart.errorTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.general.autostart.errorTitle"), e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Version } from "@bindings/services";
|
||||
|
||||
const UNKNOWN_VERSION = "—";
|
||||
|
||||
// useGuiVersion reports the UI binary's own version, which is stamped into it at
|
||||
// build time and answered in-process. The daemon version comes from the status
|
||||
// feed instead, see StatusContext.
|
||||
export const useGuiVersion = (): string => {
|
||||
const [guiVersion, setGuiVersion] = useState<string>(UNKNOWN_VERSION);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Version.GUI()
|
||||
.then((v) => {
|
||||
if (!cancelled) setGuiVersion(v);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.warn("[useGuiVersion] read failed", e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return guiVersion;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Connection, WindowManager } from "@bindings/services";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialogFor } from "@/lib/errors.ts";
|
||||
|
||||
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
|
||||
export const EVENT_TRIGGER_LOGIN = "trigger-login";
|
||||
@@ -120,10 +120,7 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
|
||||
}
|
||||
|
||||
if (connectError !== undefined) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(connectError),
|
||||
});
|
||||
await errorDialogFor(i18next.t("connect.error.loginTitle"), connectError);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
type ClassifiedError = { short: string; long: string; command: string };
|
||||
type ClassifiedError = { code: string; 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,14 +22,15 @@ const toWailsEnvelope = (e: unknown): Record<string, unknown> | null => {
|
||||
return asObject(obj.cause) ?? parseJsonObject(obj.message);
|
||||
};
|
||||
|
||||
// Read { short, long, command } from wherever the classified error sits in the envelope
|
||||
// Read { code, 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 code = typeof o.code === "string" ? o.code : "";
|
||||
const short = typeof o.short === "string" ? o.short : "";
|
||||
const long = typeof o.long === "string" ? o.long : "";
|
||||
const command = typeof o.command === "string" ? o.command : "";
|
||||
return short || long ? { short, long, command } : null;
|
||||
return short || long ? { code, short, long, command } : null;
|
||||
};
|
||||
|
||||
const classify = (e: unknown): ClassifiedError | null => {
|
||||
@@ -60,14 +61,38 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
// privileges). Empty for every other error.
|
||||
export const errorCommand = (e: unknown): string => classify(e)?.command ?? "";
|
||||
|
||||
// isDaemonUnavailable reports whether an error means the daemon could not be
|
||||
// reached, so a caller can retry quietly instead of putting a dialog up while
|
||||
// the service is still starting. Matches the classified code first and the raw
|
||||
// gRPC status text second, since not every service classifies its errors.
|
||||
export const isDaemonUnavailable = (e: unknown): boolean => {
|
||||
if (classify(e)?.code === "daemon_unreachable") return true;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return msg.includes("code = Unavailable");
|
||||
};
|
||||
|
||||
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 is shown for copying below the message. Prefer errorDialogFor,
|
||||
// which takes it from the error, over setting this by hand.
|
||||
Command?: string;
|
||||
};
|
||||
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message, options.Command ?? "");
|
||||
}
|
||||
|
||||
// errorDialogFor opens a dialog for a thrown error, taking both the message and
|
||||
// any command the daemon attached from the error itself.
|
||||
//
|
||||
// Use it wherever the message is just the error. Passing Command by hand is what
|
||||
// kept the daemon's suggested command off the screen everywhere except Settings,
|
||||
// since every other caller had to remember to ask for it.
|
||||
export function errorDialogFor(title: string, e: unknown): Promise<void> {
|
||||
return errorDialog({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor } from "@/lib/errors";
|
||||
|
||||
const EVENT_CANCEL = "browser-login:cancel";
|
||||
const WINDOW_WIDTH = 360;
|
||||
@@ -25,10 +25,7 @@ export default function LoginWaitingForBrowserDialog() {
|
||||
|
||||
const reportOpenFailure = useCallback(
|
||||
(e: unknown) => {
|
||||
void errorDialog({
|
||||
Title: t("browserLogin.openFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
void errorDialogFor(t("browserLogin.openFailedTitle"), e);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
import { cn } from "@/lib/cn.ts";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialogFor } from "@/lib/errors.ts";
|
||||
import {
|
||||
startConnection,
|
||||
EVENT_BROWSER_LOGIN_CANCEL,
|
||||
@@ -40,8 +40,6 @@ const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed
|
||||
|
||||
const FORCE_TOGGLE_DELAY_MS = 7000;
|
||||
|
||||
const errorMessage = formatErrorMessage;
|
||||
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
@@ -100,10 +98,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.connectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("connect.error.connectTitle"), e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,10 +110,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.disconnectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("connect.error.disconnectTitle"), e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,10 +201,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
} catch (e) {
|
||||
setAction(null);
|
||||
await refresh();
|
||||
await errorDialog({
|
||||
Title: t("connect.error.disconnectTitle"),
|
||||
Message: errorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("connect.error.disconnectTitle"), e);
|
||||
}
|
||||
};
|
||||
const show = connState === ConnectionState.Connected;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -45,10 +45,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(title, e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { isNetbirdCloud } from "@/hooks/useManagementUrl.ts";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE_ID = "default";
|
||||
|
||||
@@ -84,10 +84,7 @@ export function ProfilesTab() {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: title,
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(title, e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { Connection, Profiles as ProfilesSvc, Session, WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { EVENT_BROWSER_LOGIN_CANCEL, EVENT_TRIGGER_LOGIN } from "@/lib/connection";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
|
||||
import { errorDialogFor } from "@/lib/errors.ts";
|
||||
import { formatRemaining } from "@/lib/formatters";
|
||||
|
||||
const DEFAULT_SECONDS = 360;
|
||||
@@ -159,10 +159,7 @@ export default function SessionExpirationDialog() {
|
||||
WindowManager.CloseRenewFlow().catch(console.error);
|
||||
} catch (e) {
|
||||
resetDialog();
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.extendFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("sessionExpiration.extendFailedTitle"), e);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -174,10 +171,7 @@ export default function SessionExpirationDialog() {
|
||||
await WindowManager.CloseSessionExpiration();
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("connect.error.loginTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("connect.error.loginTitle"), e);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
@@ -194,10 +188,7 @@ export default function SessionExpirationDialog() {
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
await errorDialog({
|
||||
Title: t("sessionExpiration.logoutFailedTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(t("sessionExpiration.logoutFailedTitle"), e);
|
||||
}
|
||||
}, [busy, t]);
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { useStatus } from "@/contexts/StatusContext.tsx";
|
||||
import { useGuiVersion } from "@/hooks/useGuiVersion";
|
||||
import { UpdateVersionCard } from "@/modules/auto-update/UpdateVersionCard";
|
||||
import { useAccentTrigger } from "@/modules/settings/SettingsAccent";
|
||||
|
||||
@@ -38,7 +38,7 @@ function openUrl(url: string) {
|
||||
export function SettingsAbout() {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useStatus();
|
||||
const { guiVersion } = useSettings();
|
||||
const guiVersion = useGuiVersion();
|
||||
const daemonVersion = status?.daemonVersion ?? "—";
|
||||
|
||||
const handleVersionClick = useAccentTrigger();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -31,6 +31,17 @@ const enum Tab {
|
||||
About = "about",
|
||||
}
|
||||
|
||||
// Tabs that render the daemon's profile configuration. Only these mount
|
||||
// SettingsProvider, so a profile whose configuration this user may not read
|
||||
// still leaves the rest of the page reachable.
|
||||
const CONFIG_TABS: ReadonlySet<Tab> = new Set([
|
||||
Tab.General,
|
||||
Tab.Network,
|
||||
Tab.Security,
|
||||
Tab.SSH,
|
||||
Tab.Advanced,
|
||||
]);
|
||||
|
||||
const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.General]: <SettingsGeneral />,
|
||||
[Tab.Network]: <SettingsNetwork />,
|
||||
@@ -42,9 +53,16 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
|
||||
[Tab.About]: <SettingsAbout />,
|
||||
};
|
||||
|
||||
// WithSettings reads the daemon's profile configuration for the tabs that need
|
||||
// it. Radix keeps only the active tab's content mounted, so gating on the active
|
||||
// tab is what keeps the read off the tabs that do not use it.
|
||||
const WithSettings = ({ enabled, children }: { enabled: boolean; children: ReactNode }) =>
|
||||
enabled ? <SettingsProvider>{children}</SettingsProvider> : <>{children}</>;
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const navState = location.state as { tab?: string } | null;
|
||||
const [searchParams] = useSearchParams();
|
||||
const { mdm, features } = useRestrictions();
|
||||
|
||||
const visibleTabs = useMemo<Tab[]>(() => {
|
||||
@@ -63,7 +81,11 @@ export const SettingsPage = () => {
|
||||
}, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]);
|
||||
|
||||
const defaultTab = visibleTabs[0];
|
||||
const [active, setActive] = useState<string>(() => navState?.tab ?? defaultTab);
|
||||
// The window carries its tab in the URL, so the first render opens on the
|
||||
// requested one rather than on the default and then correcting itself.
|
||||
const [active, setActive] = useState<string>(
|
||||
() => navState?.tab ?? searchParams.get("tab") ?? defaultTab,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (navState?.tab) setActive(navState.tab);
|
||||
@@ -92,7 +114,7 @@ export const SettingsPage = () => {
|
||||
<SettingsNavigation />
|
||||
<AppRightPanel>
|
||||
<AutostartSettingsProvider>
|
||||
<SettingsProvider>
|
||||
<WithSettings enabled={CONFIG_TABS.has(active as Tab)}>
|
||||
<ScrollArea.Root
|
||||
key={active}
|
||||
type={"auto"}
|
||||
@@ -121,7 +143,7 @@ export const SettingsPage = () => {
|
||||
/>
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</SettingsProvider>
|
||||
</WithSettings>
|
||||
</AutostartSettingsProvider>
|
||||
</AppRightPanel>
|
||||
</VerticalTabs>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Restrictions, SetConfigParams } from "@bindings/services/models.js";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialogFor } from "@/lib/errors";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { isNetbirdCloud } from "@/hooks/useManagementUrl";
|
||||
import { WelcomeStepTray } from "./WelcomeStepTray";
|
||||
@@ -130,10 +130,7 @@ export default function WelcomeDialog() {
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
await errorDialogFor(i18next.t("settings.error.saveTitle"), e);
|
||||
throw e;
|
||||
}
|
||||
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Warten auf Autorisierung…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Profil eines anderen Benutzers"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Diese Aktion erfordert erhöhte Rechte."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Ein anderer Benutzer hat diesen Rechner verbunden."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Dieses Profil gehört einem anderen Benutzer."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1850,5 +1850,21 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Waiting for authorization…",
|
||||
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Another user's profile",
|
||||
"description": "Shown in place of the active profile's name when it belongs to a different user account."
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "This action requires elevated privileges.",
|
||||
"description": "Short headline when the daemon refuses an action that needs elevated privileges. The daemon's own sentence, naming the action and what it needs, is shown as the detail."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Another user has this machine connected.",
|
||||
"description": "Short headline when the daemon refuses an action because a different user account holds the active connection. The daemon's own sentence is shown as the detail."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "This profile belongs to another user.",
|
||||
"description": "Short headline when the daemon refuses an action because the profile it addresses is owned by a different user account."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Esperando la autorización…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Perfil de otro usuario"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Esta acción requiere privilegios elevados."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Otro usuario tiene esta máquina conectada."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Este perfil pertenece a otro usuario."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Profil d’un autre utilisateur"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Cette action nécessite des privilèges élevés."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Un autre utilisateur a connecté cette machine."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Ce profil appartient à un autre utilisateur."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Várakozás az engedélyezésre…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Másik felhasználó profilja"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Ehhez a művelethez emelt szintű jogosultság szükséges."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Egy másik felhasználó csatlakoztatta ezt a gépet."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Ez a profil egy másik felhasználóé."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "In attesa dell'autorizzazione…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Profilo di un altro utente"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Questa azione richiede privilegi elevati."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Un altro utente è già connesso su questa macchina."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Questo profilo appartiene a un altro utente."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "承認を待っています…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "別のユーザーのプロファイル"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "この操作には昇格した権限が必要です。"
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "別のユーザーがこのマシンを接続しています。"
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "このプロファイルは別のユーザーのものです。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Aguardando a autorização…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Perfil de outro usuário"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Esta ação requer privilégios elevados."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Outro usuário está com esta máquina conectada."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Este perfil pertence a outro usuário."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Ожидание авторизации…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Профиль другого пользователя"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Для этого действия нужны повышенные права."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Другой пользователь подключил эту машину."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Этот профиль принадлежит другому пользователю."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1387,5 +1387,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Очікування авторизації…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Профіль іншого користувача"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Ця дія потребує підвищених привілеїв."
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "Інший користувач підключив цю машину."
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "Цей профіль належить іншому користувачеві."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1389,5 +1389,17 @@
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "正在等待授权…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "其他用户的配置文件"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "此操作需要提升的权限。"
|
||||
},
|
||||
"error.session_held": {
|
||||
"message": "其他用户已连接此机器。"
|
||||
},
|
||||
"error.not_profile_owner": {
|
||||
"message": "此配置文件属于其他用户。"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -105,11 +105,9 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
profiles := services.NewProfiles(conn)
|
||||
// updater.Holder owns the typed update State; DaemonFeed feeds it and the
|
||||
// Update service is a thin Wails-bound facade over it plus the install RPCs.
|
||||
updaterHolder := updater.NewHolder(app.Event)
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||
notifier := newNotifier()
|
||||
compat := services.NewCompat(conn)
|
||||
@@ -128,6 +126,8 @@ func main() {
|
||||
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
profiles := services.NewProfiles(conn, bundle, prefStore)
|
||||
update := services.NewUpdate(conn, updaterHolder, bundle, prefStore)
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
profileSwitcher := services.NewProfileSwitcher(profiles, connection, daemonFeed)
|
||||
@@ -338,7 +338,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) {
|
||||
app.RegisterService(application.NewService(s.networks))
|
||||
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||
app.RegisterService(application.NewService(s.profiles))
|
||||
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
||||
app.RegisterService(application.NewService(services.NewDebug(conn, s.bundle, s.prefStore)))
|
||||
app.RegisterService(application.NewService(s.update))
|
||||
app.RegisterService(application.NewService(s.daemonFeed))
|
||||
app.RegisterService(application.NewService(s.notifier))
|
||||
|
||||
+25
-16
@@ -38,17 +38,20 @@ type LogLevel struct {
|
||||
}
|
||||
|
||||
type Debug struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewDebug(conn DaemonConn) *Debug {
|
||||
return &Debug{conn: conn}
|
||||
// NewDebug wires up a Debug service. translator or prefs may be nil, in which
|
||||
// case classification falls back to the bare error key.
|
||||
func NewDebug(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Debug {
|
||||
return &Debug{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
return DebugBundleResult{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
@@ -59,7 +62,7 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
return DebugBundleResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return DebugBundleResult{
|
||||
Path: resp.GetPath(),
|
||||
@@ -71,11 +74,11 @@ func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleRes
|
||||
func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
return LogLevel{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
return LogLevel{}, s.classifier.classify(err)
|
||||
}
|
||||
return LogLevel{Level: resp.GetLevel().String()}, nil
|
||||
}
|
||||
@@ -104,29 +107,33 @@ func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
|
||||
func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.StartBundleCaptureRequest{}
|
||||
if timeoutSeconds > 0 {
|
||||
req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second)
|
||||
}
|
||||
_, err = cli.StartBundleCapture(ctx, req)
|
||||
return err
|
||||
if _, err := cli.StartBundleCapture(ctx, req); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Debug) StopBundleCapture(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
_, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{})
|
||||
return err
|
||||
if _, err := cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
// proto.LogLevel_value keys are upper-case enum names; callers pass
|
||||
// lowercase logrus names. Upper-case before lookup or a valid level
|
||||
@@ -135,6 +142,8 @@ func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
if !ok {
|
||||
level = int32(proto.LogLevel_INFO)
|
||||
}
|
||||
_, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)})
|
||||
return err
|
||||
if _, err := cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)}); err != nil {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
@@ -15,19 +14,27 @@ import (
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
|
||||
// carries one.
|
||||
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
|
||||
for _, detail := range gstatus.Convert(err).Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
|
||||
return info, true
|
||||
}
|
||||
// denialCode maps a refusal to the code the frontend presents it by, reporting
|
||||
// false for a reason this build does not know. An unknown reason keeps the
|
||||
// summary the daemon wrote and loses only the tailored presentation, which is
|
||||
// what makes adding a reason daemon-side safe.
|
||||
func denialCode(reason string) (string, bool) {
|
||||
switch reason {
|
||||
case ipcauth.ErrorReasonPrivilegeRequired:
|
||||
return "privilege_required", true
|
||||
case ipcauth.ErrorReasonSessionHeld:
|
||||
return "session_held", true
|
||||
case ipcauth.ErrorReasonNotProfileOwner:
|
||||
return "not_profile_owner", true
|
||||
default:
|
||||
return "permission_denied", false
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// privilegeRefused reports whether the daemon refused for want of privileges.
|
||||
func privilegeRefused(err error) bool {
|
||||
denial, ok := ipcauth.DenialFrom(err)
|
||||
return ok && denial.Reason == ipcauth.ErrorReasonPrivilegeRequired
|
||||
}
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
@@ -40,9 +47,10 @@ type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend. The frontend
|
||||
// translates Code via i18n; Short is an English fallback; Long carries the
|
||||
// unwrapped daemon message.
|
||||
// ClientError is a structured error returned to the frontend. Short is the
|
||||
// localised headline, Long the unwrapped daemon message shown under it, and Code
|
||||
// the stable identifier Short was resolved from. The frontend reads Short, Long
|
||||
// and Command; it does not translate Code itself.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
@@ -94,21 +102,8 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
|
||||
// A refusal for want of privileges carries its own summary and the command
|
||||
// that performs the operation, both written for the user. Surface them
|
||||
// verbatim: no substring guessing, and no localisation of a message the
|
||||
// daemon composed.
|
||||
if info, ok := privilegeErrorInfo(err); ok {
|
||||
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
|
||||
if summary == "" {
|
||||
summary = msg
|
||||
}
|
||||
return &ClientError{
|
||||
Code: "privilege_required",
|
||||
Short: summary,
|
||||
Long: summary,
|
||||
Command: info.GetMetadata()[ipcauth.ErrorMetaCommand],
|
||||
}
|
||||
if denial, ok := ipcauth.DenialFrom(err); ok {
|
||||
return c.classifyDenial(denial)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(msg)
|
||||
@@ -155,6 +150,24 @@ func (c errorClassifier) classify(err error) *ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
// classifyDenial presents a refusal the daemon explained: a localised headline
|
||||
// for the reasons this build knows, with the daemon's own sentence as the
|
||||
// detail the frontend shows under it. An unrecognised reason keeps that sentence
|
||||
// as the headline too, so a reason added daemon-side still reaches the user.
|
||||
func (c errorClassifier) classifyDenial(denial ipcauth.Denial) *ClientError {
|
||||
code, known := denialCode(denial.Reason)
|
||||
short := denial.Summary
|
||||
if known {
|
||||
short = c.translateShort(code)
|
||||
}
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: short,
|
||||
Long: denial.Summary,
|
||||
Command: denial.Command,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code, returning the
|
||||
// bare "error.<code>" key when no translation is available so the gap stays visible.
|
||||
func (c errorClassifier) translateShort(code string) string {
|
||||
|
||||
@@ -4,11 +4,17 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
)
|
||||
|
||||
func TestErrorClassifier_Classify(t *testing.T) {
|
||||
@@ -48,3 +54,106 @@ func TestErrorClassifier_Classify(t *testing.T) {
|
||||
require.Nil(t, c.classify(nil))
|
||||
})
|
||||
}
|
||||
|
||||
// Every reason the daemon explains gets its own code, and the headline is looked
|
||||
// up from that code rather than repeating the daemon's sentence, so a held
|
||||
// session reads differently from a privilege refusal.
|
||||
func TestClassifyMapsEveryDaemonReason(t *testing.T) {
|
||||
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
code string
|
||||
command bool
|
||||
}{
|
||||
{"privilege", ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"), "privilege_required", true},
|
||||
{"session held", ipcauth.SessionHeldError("switching profile"), "session_held", true},
|
||||
{"not owner", ipcauth.NotOwnerError("reading the profile configuration"), "not_profile_owner", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.classify(tc.err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tc.code, got.Code)
|
||||
assert.Equal(t, "error."+tc.code, got.Short, "Short comes from the locale bundle, not the daemon")
|
||||
assert.NotEmpty(t, got.Long, "the daemon's sentence has to reach the user")
|
||||
assert.NotContains(t, got.Long, "rpc error")
|
||||
assert.Equal(t, tc.command, got.Command != "")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every denial code needs an entry in the shipped bundle, or the dialog shows a
|
||||
// bare "error.<code>" key where the headline should be. Resolved against the real
|
||||
// locale tree so a reason added without a translation fails here, not on screen.
|
||||
func TestDenialHeadlinesResolveInTheShippedBundle(t *testing.T) {
|
||||
bundle, err := i18n.NewBundle(os.DirFS("../i18n/locales"))
|
||||
require.NoError(t, err, "the shipped locale tree must load")
|
||||
|
||||
c := errorClassifier{translator: bundle}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
short string
|
||||
long string
|
||||
}{
|
||||
{
|
||||
"privilege",
|
||||
ipcauth.PrivilegeError("Claiming a profile requires root.", "sudo netbird profile claim"),
|
||||
"This action requires elevated privileges.",
|
||||
"Claiming a profile requires root.",
|
||||
},
|
||||
{
|
||||
"session held",
|
||||
ipcauth.SessionHeldError("switching profile"),
|
||||
"Another user has this machine connected.",
|
||||
"Switching profile is refused while another user has this machine connected.",
|
||||
},
|
||||
{
|
||||
"not owner",
|
||||
ipcauth.NotOwnerError("reading the profile configuration"),
|
||||
"This profile belongs to another user.",
|
||||
"Reading the profile configuration is refused because the profile it addresses belongs to another user.",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := c.classify(tc.err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tc.short, got.Short, "Short should be the localised headline for the code")
|
||||
assert.Contains(t, got.Long, tc.long, "Long should carry the daemon's own sentence")
|
||||
assert.NotEqual(t, got.Short, got.Long, "a repeated sentence costs the frontend its detail line")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A reason added daemon-side must still reach the user, losing only the tailored
|
||||
// presentation. It must not borrow another code's headline: "permission_denied"
|
||||
// is the sign-in rejection, which has nothing to do with an IPC refusal.
|
||||
func TestClassifyKeepsTheSentenceForAnUnknownReason(t *testing.T) {
|
||||
code, known := denialCode("SOMETHING_NEW")
|
||||
assert.False(t, known, "an unrecognised reason must not claim a tailored headline")
|
||||
assert.Equal(t, "permission_denied", code)
|
||||
|
||||
const summary = "Doing something new is refused for a reason this build predates."
|
||||
st, err := gstatus.New(gcodes.PermissionDenied, summary).WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: "SOMETHING_NEW",
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{ipcauth.ErrorMetaSummary: summary},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
got := errorClassifier{}.classify(st.Err())
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, summary, got.Short, "the daemon's sentence stands in for the headline")
|
||||
assert.Equal(t, summary, got.Long)
|
||||
}
|
||||
|
||||
// Only a privilege refusal is answered by offering to elevate. Offering it for
|
||||
// a session another user holds would be nonsense.
|
||||
func TestPrivilegeRefusedIsNarrow(t *testing.T) {
|
||||
assert.True(t, privilegeRefused(ipcauth.PrivilegeError("x", "y")))
|
||||
assert.False(t, privilegeRefused(ipcauth.SessionHeldError("connecting")))
|
||||
assert.False(t, privilegeRefused(ipcauth.NotOwnerError("connecting")))
|
||||
assert.False(t, privilegeRefused(errors.New("connection refused")))
|
||||
}
|
||||
|
||||
@@ -54,11 +54,14 @@ type RenameProfileParams struct {
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewProfiles(conn DaemonConn) *Profiles {
|
||||
return &Profiles{conn: conn}
|
||||
// NewProfiles wires up a Profiles service. translator or prefs may be nil, in
|
||||
// which case classification falls back to the bare error key.
|
||||
func NewProfiles(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Profiles {
|
||||
return &Profiles{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
// Username returns the OS username the daemon expects for profile lookups.
|
||||
@@ -73,11 +76,11 @@ func (s *Profiles) Username() (string, error) {
|
||||
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, s.classifier.classify(err)
|
||||
}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
out := make([]Profile, 0, len(resp.GetProfiles()))
|
||||
@@ -94,11 +97,11 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
|
||||
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
return ActiveProfile{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
return ActiveProfile{}, s.classifier.classify(err)
|
||||
}
|
||||
return ActiveProfile{
|
||||
ID: resp.GetId(),
|
||||
@@ -114,7 +117,7 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.SwitchProfileRequest{}
|
||||
if p.ProfileName != "" {
|
||||
@@ -125,7 +128,7 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
}
|
||||
resp, err := cli.SwitchProfile(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
@@ -136,14 +139,14 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
@@ -151,14 +154,14 @@ func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
@@ -188,7 +191,7 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
|
||||
Username: p.Username,
|
||||
@@ -196,7 +199,7 @@ func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, e
|
||||
NewProfileName: p.NewName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifier.classify(err)
|
||||
}
|
||||
return resp.GetOldProfileName(), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// stubProfileDaemon refuses SwitchProfile the way the daemon refuses a caller
|
||||
// who does not hold the session. The embedded interface is nil, so any other
|
||||
// call panics rather than passing quietly.
|
||||
type stubProfileDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *stubProfileDaemon) SwitchProfile(_ context.Context, _ *proto.SwitchProfileRequest, _ ...grpc.CallOption) (*proto.SwitchProfileResponse, error) {
|
||||
return nil, d.err
|
||||
}
|
||||
|
||||
// sessionHeldRefusal is the error the daemon raises when another user holds the
|
||||
// connection, detail and all: see ipcauth.SessionHeldError.
|
||||
func sessionHeldRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, sessionHeldSummaryText+"\n\nsudo netbird down").
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonSessionHeld,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: sessionHeldSummaryText,
|
||||
ipcauth.ErrorMetaCommand: "sudo netbird down",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
const sessionHeldSummaryText = "Switching profiles is refused while another user has this machine connected."
|
||||
|
||||
func profilesRefusingSwitch(t *testing.T) *Profiles {
|
||||
t.Helper()
|
||||
// nil translator → Short is the bare "error.<code>" key, which is enough to
|
||||
// tell a resolved headline from the daemon's own sentence.
|
||||
return NewProfiles(stubConn{client: &stubProfileDaemon{err: sessionHeldRefusal(t)}}, nil, nil)
|
||||
}
|
||||
|
||||
// A refused switch has to reach the caller as the classified value, since that is
|
||||
// the only thing carrying the headline and the command the frontend renders.
|
||||
func TestProfilesSwitchClassifiesRefusal(t *testing.T) {
|
||||
_, err := profilesRefusingSwitch(t).Switch(context.Background(), ProfileRef{ProfileName: "work"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "Switch must return the classified error, got %T", err)
|
||||
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, sessionHeldSummaryText, clientErr.Long, "the daemon's sentence is the detail")
|
||||
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
|
||||
}
|
||||
|
||||
// The switcher used to wrap this in fmt.Errorf, which left the Wails binding
|
||||
// nothing to marshal and put the raw "switch profile %q: rpc error: ..." string
|
||||
// in front of the user instead of the headline and the copyable command.
|
||||
func TestProfileSwitcherReturnsClassifiedRefusal(t *testing.T) {
|
||||
switcher := NewProfileSwitcher(profilesRefusingSwitch(t), nil, nil)
|
||||
|
||||
err := switcher.SwitchActive(context.Background(), ProfileRef{ProfileName: "01HZY0000000000000000000"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "the switcher must pass the classified error through, got %T", err)
|
||||
assert.Equal(t, "session_held", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, "sudo netbird down", clientErr.Command, "the suggested command survives")
|
||||
assert.Equal(t, "error.session_held", err.Error(),
|
||||
"no wrapping prefix and no gRPC dump in front of the headline")
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -68,9 +67,12 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
// Returned unwrapped: the Wails binding marshals the outermost error, so a
|
||||
// wrapper replaces the classified headline and the daemon's suggested
|
||||
// command with a raw gRPC string. The Infof above names the profile.
|
||||
resolvedID, err := s.profiles.Switch(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
|
||||
@@ -90,8 +92,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
|
||||
}
|
||||
|
||||
if connect {
|
||||
// Unwrapped for the same reason as the switch above.
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,14 +133,16 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
return Config{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
// Reading another user's profile is refused here, and the settings
|
||||
// screen puts the result straight in front of the user.
|
||||
return Config{}, s.classifier.classify(err)
|
||||
}
|
||||
return Config{
|
||||
ManagementURL: resp.GetManagementUrl(),
|
||||
@@ -175,7 +177,7 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
@@ -207,7 +209,7 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcom
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
if _, refused := privilegeErrorInfo(err); refused {
|
||||
if privilegeRefused(err) {
|
||||
return s.setConfigElevated(ctx, p, req, err)
|
||||
}
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
@@ -251,7 +253,7 @@ func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req
|
||||
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const notOwnerSummaryText = "Reading the profile configuration is refused because the profile it addresses belongs to another user."
|
||||
|
||||
// stubConfigDaemon refuses GetConfig the way the daemon refuses a caller who
|
||||
// does not own the profile the request names. The embedded interface is nil, so
|
||||
// any other call panics rather than passing quietly.
|
||||
type stubConfigDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *stubConfigDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
|
||||
return nil, d.err
|
||||
}
|
||||
|
||||
// notOwnerRefusal is the error the gate raises for a profile owned by somebody
|
||||
// else: see ipcauth.NotOwnerError. It carries no command on purpose — privilege
|
||||
// is not what the method asked for.
|
||||
func notOwnerRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, notOwnerSummaryText).
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonNotProfileOwner,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{ipcauth.ErrorMetaSummary: notOwnerSummaryText},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
// The settings screen reads the config on mount and shows whatever comes back,
|
||||
// so an unclassified refusal there is a raw gRPC dump in front of the user.
|
||||
func TestSettingsGetConfigClassifiesRefusal(t *testing.T) {
|
||||
// nil translator → Short is the bare "error.<code>" key.
|
||||
settings := NewSettings(stubConn{client: &stubConfigDaemon{err: notOwnerRefusal(t)}}, nil, nil, testDaemonAddr)
|
||||
|
||||
_, err := settings.GetConfig(context.Background(), ConfigParams{ProfileName: "01HZY0000000000000000000"})
|
||||
|
||||
clientErr, ok := err.(*ClientError)
|
||||
require.True(t, ok, "GetConfig must return the classified error, got %T", err)
|
||||
assert.Equal(t, "not_profile_owner", clientErr.Code, "the refusal reason decides the code")
|
||||
assert.Equal(t, notOwnerSummaryText, clientErr.Long, "the daemon's sentence is the detail")
|
||||
assert.Empty(t, clientErr.Command, "this refusal has no command to offer")
|
||||
}
|
||||
@@ -22,12 +22,15 @@ type UpdateResult struct {
|
||||
// Update is the Wails-bound facade over the daemon's update RPCs. The state
|
||||
// machine and push event live in client/ui/updater.
|
||||
type Update struct {
|
||||
conn DaemonConn
|
||||
holder *updater.Holder
|
||||
conn DaemonConn
|
||||
holder *updater.Holder
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
|
||||
return &Update{conn: conn, holder: holder}
|
||||
// NewUpdate wires up an Update service. translator or prefs may be nil, in
|
||||
// which case classification falls back to the bare error key.
|
||||
func NewUpdate(conn DaemonConn, holder *updater.Holder, translator ErrorTranslator, prefs LanguagePreference) *Update {
|
||||
return &Update{conn: conn, holder: holder, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
func (s *Update) GetState() updater.State {
|
||||
@@ -52,11 +55,11 @@ func (s *Update) Quit() {
|
||||
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
return UpdateResult{}, s.classifier.classify(err)
|
||||
}
|
||||
resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{})
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
return UpdateResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return UpdateResult{
|
||||
Success: resp.GetSuccess(),
|
||||
|
||||
@@ -269,7 +269,15 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
// settingsWindowURL is the start URL for a settings window showing tab. The tab
|
||||
// travels in the URL so the first render already has it. EventSettingsOpen
|
||||
// reaches the frontend only after it reports ready, by which point a tab that
|
||||
// reads the daemon config has mounted and sent its read.
|
||||
func settingsWindowURL(tab string) string {
|
||||
return "/#/settings?tab=" + url.QueryEscape(tab)
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow(tab string) *application.WebviewWindow {
|
||||
a := CurrentAppearance()
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: windowSettings,
|
||||
@@ -282,7 +290,7 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: "/#/settings",
|
||||
URL: settingsWindowURL(tab),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
@@ -305,7 +313,8 @@ func (s *WindowManager) OpenSettings(tab string) {
|
||||
target = "general"
|
||||
}
|
||||
|
||||
s.withWindow(windowSettings, &s.settings, s.newSettingsWindow, func(w *application.WebviewWindow, _ bool) {
|
||||
factory := func() *application.WebviewWindow { return s.newSettingsWindow(target) }
|
||||
s.withWindow(windowSettings, &s.settings, factory, func(w *application.WebviewWindow, _ bool) {
|
||||
s.mu.Lock()
|
||||
ready := s.ready[w.ID()]
|
||||
if !ready {
|
||||
|
||||
@@ -348,3 +348,12 @@ func TestCloseRenewFlowDuringBrowserLoginCreationRestoresHiddenWindows(t *testin
|
||||
require.Empty(t, s.creating)
|
||||
require.Empty(t, s.pendingClose)
|
||||
}
|
||||
|
||||
// The settings window opens on whichever tab the caller asked for, so a tab that
|
||||
// does not read the daemon configuration never mounts the one that does.
|
||||
func TestSettingsWindowURLCarriesTab(t *testing.T) {
|
||||
require.Equal(t, "/#/settings?tab=profiles", settingsWindowURL("profiles"))
|
||||
require.Equal(t, "/#/settings?tab=general", settingsWindowURL("general"))
|
||||
require.Equal(t, "/#/settings?tab=a%2Fb+c", settingsWindowURL("a/b c"),
|
||||
"a tab name is escaped rather than trusted to be URL-safe")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user