diff --git a/client/server/server.go b/client/server/server.go index 4a912ff36..9f113bc21 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -2585,32 +2585,29 @@ func (s *Server) GetActiveProfile(ctx context.Context, msg *proto.GetActiveProfi return nil, gstatus.Error(codes.Unauthenticated, "caller identity could not be resolved") } - // The name is resolved through the caller's own listing, so a profile - // belonging to somebody else is not in it. Leave the name empty rather than - // falling back to the ID: a 32 character hex string tells the user nothing, - // and the owner's chosen name is not the caller's to read. Clients render - // their own wording for an active profile that is not theirs. - // - // A legacy profile is its own name, so the ID stands in for it. - displayName := "" - if activeProfile.ID == profilemanager.DefaultProfileName { - displayName = activeProfile.ID.String() - } else if profiles, lerr := s.profileManager.ListProfiles(userID); lerr == nil { - for _, p := range profiles { - if p.ID == activeProfile.ID { - displayName = p.Name - break - } - } - } - return &proto.GetActiveProfileResponse{ - ProfileName: displayName, + ProfileName: s.activeProfileNameFor(userID, activeProfile.ID), Username: activeProfile.Username, Id: activeProfile.ID.String(), }, nil } +// activeProfileNameFor returns the display name of the active profile as this +// caller may read it, and empty when the profile is not theirs. +func (s *Server) activeProfileNameFor(caller ipcauth.Identity, activeID profilemanager.ID) string { + profiles, err := s.profileManager.ListProfiles(caller) + if err != nil { + log.Debugf("failed to list profiles to name the active one: %v", err) + return "" + } + for _, p := range profiles { + if p.ID == activeID { + return p.Name + } + } + return "" +} + // GetFeatures returns the features supported by the daemon. func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) (*proto.GetFeaturesResponse, error) { s.mutex.Lock() diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index d7a85277a..d3e368e91 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -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 ( - + { diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 9f86bc6ef..e36eda4b7 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -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; @@ -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([]); const [loaded, setLoaded] = useState(false); const retryRef = useRef | 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, diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index 90087d20f..464b23501 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -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(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 = ( + + + + ); return ( - - - + {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. + + {trigger} + + ) : ( + trigger + )} { "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", )} > + {activeProfileForeign && } { ); }; +// 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 ( +
+ + {/* The popover sizes itself to its content, so without a cap the + sentence would render on one line and widen the whole list. */} + {t("profile.ownedByAnother.hint")} +
+ ); +}; + const ProfileTriggerSkeleton = () => (
( type ProfileTriggerButtonProps = React.ButtonHTMLAttributes & { 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( - 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 (