mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-26 22:39:07 +01:00
Improve ui when other holds the session
This commit is contained in:
@@ -14,6 +14,10 @@ type Props = {
|
||||
keepOpenOnClick?: boolean;
|
||||
contentClassName?: string;
|
||||
closeDelay?: number;
|
||||
// suppressed forces the tooltip shut, for a trigger that also opens
|
||||
// something else (a popover on the same button) whose content would
|
||||
// otherwise render underneath it.
|
||||
suppressed?: boolean;
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -28,6 +32,7 @@ export const Tooltip = ({
|
||||
keepOpenOnClick = true,
|
||||
contentClassName,
|
||||
closeDelay = 0,
|
||||
suppressed = false,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hoveringRef = useRef(false);
|
||||
@@ -49,6 +54,12 @@ export const Tooltip = ({
|
||||
};
|
||||
useEffect(() => () => cancelClose(), []);
|
||||
|
||||
// Drops the hover that was in flight when the other surface opened, so the
|
||||
// tooltip does not spring back the moment it closes again.
|
||||
useEffect(() => {
|
||||
if (suppressed) setOpen(false);
|
||||
}, [suppressed]);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next && keepOpenOnClick && hoveringRef.current) return;
|
||||
if (next) cancelClose();
|
||||
@@ -57,7 +68,7 @@ export const Tooltip = ({
|
||||
|
||||
return (
|
||||
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
|
||||
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<RTooltip.Root open={open && !suppressed} onOpenChange={handleOpenChange}>
|
||||
<RTooltip.Trigger
|
||||
asChild
|
||||
onPointerEnter={() => {
|
||||
|
||||
@@ -18,12 +18,17 @@ const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
type ProfileContextValue = {
|
||||
username: string;
|
||||
// activeProfile is the display NAME of the active profile (for rendering
|
||||
// and the "default" check). activeProfileId is its stable on-disk ID, used
|
||||
// as the handle for daemon requests and for active-profile comparisons,
|
||||
// since display names can collide.
|
||||
// activeProfile is the display NAME of the active profile, empty when the
|
||||
// daemon withholds it (see activeProfileForeign). activeProfileId is its
|
||||
// stable on-disk ID, used as the handle for daemon requests and for
|
||||
// active-profile comparisons, since display names can collide.
|
||||
activeProfile: string;
|
||||
activeProfileId: string;
|
||||
// activeProfileForeign is set when the daemon is on a profile this user
|
||||
// cannot address, so nothing in profiles is marked active and none of the
|
||||
// profile actions will be allowed on it. Views render their own wording
|
||||
// for it rather than a name.
|
||||
activeProfileForeign: boolean;
|
||||
profiles: Profile[];
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
@@ -49,6 +54,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [activeProfile, setActiveProfile] = useState("");
|
||||
const [activeProfileId, setActiveProfileId] = useState("");
|
||||
const [activeProfileForeign, setActiveProfileForeign] = useState(false);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -65,15 +71,13 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
ProfilesSvc.List(u),
|
||||
]);
|
||||
setUsername(u);
|
||||
// An empty name means the daemon would not disclose it: the active
|
||||
// profile belongs to another user. Falling back to "default" would
|
||||
// name the wrong profile, so say what it is instead.
|
||||
const activeName = active.profileName
|
||||
? active.profileName
|
||||
: active.id
|
||||
? i18next.t("profile.ownedByAnother")
|
||||
: "default";
|
||||
setActiveProfile(activeName);
|
||||
// The listing holds every profile this user may address, so an
|
||||
// active profile missing from it is one they cannot act on at all:
|
||||
// the daemon withholds its name too. Falling back to "default"
|
||||
// would name the wrong profile, and a user who owns a profile of
|
||||
// their own called "default" could not tell the two apart.
|
||||
setActiveProfileForeign(!!active.id && !list.some((p) => p.id === active.id));
|
||||
setActiveProfile(active.profileName);
|
||||
setActiveProfileId(active.id || "default");
|
||||
setProfiles(list);
|
||||
setLoaded(true);
|
||||
@@ -169,6 +173,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
activeProfileForeign,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
@@ -183,6 +188,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
activeProfileForeign,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { Check, ChevronDown, Lock, Settings2, UserCircle } from "lucide-react";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
@@ -20,7 +20,14 @@ const MANAGE_VALUE = "__manage_profiles__";
|
||||
|
||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
|
||||
const {
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
activeProfileForeign,
|
||||
profiles,
|
||||
switchProfile,
|
||||
loaded,
|
||||
} = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
@@ -66,19 +73,48 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
|
||||
const hasProfile = !!activeProfileId;
|
||||
const activeFromList = profiles.find((p) => p.id === activeProfileId)?.name;
|
||||
const displayName = hasProfile
|
||||
? (activeFromList ?? activeProfile)
|
||||
: t("profile.selector.noProfile");
|
||||
const noProfile = t("profile.selector.noProfile");
|
||||
let displayName = noProfile;
|
||||
if (activeProfileForeign) {
|
||||
displayName = t("profile.ownedByAnother.name");
|
||||
} else if (hasProfile) {
|
||||
// The daemon's name is a fallback for a profile the listing did not
|
||||
// carry, and both are empty when it reports no active profile at all.
|
||||
displayName = activeFromList || activeProfile || noProfile;
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<Popover.Trigger asChild className={"wails-no-draggable"} disabled={!hasProfile}>
|
||||
<ProfileTriggerButton
|
||||
name={displayName}
|
||||
locked={activeProfileForeign}
|
||||
disabled={!hasProfile}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
);
|
||||
|
||||
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>
|
||||
{activeProfileForeign ? (
|
||||
// The label has to stay short enough not to truncate in the
|
||||
// header, so the sentence that explains the state lives here
|
||||
// and in the notice above the list.
|
||||
<Tooltip
|
||||
content={t("profile.ownedByAnother.hint")}
|
||||
suppressed={open}
|
||||
keepOpenOnClick={false}
|
||||
contentClassName={cn(
|
||||
"max-w-[16rem] leading-snug",
|
||||
"rounded-md border border-nb-gray-800 bg-white px-2 py-1.5",
|
||||
"dark:border-nb-gray-850 dark:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
{trigger}
|
||||
</Tooltip>
|
||||
) : (
|
||||
trigger
|
||||
)}
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
align={"center"}
|
||||
@@ -99,6 +135,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
)}
|
||||
>
|
||||
{activeProfileForeign && <ForeignProfileNotice />}
|
||||
<Command
|
||||
loop
|
||||
shouldFilter={false}
|
||||
@@ -174,6 +211,27 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ForeignProfileNotice explains why no row in the list is marked active: the
|
||||
// daemon is on a profile belonging to somebody else, which this user can
|
||||
// neither read nor act on.
|
||||
const ForeignProfileNotice = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role={"note"}
|
||||
className={cn(
|
||||
"mb-1 flex items-start gap-2 rounded-md px-2 py-2",
|
||||
"bg-nb-gray-900/70 text-xs leading-snug text-nb-gray-300 dark:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<Lock size={13} aria-hidden={"true"} className={"mt-0.5 shrink-0"} />
|
||||
{/* The popover sizes itself to its content, so without a cap the
|
||||
sentence would render on one line and widen the whole list. */}
|
||||
<span className={"max-w-[14rem]"}>{t("profile.ownedByAnother.hint")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileTriggerSkeleton = () => (
|
||||
<div
|
||||
role={"status"}
|
||||
@@ -191,13 +249,17 @@ const ProfileTriggerSkeleton = () => (
|
||||
|
||||
type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
name: string;
|
||||
// locked marks the active profile as one this user cannot act on. The name
|
||||
// is then wording of our own rather than a profile's, so it gets a neutral
|
||||
// icon instead of one picked from it.
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
|
||||
function ProfileTriggerButton({ name, className, disabled, ...props }, ref) {
|
||||
function ProfileTriggerButton({ name, locked, className, disabled, ...props }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const isFocusVisible = useFocusVisible();
|
||||
const Icon = pickProfileIcon(name) ?? UserCircle;
|
||||
const Icon = locked ? Lock : (pickProfileIcon(name) ?? UserCircle);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
@@ -223,7 +285,14 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
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"}>
|
||||
<span
|
||||
className={cn(
|
||||
"wails-no-draggable truncate text-sm font-medium",
|
||||
// Wording of ours rather than a name, and the longest
|
||||
// translation of it does not fit the name budget.
|
||||
locked ? "max-w-[170px]" : "max-w-[140px]",
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<ChevronDown
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CircleMinus,
|
||||
Lock,
|
||||
LogIn,
|
||||
MoreVertical,
|
||||
PencilLine,
|
||||
@@ -43,6 +44,7 @@ export function ProfilesTab() {
|
||||
const {
|
||||
profiles,
|
||||
activeProfileId,
|
||||
activeProfileForeign,
|
||||
loaded,
|
||||
username,
|
||||
switchProfileNoConnect,
|
||||
@@ -172,6 +174,8 @@ export function ProfilesTab() {
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
|
||||
|
||||
{activeProfileForeign && <ForeignProfileNotice />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
|
||||
@@ -233,6 +237,25 @@ export function ProfilesTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ForeignProfileNotice explains why no row carries the active badge: the daemon
|
||||
// is on a profile belonging to somebody else, which this table cannot list.
|
||||
const ForeignProfileNotice = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role={"note"}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg px-3 py-2.5",
|
||||
"border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
|
||||
"text-xs leading-snug text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<Lock size={14} aria-hidden={"true"} className={"mt-px shrink-0"} />
|
||||
<span>{t("profile.ownedByAnother.hint")}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ProfilesTableProps = {
|
||||
ordered: Profile[];
|
||||
activeProfileId: string | undefined;
|
||||
|
||||
Reference in New Issue
Block a user