mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-20 21:59:07 +02:00
add custom error dialog
This commit is contained in:
@@ -6,6 +6,7 @@ import SessionExpiredDialog from "@/modules/session/SessionExpiredDialog.tsx";
|
||||
import SessionAboutToExpireDialog from "@/modules/session/SessionAboutToExpireDialog.tsx";
|
||||
import UpdateInProgressDialog from "@/modules/auto-update/UpdateInProgressDialog.tsx";
|
||||
import WelcomeDialog from "@/modules/welcome/WelcomeDialog.tsx";
|
||||
import ErrorDialog from "@/modules/error/ErrorDialog.tsx";
|
||||
import { AppLayout } from "@/layouts/AppLayout.tsx";
|
||||
import { MainPage } from "@/modules/main/MainPage.tsx";
|
||||
import { SettingsPage } from "@/modules/settings/SettingsPage.tsx";
|
||||
@@ -41,6 +42,7 @@ Promise.all([
|
||||
<Route path="session-expired" element={<SessionExpiredDialog />} />
|
||||
<Route path="session-about-to-expire" element={<SessionAboutToExpireDialog />} />
|
||||
<Route path="welcome" element={<WelcomeDialog />} />
|
||||
<Route path="error" element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
|
||||
@@ -4,35 +4,35 @@ import { cn } from "@/lib/cn";
|
||||
|
||||
// SquareIcon is the rounded-square icon tile used by dialog-style surfaces
|
||||
// (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";
|
||||
// icon centered inside. The `variant` selects the semantic colour scheme — all
|
||||
// variants keep the neutral dark tile + border; only the icon colour changes
|
||||
// to match the action's severity.
|
||||
export type SquareIconVariant = "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",
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
};
|
||||
|
||||
type SquareIconProps = {
|
||||
icon: ComponentType<LucideProps>;
|
||||
iconSize?: number;
|
||||
tone?: SquareIconTone;
|
||||
variant?: SquareIconVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SquareIcon = ({
|
||||
icon: Icon,
|
||||
iconSize = 20,
|
||||
tone = "default",
|
||||
variant = "default",
|
||||
className,
|
||||
}: SquareIconProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center border",
|
||||
toneClass[tone],
|
||||
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
// in the footer. It's the in-window counterpart to a native confirm dialog.
|
||||
//
|
||||
// Most call sites should not render this directly: use the imperative
|
||||
// `useConfirm()` from DialogContext (`await confirm({...})`), which mounts a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createContext, ReactNode, useCallback, useContext, useRef, useState } f
|
||||
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
|
||||
// boolean — the in-app equivalent of a native confirmation dialog. 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.
|
||||
|
||||
@@ -1,42 +1,30 @@
|
||||
import { Dialogs } from "@wailsio/runtime";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
|
||||
import { isWindows } from "@/lib/platform";
|
||||
// Options for errorDialog. Kept as a {Title, Message} object so the many
|
||||
// existing call sites read unchanged after the switch from the native OS
|
||||
// MessageBox to the custom window below.
|
||||
export type ErrorDialogOptions = {
|
||||
Title: string;
|
||||
Message: string;
|
||||
};
|
||||
|
||||
// Derived from the runtime rather than deep-imported: the package's exports map
|
||||
// only exposes the types barrel, not "@wailsio/runtime/types/dialogs".
|
||||
type MessageDialogOptions = Parameters<typeof Dialogs.Error>[0];
|
||||
|
||||
// On Windows a native MessageBox attached to a parent window disables that
|
||||
// parent (WS_DISABLED) for the lifetime of the dialog and re-enables it on
|
||||
// dismissal. When the parent is the main window — whose WindowClosing hook
|
||||
// hides instead of closes (main.go) — the enable/hide sequence can race and
|
||||
// leave the window unable to process its close (X) button afterwards: the user
|
||||
// reports the main window can no longer be closed once an error dialog (e.g. a
|
||||
// rejected login) has been shown. Detaching the dialog gives the MessageBox a
|
||||
// NULL owner, so no window is ever disabled and the X keeps working.
|
||||
// errorDialog surfaces a user-actionable failure. It opens the custom,
|
||||
// frameless, always-on-top NetBird error window (modules/error/ErrorDialog.tsx
|
||||
// via Go WindowManager.OpenError) — it is NOT the native OS MessageBox any
|
||||
// more, despite the name.
|
||||
//
|
||||
// macOS keeps the attached (sheet-style) presentation — the bug is Windows-only
|
||||
// and detaching there loses the sheet animation — so we only force Detached on
|
||||
// Windows and leave any caller-supplied value untouched elsewhere.
|
||||
function withDetached(options: MessageDialogOptions): MessageDialogOptions {
|
||||
if (options.Detached !== undefined || !isWindows()) {
|
||||
return options;
|
||||
}
|
||||
return { ...options, Detached: true };
|
||||
}
|
||||
|
||||
export function errorDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Error(withDetached(options));
|
||||
}
|
||||
|
||||
export function warningDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Warning(withDetached(options));
|
||||
}
|
||||
|
||||
export function infoDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Info(withDetached(options));
|
||||
}
|
||||
|
||||
export function questionDialog(options: MessageDialogOptions): Promise<string> {
|
||||
return Dialogs.Question(withDetached(options));
|
||||
// Why the native box is gone: on Windows a native MessageBox attached to a
|
||||
// parent window disables that window (WS_DISABLED) for its lifetime, and the
|
||||
// main window's WindowClosing hook hides instead of closing — the two raced
|
||||
// and could leave the main window unable to process its close (X) button after
|
||||
// an error was shown. The custom window has its own chrome and never touches
|
||||
// another window's enabled state, so that class of bug is gone (and with it
|
||||
// the old `Detached: true` Windows-only workaround, plus the warning/info/
|
||||
// question wrappers that nothing called).
|
||||
//
|
||||
// Title and message must already be localised. Resolves as soon as the window
|
||||
// is opened (it does not block until the user dismisses it), so `await`ing
|
||||
// callers continue immediately after the dialog appears.
|
||||
export function errorDialog(options: ErrorDialogOptions): Promise<void> {
|
||||
return WindowManager.OpenError(options.Title, options.Message);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const formatErrorMessage = (e: unknown): string => {
|
||||
const short = typeof ce.short === "string" ? ce.short : "";
|
||||
const long = typeof ce.long === "string" ? ce.long : "";
|
||||
if (short && long && long !== short) {
|
||||
return `${short}\n\nDetails: ${long}`;
|
||||
return `${short} Details: ${long}`;
|
||||
}
|
||||
if (short) return short;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertCircleIcon } from "lucide-react";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { DialogDescription } from "@/components/dialog/DialogDescription";
|
||||
import { DialogHeading } from "@/components/dialog/DialogHeading";
|
||||
import { SquareIcon } from "@/components/SquareIcon";
|
||||
import { WindowManager } from "@bindings/services";
|
||||
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
|
||||
|
||||
const WINDOW_WIDTH = 380;
|
||||
|
||||
// ErrorDialog is the app's error surface — a frameless, always-on-top
|
||||
// NetBird-chromed window opened by WindowManager.OpenError(title, message),
|
||||
// which the lib/dialogs.ts errorDialog() wrapper drives in place of the old
|
||||
// native OS MessageBox. Title and message arrive as query params (see
|
||||
// services/windowmanager.go errorDialogURL); both are caller-localised. The
|
||||
// title is also the window's chrome title ("NetBird - <title>", set Go-side);
|
||||
// it's repeated as the heading here so it stays visible on macOS, where the
|
||||
// hidden-inset title bar doesn't render the chrome title. The single Close
|
||||
// button (and the Escape key) dismisses the window via WindowManager.CloseError
|
||||
// — the Go side destroys it on close.
|
||||
export default function ErrorDialog() {
|
||||
const { t } = useTranslation();
|
||||
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const title = params.get("title") || t("window.title.error");
|
||||
const message = params.get("message") || "";
|
||||
|
||||
const close = useCallback(() => {
|
||||
WindowManager.CloseError().catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Escape closes — keyboard-accessible cancellation, matching the native
|
||||
// dialog's behaviour. The primary button is autoFocused below so Enter
|
||||
// also dismisses.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<ConfirmDialog ref={contentRef}>
|
||||
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
|
||||
|
||||
<div className={"flex flex-col items-center gap-1"}>
|
||||
<DialogHeading className={"text-balance"}>{title}</DialogHeading>
|
||||
{message && (
|
||||
<DialogDescription className={"text-balance"}>
|
||||
<span className={"whitespace-pre-wrap break-words"}>{message}</span>
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<Button
|
||||
autoFocus
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
className={"w-full"}
|
||||
onClick={close}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user