mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
refactor, lint, cleanup
This commit is contained in:
@@ -19,10 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
|
||||
// just like "test" does. The list is scanned in order, so more-specific
|
||||
// tokens (e.g. "staging" before "stage") should come first when they share
|
||||
// roots.
|
||||
// Scanned in order — put more-specific tokens first (e.g. "staging" before "stage").
|
||||
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
|
||||
[/(default|personal)/i, UserCircle],
|
||||
[/(work|business|office|company|corp|corporate)/i, Briefcase],
|
||||
|
||||
@@ -18,19 +18,11 @@ import {
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => 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
|
||||
// (client/internal/profilemanager/profilemanager.go): only letters, digits,
|
||||
// `_` and `-` survive on the Go side. We additionally lowercase and convert
|
||||
// spaces to `-` so what the user sees in the input is exactly what the
|
||||
// daemon will store — otherwise the daemon silently sanitizes ("my profile"
|
||||
// → "myprofile") while the UI keeps the raw name in flight, which spawns a
|
||||
// ghost row and breaks subsequent delete.
|
||||
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
|
||||
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
|
||||
const sanitizeProfileInput = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
@@ -46,9 +38,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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);
|
||||
@@ -65,8 +54,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 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);
|
||||
@@ -100,9 +87,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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;
|
||||
@@ -117,16 +101,14 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
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);
|
||||
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;
|
||||
|
||||
@@ -178,7 +160,9 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
<Input
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
placeholder={t("settings.general.management.urlPlaceholder")}
|
||||
placeholder={t(
|
||||
"settings.general.management.urlPlaceholder",
|
||||
)}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
error={urlInputError}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { errorDialog } from "@/lib/dialogs.ts";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as ScrollArea from "@radix-ui/react-scroll-area";
|
||||
import { Command } from "cmdk";
|
||||
@@ -10,7 +9,7 @@ import type { Profile } from "@bindings/services/models.js";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatErrorMessage } from "@/lib/errors";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
type ProfileDropdownProps = {
|
||||
onManageProfiles?: () => void;
|
||||
@@ -59,79 +58,77 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const displayName = activeProfile || t("profile.selector.loading");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ProfileTriggerButton name={displayName} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"}>
|
||||
<ProfileTriggerButton name={displayName} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 min-w-64 overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg select-none wails-no-draggable",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Command loop shouldFilter={false} onKeyDown={(e) => e.stopPropagation()}>
|
||||
{sortedProfiles.length > 0 && (
|
||||
<>
|
||||
<ScrollArea.Root type="auto" className="overflow-hidden -mx-1">
|
||||
<ScrollArea.Viewport className="max-h-60 px-1">
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</Command.List>
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"flex select-none touch-none transition-colors",
|
||||
"w-1.5 bg-transparent",
|
||||
)}
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700 relative" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
<div className="-mx-1 h-px bg-nb-gray-910" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</>
|
||||
<div className={"pt-1"}>
|
||||
<Command.Item
|
||||
value={MANAGE_VALUE}
|
||||
onSelect={handleManage}
|
||||
disabled={!onManageProfiles}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-2 py-1.5",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
"data-[selected=true]:bg-nb-gray-900",
|
||||
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Settings2 size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{t("profile.dropdown.manageProfiles")}
|
||||
</span>
|
||||
</Command.Item>
|
||||
</div>
|
||||
</Command>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -186,7 +183,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
>
|
||||
<div className="flex flex-col min-w-0 flex-1 leading-tight">
|
||||
<span className="truncate">{profile.name}</span>
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
{isActive && (
|
||||
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
@@ -17,7 +16,8 @@ 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";
|
||||
import { reconcileOrder } from "@/lib/sorting";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
@@ -38,38 +38,22 @@ export function ProfilesTab() {
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// 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`.
|
||||
// Order is held stable so switching only flips the badge, never reorders rows
|
||||
// (else the clicked row jumps to the top under the cursor).
|
||||
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);
|
||||
const { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
profiles,
|
||||
(p) => p.name,
|
||||
(a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
}, [profiles, activeProfile]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
@@ -120,27 +104,17 @@ export function ProfilesTab() {
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
try {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
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).
|
||||
// SetConfig is keyed by profile name, so it writes the not-yet-active
|
||||
// profile. Write before switching so any reconnect targets the right deployment.
|
||||
if (managementUrl !== CLOUD_MANAGEMENT_URL) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: name, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(name);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: i18next.t("profile.error.createTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -193,7 +167,11 @@ export function ProfilesTab() {
|
||||
</SettingsBottomBar>
|
||||
</SectionGroup>
|
||||
|
||||
<ProfileCreationModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
|
||||
<ProfileCreationModal
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -230,12 +208,16 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
|
||||
<div className={"flex items-center gap-2 min-w-0"}>
|
||||
<span className={"truncate font-medium text-nb-gray-100 select-text cursor-text"}>
|
||||
<span
|
||||
className={
|
||||
"truncate font-medium text-nb-gray-100 select-text cursor-text"
|
||||
}
|
||||
>
|
||||
{profile.name}
|
||||
</span>
|
||||
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
|
||||
</div>
|
||||
{showEmail && <TruncatedEmail email={profile.email!} />}
|
||||
{showEmail && <TruncatedEmail email={profile.email} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -265,7 +247,10 @@ const TruncatedEmail = ({ email }: { email: string }) => {
|
||||
}, [email]);
|
||||
|
||||
const span = (
|
||||
<span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}>
|
||||
<span
|
||||
ref={ref}
|
||||
className={"text-xs text-nb-gray-300 truncate mt-0.5 select-text cursor-text"}
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
);
|
||||
@@ -294,11 +279,10 @@ const RowActions = ({
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
const deleteLabel = isDefault
|
||||
? t("profile.delete.disabledDefault")
|
||||
: isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
const nonDefaultDeleteLabel = isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
@@ -329,10 +313,8 @@ type ActionIconButtonProps = {
|
||||
icon: typeof CircleMinus;
|
||||
onClick: () => void;
|
||||
variant?: "default" | "danger";
|
||||
/** When true the button still occupies space (preserves row layout)
|
||||
* but is invisible and non-interactive. */
|
||||
/** Occupies space but invisible and non-interactive (preserves row layout). */
|
||||
hidden?: boolean;
|
||||
/** When true the button is visible but non-interactive (greyed out). */
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -359,7 +341,8 @@ const ActionIconButton = ({
|
||||
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
|
||||
hidden && "opacity-0 pointer-events-none",
|
||||
disabled && "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
disabled &&
|
||||
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
|
||||
)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
@@ -368,9 +351,7 @@ const ActionIconButton = ({
|
||||
if (hidden) return button;
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className={"block max-w-[260px] leading-snug"}>{label}</span>
|
||||
}
|
||||
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
|
||||
side={"top"}
|
||||
>
|
||||
{button}
|
||||
|
||||
Reference in New Issue
Block a user