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
+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;