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