add cloud / selfhosted segment in profile creation

This commit is contained in:
Eduard Gert
2026-06-05 14:38:00 +02:00
parent 5877880789
commit efd874efac
11 changed files with 328 additions and 94 deletions

View File

@@ -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.

View File

@@ -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>
);
});

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",