Improve ui when other holds the session

This commit is contained in:
Theodor S. Midtlien
2026-09-21 17:35:11 +02:00
parent e5c1b21fd3
commit a785814a3f
18 changed files with 250 additions and 74 deletions
+17 -20
View File
@@ -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()
+12 -1
View File
@@ -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;
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Warten auf Autorisierung…"
},
"profile.ownedByAnother": {
"message": "Profil eines anderen Benutzers"
"profile.ownedByAnother.name": {
"message": "Anderer Benutzer"
},
"profile.ownedByAnother.hint": {
"message": "Das aktive Profil gehört einem anderen Benutzer. Nur dieser kann es ändern oder die Verbindung trennen."
},
"error.privilege_required": {
"message": "Diese Aktion erfordert erhöhte Rechte."
+7 -3
View File
@@ -1851,9 +1851,13 @@
"message": "Waiting for authorization…",
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
},
"profile.ownedByAnother": {
"message": "Another user's profile",
"description": "Shown in place of the active profile's name when it belongs to a different user account."
"profile.ownedByAnother.name": {
"message": "Another user",
"description": "Stands in for the active profile's name in the profile selector when that profile belongs to a different user account, whose name the daemon does not disclose. Keep it short: it sits in a narrow header button that truncates longer text."
},
"profile.ownedByAnother.hint": {
"message": "The active profile belongs to another user. Only its owner can change or disconnect it.",
"description": "Sentence explaining why the active profile cannot be acted on: it belongs to a different user account. Shown as a tooltip on the profile selector and as a notice above the profile list."
},
"error.privilege_required": {
"message": "This action requires elevated privileges.",
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Esperando la autorización…"
},
"profile.ownedByAnother": {
"message": "Perfil de otro usuario"
"profile.ownedByAnother.name": {
"message": "Otro usuario"
},
"profile.ownedByAnother.hint": {
"message": "El perfil activo pertenece a otro usuario. Solo su propietario puede modificarlo o desconectarlo."
},
"error.privilege_required": {
"message": "Esta acción requiere privilegios elevados."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "En attente de lautorisation…"
},
"profile.ownedByAnother": {
"message": "Profil dun autre utilisateur"
"profile.ownedByAnother.name": {
"message": "Autre utilisateur"
},
"profile.ownedByAnother.hint": {
"message": "Le profil actif appartient à un autre utilisateur. Seul son propriétaire peut le modifier ou le déconnecter."
},
"error.privilege_required": {
"message": "Cette action nécessite des privilèges élevés."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Várakozás az engedélyezésre…"
},
"profile.ownedByAnother": {
"message": "Másik felhasználó profilja"
"profile.ownedByAnother.name": {
"message": "Másik felhasználó"
},
"profile.ownedByAnother.hint": {
"message": "Az aktív profil egy másik felhasználóé. Csak a tulajdonosa módosíthatja vagy bonthatja a kapcsolatot."
},
"error.privilege_required": {
"message": "Ehhez a művelethez emelt szintű jogosultság szükséges."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "In attesa dell'autorizzazione…"
},
"profile.ownedByAnother": {
"message": "Profilo di un altro utente"
"profile.ownedByAnother.name": {
"message": "Altro utente"
},
"profile.ownedByAnother.hint": {
"message": "Il profilo attivo appartiene a un altro utente. Solo il suo proprietario può modificarlo o disconnetterlo."
},
"error.privilege_required": {
"message": "Questa azione richiede privilegi elevati."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "承認を待っています…"
},
"profile.ownedByAnother": {
"message": "別のユーザーのプロファイル"
"profile.ownedByAnother.name": {
"message": "別のユーザー"
},
"profile.ownedByAnother.hint": {
"message": "アクティブなプロファイルは別のユーザーのものです。変更や切断はその所有者のみが行えます。"
},
"error.privilege_required": {
"message": "この操作には昇格した権限が必要です。"
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Aguardando a autorização…"
},
"profile.ownedByAnother": {
"message": "Perfil de outro usuário"
"profile.ownedByAnother.name": {
"message": "Outro usuário"
},
"profile.ownedByAnother.hint": {
"message": "O perfil ativo pertence a outro usuário. Somente o proprietário pode alterá-lo ou desconectá-lo."
},
"error.privilege_required": {
"message": "Esta ação requer privilégios elevados."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Ожидание авторизации…"
},
"profile.ownedByAnother": {
"message": "Профиль другого пользователя"
"profile.ownedByAnother.name": {
"message": "Другой пользователь"
},
"profile.ownedByAnother.hint": {
"message": "Активный профиль принадлежит другому пользователю. Изменить его или отключить может только владелец."
},
"error.privilege_required": {
"message": "Для этого действия нужны повышенные права."
+5 -2
View File
@@ -1388,8 +1388,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "Очікування авторизації…"
},
"profile.ownedByAnother": {
"message": "Профіль іншого користувача"
"profile.ownedByAnother.name": {
"message": "Інший користувач"
},
"profile.ownedByAnother.hint": {
"message": "Активний профіль належить іншому користувачеві. Змінити його або відключити може лише власник."
},
"error.privilege_required": {
"message": "Ця дія потребує підвищених привілеїв."
+5 -2
View File
@@ -1390,8 +1390,11 @@
"settings.ssh.privilege.authorizePending": {
"message": "正在等待授权…"
},
"profile.ownedByAnother": {
"message": "其他用户的配置文件"
"profile.ownedByAnother.name": {
"message": "其他用户"
},
"profile.ownedByAnother.hint": {
"message": "活动配置文件属于其他用户。只有其所有者才能更改或断开连接。"
},
"error.privilege_required": {
"message": "此操作需要提升的权限。"
+4
View File
@@ -139,6 +139,10 @@ type Tray struct {
profilesMu sync.Mutex
profiles []services.Profile
profilesUser string
// profilesForeign records that the active profile is one this user cannot
// address, so no cached row is marked active and the submenu has no name
// to show.
profilesForeign bool
// menuMu serialises relayoutMenu (buildMenu + SetMenu) and guards the
// menu/item-pointer fields above. relayoutMenu is the only post-startup
+34 -2
View File
@@ -79,14 +79,39 @@ func (t *Tray) loadProfiles() {
return
}
// Resolved before the lock: it is another daemon round trip, and
// profilesMu is what the menu repaint reads its rows under.
foreign := activeIsForeign(ctx, t.svc.Profiles, profiles)
t.profilesMu.Lock()
t.profiles = profiles
t.profilesUser = username
t.profilesForeign = foreign
t.profilesMu.Unlock()
t.relayoutMenu()
}
// activeIsForeign reports whether the daemon's active profile is one this user
// cannot address. The listing holds every profile they may act on, so an active
// profile missing from it is somebody else's, and the daemon withholds its name.
func activeIsForeign(ctx context.Context, svc *services.Profiles, profiles []services.Profile) bool {
active, err := svc.GetActive(ctx)
if err != nil {
log.Debugf("get active profile: %v", err)
return false
}
if active.ID == "" {
return false
}
for _, p := range profiles {
if p.ID == active.ID {
return false
}
}
return true
}
// fillProfileSubmenu paints cached profile rows into the freshly built submenu.
// Pure UI: never fetches, never calls SetMenu (relayoutMenu owns the SetMenu).
func (t *Tray) fillProfileSubmenu() {
@@ -96,6 +121,7 @@ func (t *Tray) fillProfileSubmenu() {
t.profilesMu.Lock()
profiles := append([]services.Profile(nil), t.profiles...)
username := t.profilesUser
foreign := t.profilesForeign
t.profilesMu.Unlock()
sort.Slice(profiles, func(i, j int) bool {
@@ -144,8 +170,14 @@ func (t *Tray) fillProfileSubmenu() {
})
manageProfiles.SetEnabled(!disableProfiles)
log.Infof("tray fillProfileSubmenu: %d profile(s) for user %q, active=%q", len(profiles), username, activeName)
if t.profileSubmenuItem != nil && activeName != "" {
t.profileSubmenuItem.SetLabel(activeName)
if t.profileSubmenuItem != nil {
// Without this the row would keep the name of whatever was active
// before, since a profile belonging to somebody else marks no row.
if foreign {
t.profileSubmenuItem.SetLabel(t.loc.T("profile.ownedByAnother.name"))
} else if activeName != "" {
t.profileSubmenuItem.SetLabel(activeName)
}
}
if t.profileEmailItem != nil {
if activeEmail != "" {