refactor, lint, cleanup

This commit is contained in:
Eduard Gert
2026-06-09 16:31:52 +02:00
parent bada2b5b78
commit f8e3ac6d92
79 changed files with 1441 additions and 2463 deletions
@@ -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}