mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 23:41:28 +02:00
add cloud / selfhosted segment in profile creation
This commit is contained in:
@@ -54,7 +54,7 @@ Page-specific chrome lives next to the page, not in the layout:
|
||||
- `modules/login/` — `LoginWaitingForBrowserDialog.tsx` (the SSO browser-wait window).
|
||||
- `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/`.
|
||||
- `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/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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { ButtonHTMLAttributes, forwardRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -10,6 +10,10 @@ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, ButtonVar
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
copy?: string;
|
||||
// When true, the content is replaced by a centered spinner while keeping
|
||||
// the button's rendered width/height (the content stays in the layout,
|
||||
// just hidden). Also disables the button.
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const buttonVariants = cva(
|
||||
@@ -93,7 +97,7 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
xs: "text-xs py-2.5 px-3.5",
|
||||
xs2: "text-[0.78rem] py-[1.1rem] px-5 leading-[0]",
|
||||
xs2: "text-[0.78rem] py-[1.1rem] px-4 leading-[0]",
|
||||
sm: "text-sm py-[9px] px-4",
|
||||
md: "py-[9px] px-4",
|
||||
lg: "text-lg py-[9px] px-4",
|
||||
@@ -124,6 +128,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
onClick,
|
||||
disabled,
|
||||
copy,
|
||||
loading = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -134,7 +139,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant,
|
||||
@@ -159,8 +164,16 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{copy !== undefined && (copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
{loading && (
|
||||
<span className={"absolute inset-0 flex items-center justify-center"}>
|
||||
<Loader2 size={iconSize} className={"animate-spin"} />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("contents", loading && "invisible")}>
|
||||
{copy !== undefined &&
|
||||
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Input
|
||||
maxWidthClass?: string;
|
||||
icon?: ReactNode;
|
||||
error?: string;
|
||||
// A soft, non-blocking caveat rendered in orange (vs. error's red). Used
|
||||
// e.g. for "couldn't reach this server" where the value is syntactically
|
||||
// fine and the user may still proceed. `error` takes precedence.
|
||||
warning?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
copy?: boolean;
|
||||
@@ -33,6 +37,10 @@ const inputVariants = cva("", {
|
||||
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-red-500 text-red-500",
|
||||
"ring-offset-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10 focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70 placeholder:text-neutral-500 border-neutral-200 dark:border-orange-400 text-orange-400",
|
||||
"ring-offset-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10 focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
prefixSuffixVariant: {
|
||||
default: [
|
||||
@@ -53,6 +61,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
warning,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
@@ -174,7 +183,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
variant: error ? "error" : warning ? "warning" : variant,
|
||||
}),
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm select-text",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
@@ -238,9 +247,14 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<span className="text-xs text-red-500 mt-2 inline-flex items-center gap-1">
|
||||
{error}
|
||||
{(error || warning) && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs mt-2 inline-flex items-center gap-1",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,11 @@ export function useManagementUrl() {
|
||||
const [url, setUrl] = useState(
|
||||
config.managementUrl === CLOUD_MANAGEMENT_URL ? "" : config.managementUrl,
|
||||
);
|
||||
// Self-hosted reachability soft-check, mirrored from the onboarding /
|
||||
// profile-creation flows: a failed probe is a non-blocking orange warning,
|
||||
// and a second Save with the same URL goes through regardless.
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setModeState(modeFromUrl(config.managementUrl));
|
||||
@@ -94,6 +99,11 @@ export function useManagementUrl() {
|
||||
}
|
||||
}, [config.managementUrl]);
|
||||
|
||||
// Clear the stale warning whenever the target changes.
|
||||
useEffect(() => {
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const setMode = async (next: ManagementMode) => {
|
||||
if (
|
||||
next === ManagementMode.Cloud &&
|
||||
@@ -125,7 +135,22 @@ export function useManagementUrl() {
|
||||
const canSave = dirty && (mode === ManagementMode.Cloud || urlValid);
|
||||
const displayUrl = mode === ManagementMode.Cloud ? CLOUD_MANAGEMENT_URL : url;
|
||||
|
||||
const save = () => saveField("managementUrl", targetUrl);
|
||||
const save = async () => {
|
||||
// Self-hosted: probe the server first. A failed probe surfaces a soft
|
||||
// warning and bails; a second Save (unreachable already set) skips the
|
||||
// re-check and saves anyway, so the user can override a false negative.
|
||||
if (mode === ManagementMode.SelfHosted && !unreachable) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(targetUrl);
|
||||
setChecking(false);
|
||||
if (!reachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await saveField("managementUrl", targetUrl);
|
||||
setUnreachable(false);
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
@@ -136,5 +161,7 @@ export function useManagementUrl() {
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import * as Dialog from "@/components/dialog/Dialog";
|
||||
import { Input } from "@/components/inputs/Input";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { DialogActions } from "@/components/dialog/DialogActions";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
|
||||
import {
|
||||
CLOUD_MANAGEMENT_URL,
|
||||
ManagementMode,
|
||||
checkManagementUrlReachable,
|
||||
isValidManagementUrl,
|
||||
normalizeManagementUrl,
|
||||
} from "@/hooks/useManagementUrl";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (name: string) => void;
|
||||
// onCreate receives the sanitized profile name and the management URL the
|
||||
// user picked (the cloud default for Cloud mode, the normalized self-
|
||||
// hosted URL otherwise).
|
||||
onCreate: (name: string, managementUrl: string) => void;
|
||||
};
|
||||
|
||||
// Mirror of the daemon's profilemanager.sanitizeProfileName rule
|
||||
@@ -27,70 +40,177 @@ const sanitizeProfileInput = (value: string): string =>
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
// unreachable: soft warning. A second submit with the same URL proceeds
|
||||
// anyway (matches the onboarding management step's behaviour for self-
|
||||
// hosted servers behind internal DNS / VPN).
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("");
|
||||
setError(null);
|
||||
setNameError(null);
|
||||
setMode(ManagementMode.Cloud);
|
||||
setUrl("");
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
setChecking(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
// Reset the URL warnings whenever the user edits the URL or flips mode —
|
||||
// otherwise a stale warning lingers next to a just-corrected value.
|
||||
useEffect(() => {
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (checking) return;
|
||||
|
||||
const sanitized = sanitizeProfileInput(name);
|
||||
if (sanitized.length === 0) {
|
||||
setError(t("profile.dialog.required"));
|
||||
inputRef.current?.focus();
|
||||
setNameError(t("profile.dialog.required"));
|
||||
nameRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
onCreate(sanitized);
|
||||
|
||||
if (mode === ManagementMode.Cloud) {
|
||||
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || !isValidManagementUrl(trimmed)) {
|
||||
setUrlError(t("settings.general.management.urlError"));
|
||||
urlRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = normalizeManagementUrl(trimmed);
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
// First failed check: soft warning + bail. A second submit with the
|
||||
// same URL skips re-checking (unreachable still true) so the user can
|
||||
// proceed if they're sure.
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(sanitized, target);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(sanitizeProfileInput(value));
|
||||
if (error) setError(null);
|
||||
if (nameError) setNameError(null);
|
||||
};
|
||||
|
||||
// Live syntactic feedback: flag a non-empty, malformed URL as the user
|
||||
// types instead of waiting for submit. Empty is not an error yet (handled
|
||||
// on submit); the unreachable soft-warning only applies once syntax is OK.
|
||||
const trimmedUrl = url.trim();
|
||||
const showUrlSyntaxError =
|
||||
mode === ManagementMode.SelfHosted && trimmedUrl !== "" && !isValidManagementUrl(trimmedUrl);
|
||||
const urlInputError = showUrlSyntaxError
|
||||
? t("settings.general.management.urlError")
|
||||
: (urlError ?? undefined);
|
||||
// Soft, non-blocking caveat (orange) — only when the URL is otherwise OK.
|
||||
const urlInputWarning =
|
||||
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Content maxWidthClass="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<Dialog.Content
|
||||
maxWidthClass="max-w-md"
|
||||
showClose={false}
|
||||
className="py-7"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-8">
|
||||
<Dialog.Title>{t("profile.dialog.title")}</Dialog.Title>
|
||||
<Dialog.Description className="mt-1">
|
||||
{t("profile.dialog.description")}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 px-7">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("profile.dialog.nameLabel")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.description")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
ref={nameRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={nameError ?? undefined}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-8 pt-3">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
placeholder={t("profile.dialog.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
error={error ?? undefined}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={"pl-1"}>
|
||||
<Label as={"div"} className={"mb-0.5"}>
|
||||
{t("settings.general.management.label")}
|
||||
</Label>
|
||||
<HelpText margin={false}>
|
||||
{t("profile.dialog.managementHelp")}
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
|
||||
{mode === ManagementMode.SelfHosted && (
|
||||
<Input
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={urlInputError}
|
||||
warning={urlInputWarning}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer separator={false} className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size={"md"}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
{t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
disabled={checking}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary"}
|
||||
size={"xs2"}
|
||||
loading={checking}
|
||||
>
|
||||
{t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
@@ -12,6 +12,9 @@ import { Tooltip } from "@/components/Tooltip";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useConfirm } from "@/contexts/DialogContext";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { SetConfigParams } from "@bindings/services/models.js";
|
||||
import { CLOUD_MANAGEMENT_URL } from "@/hooks/useManagementUrl.ts";
|
||||
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
@@ -24,6 +27,7 @@ export function ProfilesTab() {
|
||||
profiles,
|
||||
activeProfile,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
@@ -33,26 +37,40 @@ export function ProfilesTab() {
|
||||
const confirm = useConfirm();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const tabRootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// After a successful switch we want to bring the user back to the top of
|
||||
// the tab — the table re-sorts the new active profile to the row 0 and a
|
||||
// user who scrolled to find a target down the list would otherwise lose
|
||||
// visual anchoring. Settings is hosted inside a Radix ScrollArea so we
|
||||
// walk up to the viewport (it owns the actual overflow) instead of
|
||||
// `window.scrollTo`, which is a no-op here.
|
||||
const scrollTabToTop = () => {
|
||||
const el = tabRootRef.current?.closest<HTMLElement>(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
);
|
||||
el?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const sorted = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
// The display order is established once — the active profile first, then
|
||||
// the rest alphabetically — and then held stable for the lifetime of the
|
||||
// window. Switching profiles must only flip the "active" badge, never
|
||||
// reorder the rows (otherwise the row the user just clicked jumps to the
|
||||
// top under their cursor). New profiles append at the end; removed ones
|
||||
// drop out. `orderRef` is the source of truth for row order; the active
|
||||
// badge is derived live from `activeProfile`.
|
||||
const orderRef = useRef<string[]>([]);
|
||||
const ordered = useMemo(() => {
|
||||
const present = new Set(profiles.map((p) => p.name));
|
||||
if (orderRef.current.length === 0) {
|
||||
// First population: active-first, then alphabetical.
|
||||
orderRef.current = [...profiles]
|
||||
.sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((p) => p.name);
|
||||
} else {
|
||||
// Preserve the established order; drop removed, append added.
|
||||
const kept = orderRef.current.filter((n) => present.has(n));
|
||||
const added = profiles
|
||||
.map((p) => p.name)
|
||||
.filter((n) => !orderRef.current.includes(n))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
orderRef.current = [...kept, ...added];
|
||||
}
|
||||
const byName = new Map(profiles.map((p) => [p.name, p]));
|
||||
return orderRef.current
|
||||
.map((n) => byName.get(n))
|
||||
.filter((p): p is Profile => p !== undefined);
|
||||
}, [profiles, activeProfile]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
@@ -77,7 +95,6 @@ export function ProfilesTab() {
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
scrollTabToTop();
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
@@ -102,9 +119,21 @@ export function ProfilesTab() {
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string) => {
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
try {
|
||||
await addProfile(name);
|
||||
// Only persist a management URL for self-hosted; a fresh profile
|
||||
// already defaults to NetBird Cloud, so writing the cloud URL
|
||||
// would be a no-op. Do it before switching so any reconnect the
|
||||
// switch triggers already targets the right deployment. SetConfig
|
||||
// is keyed by profile name, so it writes the new profile even
|
||||
// though it isn't active yet (adminUrl left empty — the daemon
|
||||
// keeps its loaded value).
|
||||
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: name, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(name);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
@@ -115,7 +144,7 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={tabRootRef}>
|
||||
<div>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
@@ -126,7 +155,7 @@ export function ProfilesTab() {
|
||||
>
|
||||
<table className={"w-full text-sm"}>
|
||||
<tbody>
|
||||
{sorted.map((profile) => (
|
||||
{ordered.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
@@ -139,7 +168,7 @@ export function ProfilesTab() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{loaded && sorted.length === 0 && (
|
||||
{loaded && ordered.length === 0 && (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-center justify-center py-10 text-center"
|
||||
|
||||
@@ -15,7 +15,17 @@ export function SettingsGeneral() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { autostart, setAutostartEnabled } = useAutostartSetting();
|
||||
const { mode, setMode, setUrl, displayUrl, showError, canSave, save } = useManagementUrl();
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
setUrl,
|
||||
displayUrl,
|
||||
showError,
|
||||
canSave,
|
||||
save,
|
||||
checking,
|
||||
unreachable,
|
||||
} = useManagementUrl();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMode = useRef(mode);
|
||||
@@ -79,11 +89,17 @@ export function SettingsGeneral() {
|
||||
? t("settings.general.management.urlError")
|
||||
: undefined
|
||||
}
|
||||
warning={
|
||||
unreachable
|
||||
? t("settings.general.management.urlUnreachable")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!canSave}
|
||||
loading={checking}
|
||||
onClick={() => save()}
|
||||
>
|
||||
{t("common.save")}
|
||||
|
||||
@@ -90,11 +90,13 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
|
||||
}
|
||||
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
|
||||
|
||||
const inputError = useMemo(() => {
|
||||
if (syntaxError) return syntaxError;
|
||||
if (unreachable) return t("welcome.management.urlUnreachable");
|
||||
return undefined;
|
||||
}, [syntaxError, unreachable, t]);
|
||||
// Syntax problems are hard errors (red); an unreachable-but-valid URL is
|
||||
// a soft, non-blocking caveat (orange).
|
||||
const inputError = syntaxError ?? undefined;
|
||||
const inputWarning = useMemo(
|
||||
() => (!syntaxError && unreachable ? t("welcome.management.urlUnreachable") : undefined),
|
||||
[syntaxError, unreachable, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -117,6 +119,7 @@ export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepMan
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={inputError}
|
||||
warning={inputWarning}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -107,12 +107,15 @@
|
||||
"profile.selector.delete": "Profil löschen",
|
||||
|
||||
"profile.dialog.title": "Neues Profil",
|
||||
"profile.dialog.description": "Mit Profilen können Sie mehrere NetBird-Verbindungen nebeneinander verwalten. Geben Sie Ihrem Profil einen aussagekräftigen Namen.",
|
||||
"profile.dialog.nameLabel": "Profilname",
|
||||
"profile.dialog.description": "Legen Sie einen leicht erkennbaren Namen für Ihr Profil fest.",
|
||||
"profile.dialog.placeholder": "z. B. Arbeit",
|
||||
"profile.dialog.managementHelp": "NetBird Cloud oder Ihr eigener Server.",
|
||||
"profile.dialog.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder fügen Sie das Profil trotzdem hinzu, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
|
||||
"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.switch.confirm": "Bestätigen",
|
||||
"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",
|
||||
@@ -158,6 +161,7 @@
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"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.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL, oder speichern Sie trotzdem, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"settings.general.management.switchCloudTitle": "Zu NetBird Cloud wechseln?",
|
||||
"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",
|
||||
@@ -329,7 +333,7 @@
|
||||
"welcome.management.urlLabel": "URL des Management-Servers",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Bitte geben Sie eine gültige URL ein, z. B. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Wir konnten diesen Server nicht erreichen. Überprüfen Sie die URL oder Ihr Netzwerk — Sie können trotzdem fortfahren, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"welcome.management.urlUnreachable": "Server nicht erreichbar. Überprüfen Sie die URL oder Ihr Netzwerk und fahren Sie fort, wenn Sie sicher sind, dass sie korrekt ist.",
|
||||
"welcome.management.checking": "Wird geprüft …",
|
||||
|
||||
"browserLogin.title": "Setzen Sie den Anmeldevorgang im Browser fort",
|
||||
|
||||
@@ -108,10 +108,13 @@
|
||||
"profile.selector.switchTo": "Switch to this profile",
|
||||
|
||||
"profile.dialog.title": "Enter Profile Name",
|
||||
"profile.dialog.description": "Choose a memorable name.",
|
||||
"profile.dialog.nameLabel": "Profile Name",
|
||||
"profile.dialog.description": "Set an easily identifiable name for your profile.",
|
||||
"profile.dialog.placeholder": "e.g. work",
|
||||
"profile.dialog.submit": "Add Profile",
|
||||
"profile.dialog.required": "Please enter a profile name, e.g. work, home",
|
||||
"profile.dialog.managementHelp": "Use NetBird Cloud or your own server.",
|
||||
"profile.dialog.urlUnreachable": "Couldn't reach this server. Check the URL, or add the profile anyway if you're sure it's correct.",
|
||||
|
||||
"header.menu.settings": "Settings...",
|
||||
"header.menu.defaultView": "Default View",
|
||||
@@ -120,7 +123,7 @@
|
||||
|
||||
"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.switch.confirm": "Confirm",
|
||||
"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",
|
||||
@@ -181,6 +184,7 @@
|
||||
"settings.general.management.selfHosted": "Self-hosted",
|
||||
"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.urlUnreachable": "Couldn't reach this server. Check the URL, or save anyway if you're sure it's correct.",
|
||||
"settings.general.management.switchCloudTitle": "Switch to NetBird Cloud?",
|
||||
"settings.general.management.switchCloudMessage": "This disconnects your self-hosted server.\nYou may need to log in again.",
|
||||
"settings.general.management.switchCloudConfirm": "Switch to Cloud",
|
||||
@@ -350,7 +354,7 @@
|
||||
"welcome.management.urlLabel": "Management server URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Please enter a valid URL, e.g., https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "We couldn't reach this server. Check the URL or your network and try again — you can also continue if you're sure it's correct.",
|
||||
"welcome.management.urlUnreachable": "Couldn't reach this server. Check the URL or your network, then continue if you're sure it's correct.",
|
||||
"welcome.management.checking": "Checking…",
|
||||
|
||||
"browserLogin.title": "Continue in your browser to complete the login",
|
||||
|
||||
@@ -107,12 +107,15 @@
|
||||
"profile.selector.delete": "Profil törlése",
|
||||
|
||||
"profile.dialog.title": "Új profil",
|
||||
"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.nameLabel": "Profilnév",
|
||||
"profile.dialog.description": "Adjon profiljának egy könnyen azonosítható nevet.",
|
||||
"profile.dialog.placeholder": "pl. Munka",
|
||||
"profile.dialog.managementHelp": "NetBird Cloud vagy saját kiszolgáló.",
|
||||
"profile.dialog.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy adja hozzá a profilt, ha biztos benne, hogy helyes.",
|
||||
|
||||
"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.switch.confirm": "Megerősítés",
|
||||
"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",
|
||||
@@ -158,6 +161,7 @@
|
||||
"settings.general.management.selfHosted": "Saját üzemeltetésű",
|
||||
"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.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t, vagy mentse el, ha biztos benne, hogy helyes.",
|
||||
"settings.general.management.switchCloudTitle": "Átváltás a NetBird Cloudra?",
|
||||
"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",
|
||||
@@ -329,7 +333,7 @@
|
||||
"welcome.management.urlLabel": "Menedzsmentszerver URL",
|
||||
"welcome.management.urlPlaceholder": "https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlInvalid": "Adjon meg egy érvényes URL-t, pl. https://netbird.selfhosted.com:443",
|
||||
"welcome.management.urlUnreachable": "Nem sikerült elérni a szervert. Ellenőrizze az URL-t vagy a hálózatot — ha biztos benne, hogy helyes, folytathatja.",
|
||||
"welcome.management.urlUnreachable": "A szerver nem érhető el. Ellenőrizze az URL-t vagy a hálózatot, majd folytassa, ha biztos benne, hogy helyes.",
|
||||
"welcome.management.checking": "Ellenőrzés…",
|
||||
|
||||
"browserLogin.title": "Folytassa a böngészőben a bejelentkezés befejezéséhez",
|
||||
|
||||
Reference in New Issue
Block a user