mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-16 11:49:06 +02:00
add cloud / selfhosted segment in profile creation
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user