mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-04 12:39:06 +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));
|
||||
|
||||
Reference in New Issue
Block a user