mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-19 23:11:29 +02:00
replace native confirm dialog with modals in settings
This commit is contained in:
@@ -33,7 +33,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
|
||||
|
||||
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.
|
||||
|
||||
`AppLayout` is the only in-window layout. It mounts the shared provider stack (`StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`) inside a `relative flex h-full flex-col` shell and renders `<Outlet/>`. 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).
|
||||
`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).
|
||||
|
||||
Page-specific chrome lives next to the page, not in the layout:
|
||||
- **`pages/main/Main.tsx`** owns the `Header`, `ViewModeProvider`, and `NavSectionProvider`. All three are main-window-only:
|
||||
@@ -58,11 +58,11 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `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.
|
||||
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `contexts/` — every React context in the app lives here as a flat file (`StatusContext`, `ProfileContext`, `DebugBundleContext`, `ClientVersionContext`, `SettingsContext`, `NetworksContext`, `PeerDetailContext`, `ViewModeContext`, `NavSectionContext`, `DialogContext`). Single mental model: "where is the X context? `contexts/XContext.tsx`."
|
||||
- `components/` — presentational primitives, no domain coupling. Grouped by family:
|
||||
- `components/buttons/` — `Button`, `IconButton`.
|
||||
- `components/inputs/` — `Input`, `SearchInput`.
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog`.
|
||||
- `components/dialog/` — `Dialog`, `DialogActions`, `DialogDescription`, `DialogHeading`, `ConfirmDialog` (window-based dialog layout primitive), `ConfirmModal` (in-app Radix confirmation modal; usually driven via `useConfirm()` rather than rendered directly).
|
||||
- `components/switches/` — `SwitchItem`, `SwitchItemGroup`, `ToggleSwitch`, `FancyToggleSwitch`.
|
||||
- `components/typography/` — `Label`, `HelpText`.
|
||||
- `components/empty-state/` — `EmptyState`, `NoResults`, `NotConnectedState`.
|
||||
@@ -182,7 +182,7 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log
|
||||
|
||||
**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` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **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`.
|
||||
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`.
|
||||
|
||||
## Tailwind tokens
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
110
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
110
client/ui/frontend/src/components/dialog/ConfirmModal.tsx
Normal file
@@ -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",
|
||||
|
||||
74
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
74
client/ui/frontend/src/contexts/DialogContext.tsx
Normal file
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -110,14 +110,14 @@
|
||||
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
|
||||
"profile.dialog.placeholder": "z. B. Arbeit",
|
||||
|
||||
"profile.switch.title": "Profil wechseln",
|
||||
"profile.switch.message": "Zu \"{name}\" wechseln? Ihr aktuelles Profil wird getrennt.",
|
||||
"profile.switch.title": "Zu Profil \"{name}\" wechseln?",
|
||||
"profile.switch.message": "Sind Sie sicher, dass Sie das Profil wechseln möchten?\nIhr aktuelles Profil wird getrennt.",
|
||||
"profile.switch.confirm": "Wechseln",
|
||||
"profile.deregister.title": "Profil abmelden",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie \"{name}\" abmelden möchten? Sie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.deregister.title": "Profil \"{name}\" abmelden?",
|
||||
"profile.deregister.message": "Sind Sie sicher, dass Sie dieses Profil abmelden möchten?\nSie müssen sich erneut anmelden, um es zu nutzen.",
|
||||
"profile.deregister.confirm": "Abmelden",
|
||||
"profile.delete.title": "Profil löschen",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.delete.title": "Profil \"{name}\" löschen?",
|
||||
"profile.delete.message": "Sind Sie sicher, dass Sie dieses Profil löschen möchten?\nDiese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"profile.delete.disabledActive": "Aktive Profile können nicht gelöscht werden. Wechseln Sie zu einem anderen Profil, bevor Sie dieses löschen.",
|
||||
"profile.delete.disabledDefault": "Das Standardprofil kann nicht gelöscht werden.",
|
||||
"profile.error.switchTitle": "Profilwechsel fehlgeschlagen",
|
||||
@@ -159,7 +159,7 @@
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?",
|
||||
"settings.general.management.switchCloudMessage": "Dadurch wird die Verbindung zu Ihrem self-hosted Management-Server getrennt und eine neue Verbindung zu NetBird Cloud hergestellt. Möglicherweise müssen Sie sich erneut anmelden.",
|
||||
"settings.general.management.switchCloudMessage": "Dies trennt die Verbindung zu Ihrem self-hosted Server.\nMöglicherweise müssen Sie sich erneut anmelden.",
|
||||
"settings.general.management.switchCloudConfirm": "Zu Cloud wechseln",
|
||||
|
||||
"settings.network.section.connectivity": "Konnektivität",
|
||||
|
||||
@@ -118,14 +118,14 @@
|
||||
"header.menu.advancedView": "Advanced View",
|
||||
"header.menu.updateAvailable": "Update Available",
|
||||
|
||||
"profile.switch.title": "Switch Profile",
|
||||
"profile.switch.message": "Switch to \"{name}\"? Your current profile will be disconnected.",
|
||||
"profile.switch.title": "Switch Profile to \"{name}\"?",
|
||||
"profile.switch.message": "Are you sure you want to switch profiles?\nYour current profile will be disconnected.",
|
||||
"profile.switch.confirm": "Switch",
|
||||
"profile.deregister.title": "Deregister Profile",
|
||||
"profile.deregister.message": "Are you sure you want to deregister \"{name}\"? You will need to log in again to use it.",
|
||||
"profile.deregister.title": "Deregister Profile \"{name}\"?",
|
||||
"profile.deregister.message": "Are you sure you want to deregister this profile?\nYou will need to log in again to use it.",
|
||||
"profile.deregister.confirm": "Deregister",
|
||||
"profile.delete.title": "Delete Profile",
|
||||
"profile.delete.message": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"profile.delete.title": "Delete Profile \"{name}\"?",
|
||||
"profile.delete.message": "Are you sure you want to delete this profile?\nThis action cannot be undone.",
|
||||
"profile.delete.disabledActive": "Active profiles cannot be deleted. Switch to a different one before deleting this profile.",
|
||||
"profile.delete.disabledDefault": "The default profile cannot be deleted.",
|
||||
"profile.error.switchTitle": "Switch Profile Failed",
|
||||
@@ -182,7 +182,7 @@
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?",
|
||||
"settings.general.management.switchCloudMessage": "This will disconnect from your self-hosted management server and reconnect to NetBird Cloud. You may need to log in again.",
|
||||
"settings.general.management.switchCloudMessage": "This disconnects your self-hosted server.\nYou may need to log in again.",
|
||||
"settings.general.management.switchCloudConfirm": "Switch to Cloud",
|
||||
|
||||
"settings.network.section.connectivity": "Connectivity",
|
||||
|
||||
@@ -110,14 +110,14 @@
|
||||
"profile.dialog.description": "A profilok lehetővé teszik, hogy különálló NetBird-kapcsolatokat tartson egymás mellett. Adjon profiljának egy könnyen megjegyezhető nevet.",
|
||||
"profile.dialog.placeholder": "pl. Munka",
|
||||
|
||||
"profile.switch.title": "Profilváltás",
|
||||
"profile.switch.message": "Átvált erre: \"{name}\"? Az aktuális profilja le lesz választva.",
|
||||
"profile.switch.title": "Váltás a(z) \"{name}\" profilra?",
|
||||
"profile.switch.message": "Biztosan profilt szeretne váltani?\nAz aktuális profilja le lesz választva.",
|
||||
"profile.switch.confirm": "Váltás",
|
||||
"profile.deregister.title": "Profil leválasztása",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani a következőt: \"{name}\"? Újra be kell jelentkeznie a használatához.",
|
||||
"profile.deregister.title": "\"{name}\" profil leválasztása?",
|
||||
"profile.deregister.message": "Biztosan le szeretné választani ezt a profilt?\nÚjra be kell jelentkeznie a használatához.",
|
||||
"profile.deregister.confirm": "Leválasztás",
|
||||
"profile.delete.title": "Profil törlése",
|
||||
"profile.delete.message": "Biztosan törölni szeretné a következőt: \"{name}\"? Ez a művelet nem vonható vissza.",
|
||||
"profile.delete.title": "\"{name}\" profil törlése?",
|
||||
"profile.delete.message": "Biztosan törölni szeretné ezt a profilt?\nEz a művelet nem vonható vissza.",
|
||||
"profile.delete.disabledActive": "Aktív profilok nem törölhetők. Váltson másik profilra, mielőtt törölné ezt.",
|
||||
"profile.delete.disabledDefault": "Az alapértelmezett profil nem törölhető.",
|
||||
"profile.error.switchTitle": "Profilváltás sikertelen",
|
||||
@@ -159,7 +159,7 @@
|
||||
"settings.general.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.urlError": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?",
|
||||
"settings.general.management.switchCloudMessage": "Ez megszünteti a kapcsolatot a saját üzemeltetésű kezelőszerverrel, és újra csatlakozik a NetBird Cloudhoz. Lehet, hogy újra be kell jelentkeznie.",
|
||||
"settings.general.management.switchCloudMessage": "Ez leválasztja a saját üzemeltetésű kiszolgálót.\nLehet, hogy újra be kell jelentkeznie.",
|
||||
"settings.general.management.switchCloudConfirm": "Váltás a felhőre",
|
||||
|
||||
"settings.network.section.connectivity": "Kapcsolódás",
|
||||
|
||||
Reference in New Issue
Block a user