mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-17 04:09:07 +02:00
[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:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user