mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-04 12:39:06 +02:00
add custom error dialog
This commit is contained in:
@@ -29,9 +29,10 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
| `/dialog/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/session/) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
|
||||
| `/dialog/welcome` | `WelcomeDialog` (modules/welcome/) | none | Auxiliary window (Go `WindowManager.OpenWelcome`). First-launch onboarding — opened from `main.go`'s `ApplicationStarted` hook only when `prefStore.Get().OnboardingCompleted` is false. Two-step state machine: tray-screenshot pitch → Cloud-vs-self-hosted segmented control (conditional, see `shouldShowManagementStep`). Continue calls `Preferences.SetOnboardingCompleted(true)`, then `WindowManager.OpenMain()`, then `WindowManager.CloseWelcome()`. |
|
||||
| `/settings` | `SettingsPage` (modules/settings/) | `AppLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). Inherits the shared provider stack from `AppLayout`; the page itself adds the draggable strip + tabs. The `Profiles` tab (`modules/profiles/ProfilesTab.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")`. The window stays at `/#/settings` for its whole lifetime — no `SetURL` between opens, so `AppLayout`'s providers never remount. Tab is React local state, driven by the `netbird:settings:open` event Go emits before `Show`. Reset-to-General on close is handled in React via `document.visibilitychange` (Page Visibility API), which fires *before* WebKit throttles the hidden page, unlike Wails events from the Go close hook which race `Hide` and leave the previous tab visible for one frame on the next open. |
|
||||
| `/dialog/error` | `ErrorDialog` (modules/error/) | none | Auxiliary window (Go `WindowManager.OpenError(title, message)`, always-on-top). The app's single error surface — `lib/dialogs.ts`'s `errorDialog({Title, Message})` opens this instead of the old native OS MessageBox. `title` is the window chrome title (`"NetBird - <title>"`, set Go-side, not shown in body); `message` is read from `useSearchParams` and rendered as the left-aligned body next to a danger `SquareIcon`, with a bottom-right Close button (Escape also closes → `WindowManager.CloseError()`). |
|
||||
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
|
||||
|
||||
In `app.tsx` the four dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
|
||||
In `app.tsx` the dialog routes are nested under a parent `<Route path="dialog">` so the table reads as a tree, not a flat list. The Go side mirrors the prefix — `WindowManager` opens windows at `/#/dialog/<name>`. The `dialog` group has no shared layout component; it's purely a URL grouping.
|
||||
|
||||
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`DialogProvider → StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. `DialogProvider` is outermost (and outside the daemon-availability gate) so `useConfirm()` works everywhere regardless of daemon state. Both `Main` (route `/`) and `Settings` (route `/settings`) sit under it. Order matters: `SettingsContext` depends on `ProfileContext`, `ClientVersionContext` reads `StatusContext` events. `StatusProvider` (in `contexts/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. `ClientVersionProvider` no longer paints any inline overlay; install progress lives in its own auxiliary window (see `/install-progress` route).
|
||||
|
||||
@@ -55,6 +56,7 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `modules/session/` — `SessionExpiredDialog.tsx` and `SessionAboutToExpireDialog.tsx` (session lifecycle dialog windows).
|
||||
- `modules/auto-update/` — `UpdateInProgressDialog.tsx`, `UpdateBadge.tsx`, `UpdateVersionCard.tsx`. Context lives in `contexts/`.
|
||||
- `modules/profiles/` — `ProfileAvatar.tsx`, `ProfileDropdown.tsx`, `ProfileCreationModal.tsx`, `ProfilesTab.tsx`. Context lives in `contexts/`. The creation modal collects both the profile name and a management target (Cloud vs self-hosted + URL, reusing `ManagementServerSwitch` + the `useManagementUrl` helpers like the onboarding step); `ProfilesTab.handleCreate` adds the profile, `Settings.SetConfig`s the chosen `managementUrl` onto it (keyed by profile name, before switching), then switches to it. Row actions (switch/deregister/delete) confirm via the shared `useConfirm()` modal.
|
||||
- `modules/error/` — `ErrorDialog.tsx`, the custom always-on-top error window that replaced the native OS MessageBox. Opened by Go `WindowManager.OpenError(title, message)`, driven from the frontend by `errorDialog({Title, Message})` in `lib/dialogs.ts`.
|
||||
- `modules/welcome/` — first-launch onboarding dialog window. `WelcomeDialog.tsx` is the orchestrator (state machine over `tray → management → finish`); each step has its own file (`WelcomeStepTray`, `WelcomeStepManagement`). The `management` step is conditionally rendered: only when active profile is `"default"`, the profile email is empty, and the current management URL is cloud-default-or-empty (`shouldShowManagementStep` in the orchestrator). Reachability of self-hosted URLs is a soft warning via `hooks/useManagementUrl.ts checkManagementUrlReachable`; the user can re-click Continue to proceed despite a failed check. No login step — once the dialog closes, the user lands in the main window and clicks Connect there, which runs the connect toggle's local `startLogin` orchestrator.
|
||||
|
||||
Note: there's no `modules/daemon-status/` or `modules/debug-bundle/` folder. The daemon-status overlay is a generic presentational component (`components/empty-state/DaemonUnavailableOverlay.tsx`) and `useDebugBundle` is inlined into `contexts/DebugBundleContext.tsx` — both folders would be empty otherwise.
|
||||
@@ -180,9 +182,11 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
|
||||
|
||||
## Dialogs convention
|
||||
|
||||
**Always go through `src/lib/dialogs.ts`** — `errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime 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 races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged.
|
||||
**Errors → `errorDialog({Title, Message})` from `src/lib/dialogs.ts`**, never `Dialogs.*` from `@wailsio/runtime` directly. Despite the name, `errorDialog` no longer opens a native OS MessageBox — it opens the custom always-on-top `/#/dialog/error` window via Go `WindowManager.OpenError` (`modules/error/ErrorDialog.tsx`). The `{Title, Message}` signature was kept so existing call sites read unchanged. Use an action-named title ("Save Settings Failed", not "Error"). Title/message must already be localised. **Behaviour note:** `errorDialog()` resolves as soon as the window opens — it does *not* block until the user dismisses it, unlike the old native box; don't rely on the await pausing the flow.
|
||||
|
||||
Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). For **confirmations inside an app window** (the polished surfaces), prefer the in-app `useConfirm()` from `contexts/DialogContext.tsx` over the native `warningDialog` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). Reserve the native `warningDialog` (compare against the **Label string**, not an index) for confirmations raised outside a normal app window (tray-driven flows, etc.). **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`.
|
||||
Why the native box is gone: on Windows a native `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence raced and left the window unable to process its close (X) button afterwards. The custom window never touches another window's enabled state, so that bug (and the old `Detached: true` Windows workaround) is gone. The unused native `warningDialog` / `infoDialog` / `questionDialog` wrappers were removed at the same time.
|
||||
|
||||
For **confirmations inside an app window** (the polished surfaces), use the in-app `useConfirm()` from `contexts/DialogContext.tsx` — `const ok = await confirm({ title, description, confirmLabel, danger? })` resolves to a boolean. It renders a single shared `ConfirmModal` (left-aligned title + multi-line description, Cancel/confirm footer) mounted at the provider level, so call sites don't each wire up their own modal + open state. Used by the Profiles tab (switch/deregister/delete) and the management-server cloud switch (`useManagementUrl`). **Skip** dialogs entirely for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full convention rationale in `../CLAUDE.md`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
|
||||
@@ -166,8 +166,12 @@ Typical enforced-update flow on the `/update` route: call `Trigger` once, then p
|
||||
WindowManager.OpenSettings(): Promise<void>
|
||||
WindowManager.OpenBrowserLogin(uri: string): Promise<void> // uri appended as ?uri=…
|
||||
WindowManager.CloseBrowserLogin(): Promise<void>
|
||||
WindowManager.OpenError(title: string, message: string): Promise<void> // custom branded error window; both query-escaped as ?title=…&message=…
|
||||
WindowManager.CloseError(): Promise<void>
|
||||
```
|
||||
|
||||
Prefer `errorDialog({Title, Message})` from `lib/dialogs.ts` over calling `OpenError` directly — it's the app's single error surface (the old native MessageBox wrapper now routes here). Both strings must be pre-localised.
|
||||
|
||||
Both auxiliary windows are created on first open and destroyed on close (mutex-guarded singleton). The BrowserLogin window's red-X close fires the `browser-login:cancel` event so `startLogin()` can tear down the pending daemon `WaitSSOLogin`.
|
||||
|
||||
## `I18n`
|
||||
|
||||
@@ -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