[management,client] 0.75.0 release with new desktop UI (#6473)

- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow.
- **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend.
- **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher.
- **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements.
- **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms.
- **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows).
- **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow.

Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
Co-authored-by: braginini <bangvalo@gmail.com>
Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com>
Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
Maycon Santos
2026-07-06 13:47:16 +02:00
committed by GitHub
co-authored by Zoltan Papp Eduard Gert braginini Pascal Fischer riccardom
parent c9d387bd0d
commit 91acb8147c
389 changed files with 46269 additions and 6684 deletions
@@ -0,0 +1,76 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import {
Briefcase,
Building,
Cloud,
Construction,
FlaskConical,
Gamepad2,
GraduationCap,
House,
Radio,
Server,
SquareCode,
Terminal,
UserCircle,
UserPlus,
Users,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/cn";
// 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],
[/(home|house|private)/i, House],
[/(dev|development|developer|code|coding|engineering)/i, SquareCode],
[/(local|localhost|loopback)/i, Terminal],
[/(stage|staging|preprod|pre-prod)/i, Construction],
[/(test|testing|qa)/i, FlaskConical],
[/(prod|production)/i, Cloud],
[/(live)/i, Radio],
[/(selfhosted|self-hosted|on-prem|onprem)/i, Server],
[/(school|university|edu|study|student)/i, GraduationCap],
[/(client|customer)/i, Building],
[/(family)/i, Users],
[/(gaming|game)/i, Gamepad2],
[/(guest)/i, UserPlus],
];
export const pickProfileIcon = (name: string | undefined): LucideIcon | null => {
if (!name) return null;
for (const [pattern, Icon] of ICON_MAP) {
if (pattern.test(name)) return Icon;
}
return null;
};
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
name?: string;
size?: number;
};
export const ProfileAvatar = forwardRef<HTMLButtonElement, Props>(function ProfileAvatar(
{ name = "", size = 28, className, type = "button", ...props },
ref,
) {
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type={type}
className={cn(
"inline-grid place-items-center rounded-full bg-nb-gray-900 p-0 text-center",
"cursor-default outline-none",
"transition-colors duration-150 hover:bg-nb-gray-850",
"data-[state=open]:bg-nb-gray-850",
className,
)}
style={{ width: size, height: size }}
{...props}
>
<Icon size={Math.round(size * 0.4)} className={"text-nb-gray-200"} />
</button>
);
});
@@ -0,0 +1,263 @@
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
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";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
export type ProfileFormInitial = {
name: string;
managementUrl: string;
};
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (name: string, managementUrl: string) => void | Promise<void>;
initial?: ProfileFormInitial;
};
const MAX_PROFILE_NAME_LEN = 128;
export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => {
const { t } = useTranslation();
const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL;
const nameId = useId();
const urlId = useId();
const isEdit = !!initial;
const initialModeFromUrl = (u: string): ManagementMode =>
u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud;
const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : "");
const [name, setName] = useState(initial?.name ?? "");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<ManagementMode>(
initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud,
);
const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
const [urlError, setUrlError] = useState<string | null>(null);
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const urlRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setName(initial?.name ?? "");
setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud);
setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
setNameError(null);
setUrlError(null);
setUnreachable(false);
setChecking(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initial?.name, initial?.managementUrl]);
const initialModeRef = useRef<ManagementMode>(ManagementMode.Cloud);
useEffect(() => {
if (!open) return;
initialModeRef.current = mode;
const id = globalThis.setTimeout(() => {
nameRef.current?.focus();
nameRef.current?.select();
}, 0);
return () => globalThis.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// When the user toggles to Self-hosted inside the dialog (not on initial
// open), move focus to the URL input so they can start typing immediately.
useEffect(() => {
if (!open) return;
if (mode === initialModeRef.current) return;
if (mode !== ManagementMode.SelfHosted) return;
urlRef.current?.focus();
}, [open, mode]);
useEffect(() => {
setUrlError(null);
setUnreachable(false);
}, [url, mode]);
const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => {
if (managedManagementUrl) {
return { url: managedManagementUrl, needsReachCheck: false };
}
if (mode === ManagementMode.Cloud) {
return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false };
}
const trimmed = url.trim();
if (!trimmed || !isValidManagementUrl(trimmed)) {
setUrlError(t("settings.general.management.urlError"));
urlRef.current?.focus();
return null;
}
const target = normalizeManagementUrl(trimmed);
const unchanged = target === initial?.managementUrl;
return { url: target, needsReachCheck: !unchanged };
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (checking) return;
const sanitized = name.trim();
if (sanitized.length === 0) {
setNameError(t("profile.dialog.required"));
nameRef.current?.focus();
return;
}
const target = resolveTargetUrl();
if (!target) return;
if (target.needsReachCheck) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target.url);
setChecking(false);
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
}
await onSubmit(sanitized, target.url);
onOpenChange(false);
};
const handleNameChange = (value: string) => {
setName(value);
if (nameError) setNameError(null);
};
const trimmedUrl = url.trim();
const showUrlSyntaxError =
mode === ManagementMode.SelfHosted &&
trimmedUrl !== "" &&
!isValidManagementUrl(trimmedUrl);
const urlInputError = showUrlSyntaxError
? t("settings.general.management.urlError")
: (urlError ?? undefined);
const urlInputWarning =
!urlInputError && unreachable ? t("profile.dialog.urlUnreachable") : undefined;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Content
maxWidthClass={"max-w-md"}
showClose={false}
className={"py-7"}
srTitle={isEdit ? t("profile.edit.title") : t("profile.dialog.title")}
srDescription={t("profile.dialog.description")}
onOpenAutoFocus={(e) => {
e.preventDefault();
// Focus + select-all so editing an existing name is one
// keystroke away from overwriting it.
nameRef.current?.focus();
nameRef.current?.select();
}}
>
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
<HelpText margin={false}>
{t("profile.dialog.description")}
</HelpText>
</div>
<Input
id={nameId}
ref={nameRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
value={name}
onChange={(e) => handleNameChange(e.target.value)}
error={nameError ?? undefined}
maxLength={MAX_PROFILE_NAME_LEN}
spellCheck={false}
autoComplete={"off"}
autoCapitalize={"off"}
/>
</div>
{!managedManagementUrl && (
<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
id={urlId}
ref={urlRef}
aria-label={t("settings.general.management.label")}
placeholder={t(
"settings.general.management.urlPlaceholder",
)}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={urlInputError}
warning={urlInputWarning}
spellCheck={false}
autoComplete={"off"}
autoCorrect={"off"}
autoCapitalize={"off"}
/>
)}
</div>
</div>
)}
<DialogActions className={"flex-row items-center justify-end gap-2.5 pt-2"}>
<Button
type={"button"}
variant={"secondary"}
size={"sm"}
disabled={checking}
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type={"submit"}
variant={"primary"}
size={"sm"}
loading={checking}
>
{isEdit ? t("profile.edit.submit") : t("profile.dialog.submit")}
</Button>
</DialogActions>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
);
};
@@ -0,0 +1,293 @@
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Popover from "@radix-ui/react-popover";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Command } from "cmdk";
import { Check, ChevronDown, Settings2, UserCircle } from "lucide-react";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import type { Profile } from "@bindings/services/models.js";
import { Tooltip } from "@/components/Tooltip";
import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
};
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const { t } = useTranslation();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const handleTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setOpen(true);
}
};
const sortedProfiles = [...profiles].sort((a, b) => {
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
});
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
};
const handleSelect = (id: string) => {
setOpen(false);
if (id === activeProfileId) return;
void guarded(t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleManage = () => {
setOpen(false);
onManageProfiles?.();
};
if (!loaded) return <ProfileTriggerSkeleton />;
const hasProfile = !!activeProfileId;
const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name;
const displayName = hasProfile
? (activeFromList ?? activeProfile)
: t("profile.selector.noProfile");
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild className={"wails-no-draggable"} disabled={!hasProfile}>
<ProfileTriggerButton
name={displayName}
disabled={!hasProfile}
onKeyDown={handleTriggerKeyDown}
/>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align={"center"}
sideOffset={8}
collisionPadding={12}
onOpenAutoFocus={(e) => {
e.preventDefault();
listRef.current?.focus();
}}
className={cn(
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
"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]:origin-top data-[side=top]:origin-bottom",
"data-[side=left]:origin-right data-[side=right]:origin-left",
"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()}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
<Command.List
ref={listRef}
aria-label={t("header.profile.switch")}
className={"outline-none focus:outline-none focus-visible:outline-none"}
>
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-60 px-1"}>
{sortedProfiles.map((profile) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
onSelect={handleSelect}
/>
))}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
className={cn(
"flex touch-none select-none transition-colors",
"w-1.5 bg-transparent",
)}
>
<ScrollArea.Thumb
className={
"relative flex-1 rounded-full bg-nb-gray-800 hover:bg-nb-gray-700"
}
/>
</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",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
)}
>
<Settings2
size={14}
aria-hidden={"true"}
className={"shrink-0"}
/>
<span className={"flex-1 truncate"}>
{t("profile.dropdown.manageProfiles")}
</span>
</Command.Item>
</div>
</Command.List>
</Command>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
};
const ProfileTriggerSkeleton = () => (
<div
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={"wails-no-draggable flex h-10 select-none items-center gap-2 rounded-lg px-3"}
>
<div
aria-hidden={"true"}
className={"size-4 shrink-0 animate-pulse rounded-full bg-nb-gray-900"}
/>
<div aria-hidden={"true"} className={"h-4 w-24 animate-pulse rounded bg-nb-gray-900"} />
</div>
);
type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
name: string;
};
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
const { t } = useTranslation();
const isFocusVisible = useFocusVisible();
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type={"button"}
disabled={disabled}
tabIndex={disabled ? -1 : 0}
aria-label={t("header.profile.switch")}
aria-haspopup={"listbox"}
className={cn(
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
"text-nb-gray-200 hover:bg-nb-gray-900",
"data-[state=open]:bg-nb-gray-900",
"disabled:opacity-50 disabled:hover:bg-transparent",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"wails-no-draggable transition-colors duration-150",
className,
)}
{...props}
>
<Icon
size={16}
aria-hidden={"true"}
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
/>
<span className={"wails-no-draggable max-w-[140px] truncate text-sm font-medium"}>
{name}
</span>
<ChevronDown
size={14}
aria-hidden={"true"}
className={"wails-no-draggable shrink-0 text-nb-gray-200"}
/>
</button>
);
},
);
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onSelect: (id: string) => void;
};
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
const showEmail = !!profile.email;
return (
<Command.Item
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
showEmail ? "items-start" : "items-center",
)}
>
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<span className={"truncate"}>{profile.name}</span>
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
{isActive && (
<Check
size={16}
aria-hidden={"true"}
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
/>
)}
</Command.Item>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span ref={ref} className={"mt-0.5 max-w-[180px] truncate text-xs text-nb-gray-300"}>
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};
@@ -0,0 +1,679 @@
import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CircleMinus,
LogIn,
MoreVertical,
PencilLine,
PlusCircle,
Trash2,
UserCircle,
} from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/buttons/Button";
import HelpText from "@/components/typography/HelpText";
import {
ProfileCreationModal,
type ProfileFormInitial,
} from "@/modules/profiles/ProfileCreationModal";
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
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 { isNetbirdCloud } from "@/hooks/useManagementUrl.ts";
import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
import { reconcileOrder } from "@/lib/sorting";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const DEFAULT_PROFILE_ID = "default";
export function ProfilesTab() {
const { t } = useTranslation();
const {
profiles,
activeProfileId,
loaded,
username,
switchProfile,
addProfile,
removeProfile,
renameProfile,
logoutProfile,
} = useProfile();
const confirm = useConfirm();
const [newOpen, setNewOpen] = useState(false);
const [editTarget, setEditTarget] = useState<{
profile: Profile;
initial: ProfileFormInitial;
} | null>(null);
const [busy, setBusy] = useState(false);
// 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 { order, items } = reconcileOrder(
orderRef.current,
profiles,
(p) => p.id,
(a, b) => {
if (a.id === activeProfileId) return -1;
if (b.id === activeProfileId) return 1;
return a.name.localeCompare(b.name);
},
);
orderRef.current = order;
return items;
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await errorDialog({
Title: title,
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
};
const handleSwitch = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.switch.title", { name }),
description: t("profile.switch.message", { name }),
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleDeregister = async (id: string, name: string) => {
const ok = await confirm({
title: t("profile.deregister.title", { name }),
description: t("profile.deregister.message", { name }),
confirmLabel: t("profile.deregister.confirm"),
});
if (!ok) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id));
};
const handleDelete = async (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
};
const handleCreate = async (name: string, managementUrl: string) => {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfile(id);
});
};
const handleEdit = async (id: string, name: string) => {
await guarded(i18next.t("profile.error.editTitle"), async () => {
const config = await SettingsSvc.GetConfig({ profileName: id, username });
const profile = profiles.find((p) => p.id === id);
if (!profile) return;
setEditTarget({
profile,
initial: { name, managementUrl: config.managementUrl },
});
});
};
const handleSave = async (name: string, managementUrl: string) => {
if (!editTarget) return;
const { profile, initial } = editTarget;
await guarded(i18next.t("profile.error.editTitle"), async () => {
if (name !== initial.name) {
await renameProfile(profile.id, name);
}
if (managementUrl !== initial.managementUrl) {
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: profile.id,
username,
managementUrl,
}),
);
}
});
};
return (
<div>
<SectionGroup title={t("settings.profiles.section.profiles")}>
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
<div
className={cn(
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
)}
>
<ProfilesTable
ordered={ordered}
activeProfileId={activeProfileId}
onSwitch={handleSwitch}
onEdit={handleEdit}
onDeregister={handleDeregister}
onDelete={handleDelete}
/>
{loaded && ordered.length === 0 && (
<div
className={
"flex flex-col items-center justify-center py-10 text-center"
}
>
<UserCircle
size={28}
aria-hidden={"true"}
className={"mb-2 text-nb-gray-500"}
/>
<p className={"text-sm font-semibold text-nb-gray-200"}>
{t("settings.profiles.emptyTitle")}
</p>
<p className={"mt-1 max-w-sm text-balance text-xs text-nb-gray-400"}>
{t("settings.profiles.emptyDescription")}
</p>
</div>
)}
</div>
<SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
<PlusCircle size={14} aria-hidden={"true"} />
{t("settings.profiles.addProfile")}
</Button>
</SettingsBottomBar>
</SectionGroup>
<ProfileCreationModal
open={newOpen}
onOpenChange={setNewOpen}
onSubmit={handleCreate}
/>
<ProfileCreationModal
open={editTarget !== null}
onOpenChange={(o) => {
if (!o) setEditTarget(null);
}}
initial={editTarget?.initial}
onSubmit={handleSave}
/>
</div>
);
}
type ProfilesTableProps = {
ordered: Profile[];
activeProfileId: string | undefined;
onSwitch: (id: string, name: string) => void;
onEdit: (id: string, name: string) => void;
onDeregister: (id: string, name: string) => void;
onDelete: (id: string, name: string) => void;
};
const ProfilesTable = ({
ordered,
activeProfileId,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfilesTableProps) => {
const { t } = useTranslation();
const [focusedIndex, setFocusedIndex] = useState(0);
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= ordered.length) return;
setFocusedIndex(index);
const el = rowRefs.current.get(ordered[index].id);
el?.focus();
};
const actionButtonsIn = (row: HTMLTableRowElement | undefined) =>
Array.from(
row?.querySelectorAll<HTMLButtonElement>(
"button:not([aria-hidden='true']):not([aria-disabled='true'])",
) ?? [],
);
const handleRowKey = (e: KeyboardEvent<HTMLTableRowElement>, index: number): boolean => {
switch (e.key) {
case "ArrowDown":
focusRow(Math.min(index + 1, ordered.length - 1));
return true;
case "ArrowUp":
focusRow(Math.max(index - 1, 0));
return true;
case "Home":
focusRow(0);
return true;
case "End":
focusRow(ordered.length - 1);
return true;
}
return false;
};
const handleButtonKey = (
e: KeyboardEvent<HTMLTableRowElement>,
index: number,
row: HTMLTableRowElement,
): boolean => {
const buttons = actionButtonsIn(row);
const current = buttons.indexOf(e.target as HTMLButtonElement);
if (current === -1) return false;
switch (e.key) {
case "ArrowDown":
focusRow(Math.min(index + 1, ordered.length - 1));
return true;
case "ArrowUp":
focusRow(Math.max(index - 1, 0));
return true;
case "Escape":
row.focus();
return true;
case "Tab":
// At the last button: jump to the next row instead of exiting the table.
// At the first button with Shift+Tab: jump back to the row.
if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) {
focusRow(index + 1);
return true;
}
if (e.shiftKey && current === 0) {
row.focus();
return true;
}
return false;
}
return false;
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLTableRowElement>, index: number) => {
const row = rowRefs.current.get(ordered[index].id);
if (!row) return;
const onRow = e.target === row;
const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row);
if (handled) e.preventDefault();
};
const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1));
return (
<table
aria-label={t("settings.profiles.section.profiles")}
className={"w-full border-separate border-spacing-0 text-sm"}
>
<tbody className={"flex flex-col"}>
{ordered.map((profile, index) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
isFocused={index === safeFocusedIndex}
isFirst={index === 0}
isLast={index === ordered.length - 1}
rowRef={(el) => {
if (el) rowRefs.current.set(profile.id, el);
else rowRefs.current.delete(profile.id);
}}
onKeyDown={(e) => handleRowKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
onSwitch={() => onSwitch(profile.id, profile.name)}
onEdit={() => onEdit(profile.id, profile.name)}
onDeregister={() => onDeregister(profile.id, profile.name)}
onDelete={() => onDelete(profile.id, profile.name)}
/>
))}
</tbody>
</table>
);
};
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
isFocused: boolean;
isFirst: boolean;
isLast: boolean;
rowRef: (el: HTMLTableRowElement | null) => void;
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
onFocus: () => void;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({
profile,
isActive,
isFocused,
isFirst,
isLast,
rowRef,
onKeyDown,
onFocus,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: ProfileRowProps) => {
const { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email;
return (
<tr
ref={rowRef}
tabIndex={isFocused ? 0 : -1}
onKeyDown={onKeyDown}
onFocus={onFocus}
aria-label={profile.name}
className={cn(
"flex items-center gap-4 px-4 py-2.5",
"border-b border-nb-gray-910 last:border-b-0",
"outline-none",
isFirst && "rounded-t-xl",
isLast && "rounded-b-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<td
className={cn(
"flex min-w-0 flex-1 gap-2 leading-tight",
showEmail ? "items-start" : "items-center",
)}
>
<Icon
size={15}
aria-hidden={"true"}
className={cn("shrink-0 text-nb-gray-200", showEmail ? "mt-0.5" : "")}
/>
<div className={"flex min-w-0 flex-1 flex-col leading-tight"}>
<div className={"flex min-w-0 items-center gap-2"}>
<span
className={
"cursor-text select-text truncate font-medium text-nb-gray-100"
}
>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div>
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</td>
<td className={"shrink-0 text-right"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
isDefault={profile.id === DEFAULT_PROFILE_ID}
isActive={isActive}
rowFocused={isFocused}
onSwitch={onSwitch}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</td>
</tr>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span
ref={ref}
className={"mt-0.5 cursor-text select-text truncate text-xs text-nb-gray-300"}
>
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};
type RowActionsProps = {
canSwitch: boolean;
canDeregister: boolean;
isDefault: boolean;
isActive: boolean;
rowFocused: boolean;
onSwitch: () => void;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowActions = ({
canSwitch,
canDeregister,
isDefault,
isActive,
rowFocused,
onSwitch,
onEdit,
onDeregister,
onDelete,
}: RowActionsProps) => {
const { t } = useTranslation();
const deleteDisabled = isDefault || isActive;
let deleteDisabledReason: string | null = null;
if (isDefault) deleteDisabledReason = t("profile.delete.disabledDefault");
else if (isActive) deleteDisabledReason = t("profile.delete.disabledActive");
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
label={t("profile.selector.switchTo")}
icon={LogIn}
onClick={onSwitch}
hidden={!canSwitch}
tabbable={rowFocused}
/>
<RowMoreMenu
canDeregister={canDeregister}
deleteDisabled={deleteDisabled}
deleteDisabledReason={deleteDisabledReason}
rowFocused={rowFocused}
onEdit={onEdit}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</div>
);
};
type RowMoreMenuProps = {
canDeregister: boolean;
deleteDisabled: boolean;
deleteDisabledReason: string | null;
rowFocused: boolean;
onEdit: () => void;
onDeregister: () => void;
onDelete: () => void;
};
const RowMoreMenu = ({
canDeregister,
deleteDisabled,
deleteDisabledReason,
rowFocused,
onEdit,
onDeregister,
onDelete,
}: RowMoreMenuProps) => {
const { t } = useTranslation();
const moreLabel = t("profile.selector.moreOptions");
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={moreLabel}
tabIndex={rowFocused ? 0 : -1}
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
)}
>
<MoreVertical size={16} aria-hidden={"true"} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} sideOffset={4} className={"min-w-36 select-none"}>
<DropdownMenuItem onClick={onEdit}>
<div className={"flex w-full items-center gap-2"}>
<PencilLine size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.edit")}</span>
</div>
</DropdownMenuItem>
{canDeregister && (
<DropdownMenuItem onClick={onDeregister}>
<div className={"flex w-full items-center gap-2"}>
<CircleMinus size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.deregister")}</span>
</div>
</DropdownMenuItem>
)}
<DeleteMenuItem
disabled={deleteDisabled}
disabledReason={deleteDisabledReason}
onDelete={onDelete}
/>
</DropdownMenuContent>
</DropdownMenu>
);
};
type DeleteMenuItemProps = {
disabled: boolean;
disabledReason: string | null;
onDelete: () => void;
};
const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => {
const { t } = useTranslation();
const item = (
<DropdownMenuItem
disabled={disabled}
onClick={disabled ? undefined : onDelete}
className={cn(!disabled && "text-red-500 hover:!text-red-500 focus:text-red-500")}
>
<div className={"flex w-full items-center gap-2"}>
<Trash2 size={14} aria-hidden={"true"} />
<span className={"flex-1"}>{t("profile.selector.delete")}</span>
</div>
</DropdownMenuItem>
);
if (!disabled || !disabledReason) return item;
return (
<Tooltip
content={<span className={"block max-w-[260px] leading-snug"}>{disabledReason}</span>}
side={"left"}
>
<span className={"block"}>{item}</span>
</Tooltip>
);
};
type ActionIconButtonProps = {
label: string;
icon: typeof CircleMinus;
onClick: () => void;
variant?: "default" | "danger";
/** Occupies space but invisible and non-interactive (preserves row layout). */
hidden?: boolean;
disabled?: boolean;
tabbable?: boolean;
};
const ActionIconButton = ({
label,
icon: Icon,
onClick,
variant = "default",
hidden = false,
disabled = false,
tabbable = true,
}: ActionIconButtonProps) => {
const button = (
<button
type={"button"}
onClick={disabled ? undefined : onClick}
aria-label={label}
aria-hidden={hidden || undefined}
aria-disabled={disabled || undefined}
tabIndex={hidden || !tabbable ? -1 : 0}
className={cn(
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
"transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
variant === "danger"
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
hidden && "pointer-events-none opacity-0",
disabled &&
"cursor-not-allowed opacity-40 hover:!bg-transparent hover:!text-nb-gray-400",
)}
>
<Icon size={16} aria-hidden={"true"} />
</button>
);
if (hidden) return button;
return (
<Tooltip
content={<span className={"block max-w-[260px] leading-snug"}>{label}</span>}
side={"top"}
>
{button}
</Tooltip>
);
};