mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 11:19:08 +02:00
replace native confirm dialog with modals in settings
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user