replace native confirm dialog with modals in settings

This commit is contained in:
Eduard Gert
2026-06-05 13:16:39 +02:00
parent 4427aaa31f
commit 5877880789
12 changed files with 279 additions and 101 deletions
@@ -3,18 +3,36 @@ import { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
// (ConfirmDialog, etc.). Renders a bordered dark tile with the provided
// lucide icon centered inside.
// (ConfirmDialog, etc.). Renders a bordered tile with the provided lucide
// icon centered inside. The `tone` selects the semantic colour scheme —
// `default` keeps the neutral dark tile; info/warning/danger tint the tile,
// border and icon to match the action's severity.
export type SquareIconTone = "default" | "info" | "warning" | "danger";
const toneClass: Record<SquareIconTone, string> = {
default: "bg-nb-gray-920 border-nb-gray-900 text-white",
info: "bg-sky-950 border-sky-500 text-sky-100",
warning: "bg-netbird-950 border-netbird text-netbird",
danger: "bg-red-950 border-red-500 text-red-500",
};
type SquareIconProps = {
icon: ComponentType<LucideProps>;
iconSize?: number;
tone?: SquareIconTone;
className?: string;
};
export const SquareIcon = ({ icon: Icon, iconSize = 20, className }: SquareIconProps) => (
export const SquareIcon = ({
icon: Icon,
iconSize = 20,
tone = "default",
className,
}: SquareIconProps) => (
<div
className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center bg-nb-gray-920 border border-nb-gray-900 text-white",
"h-11 w-11 rounded-lg flex items-center justify-center border",
toneClass[tone],
className,
)}
>
@@ -93,7 +93,7 @@ const buttonVariants = cva(
},
size: {
xs: "text-xs py-2.5 px-3.5",
xs2: "text-[0.78rem] py-2 px-4",
xs2: "text-[0.78rem] py-[1.1rem] px-5 leading-[0]",
sm: "text-sm py-[9px] px-4",
md: "py-[9px] px-4",
lg: "text-lg py-[9px] px-4",
@@ -0,0 +1,110 @@
import { ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Button } from "@/components/buttons/Button";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogActions } from "@/components/dialog/DialogActions";
// ConfirmModal is the shared in-app confirmation modal — a left-aligned
// title + (optionally multi-line) description with Cancel / confirm buttons
// in the footer. It's the in-window counterpart to the native warningDialog.
//
// Most call sites should not render this directly: use the imperative
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
// single instance at the provider level. Render ConfirmModal yourself only
// when you need bespoke control over its open/busy lifecycle.
type ConfirmModalProps = {
open: boolean;
title: ReactNode;
description: ReactNode;
/** Confirm button label. */
confirmLabel: string;
/** Cancel button label; defaults to the shared "Cancel" string. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
/** Disable the buttons (and ignore dismiss) while an action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
export const ConfirmModal = ({
open,
title,
description,
confirmLabel,
cancelLabel,
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
// Retain the last shown content so it stays rendered through Radix's
// close animation instead of blanking out the instant the caller clears
// its state on close.
type Snapshot = Pick<ConfirmModalProps, "title" | "description" | "confirmLabel" | "danger"> & {
cancelLabel: string;
};
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const resolvedCancel = cancelLabel ?? t("common.cancel");
useEffect(() => {
if (open) {
setSnapshot({ title, description, confirmLabel, cancelLabel: resolvedCancel, danger });
}
}, [open, title, description, confirmLabel, resolvedCancel, danger]);
const view = open
? { title, description, confirmLabel, cancelLabel: resolvedCancel, danger }
: snapshot;
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
}}
>
<Dialog.Content
maxWidthClass="max-w-sm"
showClose={false}
className="py-5"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{view && (
<div className="flex flex-col gap-5 px-5">
<div className="flex flex-col gap-1 pl-1">
<DialogHeading align={"left"}>{view.title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
{view.description}
</DialogDescription>
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button
variant={"secondary"}
size={"xs2"}
disabled={busy}
onClick={onCancel}
>
{view.cancelLabel}
</Button>
<Button
autoFocus
variant={view.danger ? "danger" : "primary"}
size={"xs2"}
disabled={busy}
onClick={onConfirm}
>
{view.confirmLabel}
</Button>
</DialogActions>
</div>
)}
</Dialog.Content>
</Dialog.Root>
);
};
@@ -15,7 +15,7 @@ const Overlay = forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/40 backdrop-blur-sm",
"bg-black/60",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"duration-150 ease-out",
@@ -0,0 +1,74 @@
import { createContext, ReactNode, useCallback, useContext, useRef, useState } from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
// DialogContext exposes an imperative `confirm(...)` that resolves to a
// boolean — the in-app equivalent of the native warningDialog promise. The
// single <ConfirmModal/> lives here at the provider level, so call sites
// just `await confirm({...})` instead of each wiring up their own modal
// component + open/busy state.
//
// const confirm = useConfirm();
// if (await confirm({ title, description, confirmLabel })) { …do it… }
//
// Mounted once (outermost in AppLayout) so it's available in every in-window
// route across both the main and settings windows.
export type ConfirmOptions = {
title: ReactNode;
description: ReactNode;
confirmLabel: string;
/** Defaults to the shared "Cancel" string inside ConfirmModal. */
cancelLabel?: string;
/** Use the destructive (red) confirm button variant. */
danger?: boolean;
};
type DialogContextValue = {
confirm: (options: ConfirmOptions) => Promise<boolean>;
};
const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
// Resolve the pending promise and start the close animation. The options
// stay in state so ConfirmModal still has content to render while it
// animates out.
const settle = (result: boolean) => {
resolverRef.current?.(result);
resolverRef.current = null;
setOpen(false);
};
return (
<DialogContext.Provider value={{ confirm }}>
{children}
<ConfirmModal
open={open}
title={options?.title ?? ""}
description={options?.description ?? ""}
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
/>
</DialogContext.Provider>
);
}
export const useConfirm = () => {
const ctx = useContext(DialogContext);
if (!ctx) throw new Error("useConfirm must be used within a DialogProvider");
return ctx.confirm;
};
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { warningDialog } from "@/lib/dialogs.ts";
import i18next from "@/lib/i18n";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { useConfirm } from "@/contexts/DialogContext.tsx";
export const CLOUD_MANAGEMENT_URL = "https://api.netbird.io:443";
@@ -77,6 +77,8 @@ function modeFromUrl(url: string): ManagementMode {
}
export function useManagementUrl() {
const { t } = useTranslation();
const confirm = useConfirm();
const { config, saveField } = useSettings();
const [mode, setModeState] = useState<ManagementMode>(
modeFromUrl(config.managementUrl),
@@ -84,11 +86,6 @@ export function useManagementUrl() {
const [url, setUrl] = useState(
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
);
// Guard against double-showing the cloud-switch confirmation when the
// user toggles the segmented control multiple times before the prior
// Dialogs.Warning promise resolves. Without it each click queues a
// fresh native dialog and the user sees them stack up.
const switchConfirmOpenRef = useRef(false);
useEffect(() => {
setModeState(modeFromUrl(config.managementUrl));
@@ -97,34 +94,22 @@ export function useManagementUrl() {
}
}, [config.managementUrl]);
const setMode = (next: ManagementMode) => {
const setMode = async (next: ManagementMode) => {
if (
next === ManagementMode.Cloud &&
config.managementUrl !== CLOUD_MANAGEMENT_URL
) {
// Switching from a self-hosted management server to NetBird Cloud
// re-points the client at a different deployment and forces a
// reconnect/re-login. Confirm before applying.
if (switchConfirmOpenRef.current) return;
switchConfirmOpenRef.current = true;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm");
void warningDialog({
Title: i18next.t("settings.general.management.switchCloudTitle"),
Message: i18next.t("settings.general.management.switchCloudMessage"),
Buttons: [
{ Label: cancelLabel, IsCancel: true, IsDefault: true },
{ Label: confirmLabel },
],
})
.then((result) => {
if (result !== confirmLabel) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
})
.finally(() => {
switchConfirmOpenRef.current = false;
});
// reconnect/re-login. Confirm via the in-app modal before applying.
const ok = await confirm({
title: t("settings.general.management.switchCloudTitle"),
description: t("settings.general.management.switchCloudMessage"),
confirmLabel: t("settings.general.management.switchCloudConfirm"),
});
if (!ok) return;
setModeState(ManagementMode.Cloud);
void saveField("managementUrl", CLOUD_MANAGEMENT_URL);
return;
}
setModeState(next);
+12 -9
View File
@@ -3,6 +3,7 @@ import { ClientVersionProvider } from "@/contexts/ClientVersionContext.tsx";
import { StatusProvider } from "@/contexts/StatusContext.tsx";
import { DebugBundleProvider } from "@/contexts/DebugBundleContext.tsx";
import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
import { DialogProvider } from "@/contexts/DialogContext.tsx";
// Shared shell for every in-window route (main + settings). Owns the daemon-
// availability gate (via StatusProvider) and the providers every page needs.
@@ -14,15 +15,17 @@ import { ProfileProvider } from "@/contexts/ProfileContext.tsx";
export const AppLayout = () => {
return (
<div className={"relative flex h-full flex-col"}>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
<DialogProvider>
<StatusProvider>
<ProfileProvider>
<DebugBundleProvider>
<ClientVersionProvider>
<Outlet />
</ClientVersionProvider>
</DebugBundleProvider>
</ProfileProvider>
</StatusProvider>
</DialogProvider>
</div>
);
};
@@ -1,6 +1,6 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { errorDialog, warningDialog } from "@/lib/dialogs.ts";
import { errorDialog } from "@/lib/dialogs.ts";
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
@@ -11,6 +11,7 @@ import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import i18next from "@/lib/i18n";
import { useProfile } from "@/contexts/ProfileContext";
import { useConfirm } from "@/contexts/DialogContext";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { formatErrorMessage } from "@/lib/errors";
@@ -29,6 +30,7 @@ export function ProfilesTab() {
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const tabRootRef = useRef<HTMLDivElement>(null);
@@ -68,49 +70,35 @@ export function ProfilesTab() {
};
const handleSwitch = async (name: string) => {
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.switch.confirm");
const result = await warningDialog({
Title: i18next.t("profile.switch.title"),
Message: i18next.t("profile.switch.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (result !== confirmLabel) return;
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
scrollTabToTop();
};
const handleDeregister = async (name: string) => {
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.deregister.confirm");
const result = await warningDialog({
Title: i18next.t("profile.deregister.title"),
Message: i18next.t("profile.deregister.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
});
if (result !== confirmLabel) return;
if (!ok) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
};
const handleDelete = async (name: string) => {
if (name === DEFAULT_PROFILE) return;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("common.delete");
const result = await warningDialog({
Title: i18next.t("profile.delete.title"),
Message: i18next.t("profile.delete.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (result !== confirmLabel) return;
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
};