mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-22 06:39:08 +02:00
Improve ui when other holds the session
This commit is contained in:
+17
-20
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1390,8 +1390,11 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Profil d’un 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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1390,8 +1390,11 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "承認を待っています…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "別のユーザーのプロファイル"
|
||||
"profile.ownedByAnother.name": {
|
||||
"message": "別のユーザー"
|
||||
},
|
||||
"profile.ownedByAnother.hint": {
|
||||
"message": "アクティブなプロファイルは別のユーザーのものです。変更や切断はその所有者のみが行えます。"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "この操作には昇格した権限が必要です。"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1390,8 +1390,11 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Ожидание авторизации…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Профиль другого пользователя"
|
||||
"profile.ownedByAnother.name": {
|
||||
"message": "Другой пользователь"
|
||||
},
|
||||
"profile.ownedByAnother.hint": {
|
||||
"message": "Активный профиль принадлежит другому пользователю. Изменить его или отключить может только владелец."
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Для этого действия нужны повышенные права."
|
||||
|
||||
@@ -1388,8 +1388,11 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Очікування авторизації…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "Профіль іншого користувача"
|
||||
"profile.ownedByAnother.name": {
|
||||
"message": "Інший користувач"
|
||||
},
|
||||
"profile.ownedByAnother.hint": {
|
||||
"message": "Активний профіль належить іншому користувачеві. Змінити його або відключити може лише власник."
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "Ця дія потребує підвищених привілеїв."
|
||||
|
||||
@@ -1390,8 +1390,11 @@
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "正在等待授权…"
|
||||
},
|
||||
"profile.ownedByAnother": {
|
||||
"message": "其他用户的配置文件"
|
||||
"profile.ownedByAnother.name": {
|
||||
"message": "其他用户"
|
||||
},
|
||||
"profile.ownedByAnother.hint": {
|
||||
"message": "活动配置文件属于其他用户。只有其所有者才能更改或断开连接。"
|
||||
},
|
||||
"error.privilege_required": {
|
||||
"message": "此操作需要提升的权限。"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user