[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:
Theodor Midtlien
2026-09-17 14:36:56 +02:00
committed by GitHub
parent 801557e21b
commit cec9ee6699
69 changed files with 2912 additions and 681 deletions
@@ -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;
};
+2 -5
View File
@@ -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;
}
+30 -5
View File
@@ -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));