• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 5e70dac9-a555-444e-83c4-7c71b9b24ecf

09 Jul 2025 05:12PM UTC coverage: 86.265% (-0.01%) from 86.277%
5e70dac9-a555-444e-83c4-7c71b9b24ecf

push

circleci

web-flow
Merge pull request #5719 from mozilla/MPP-4277-relay-logout-incident

MPP-4277: Fix Relay Logout on Firefox

2729 of 3936 branches covered (69.33%)

Branch coverage included in aggregate %.

0 of 4 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

17847 of 19916 relevant lines covered (89.61%)

9.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

70.15
/frontend/src/components/layout/navigation/UserMenu.tsx
1
import {
2
  useMenuTriggerState,
3
  useTreeState,
4
  TreeProps,
5
  TreeState,
6
  Item,
7
} from "react-stately";
8✔
8
import {
9
  useMenuTrigger,
10
  useButton,
11
  useOverlay,
12
  FocusScope,
13
  DismissButton,
14
  mergeProps,
15
  useMenuItem,
16
  useFocus,
17
} from "react-aria";
8✔
18
import { Key, ReactNode, useRef, useState } from "react";
8✔
19
import { AriaMenuItemProps } from "@react-aria/menu";
20
import Link from "next/link";
8✔
21
import styles from "./UserMenu.module.scss";
8✔
22
import {
23
  Cogwheel,
24
  ContactIcon,
25
  NewTabIcon,
26
  SignOutIcon,
27
  SupportIcon,
28
} from "../../Icons";
8✔
29
import { useUsers } from "../../../hooks/api/user";
8✔
30
import { useProfiles } from "../../../hooks/api/profile";
8✔
31
import { getRuntimeConfig } from "../../../config";
8✔
32
import { getCsrfToken } from "../../../functions/cookies";
8✔
33
import { useRuntimeData } from "../../../hooks/api/runtimeData";
8✔
34
import { setCookie } from "../../../functions/cookies";
35
import { useGaEvent } from "../../../hooks/gaEvent";
8✔
36
import { useL10n } from "../../../hooks/l10n";
8✔
37
import { MenuPopupProps, useMenu } from "../../../hooks/menu";
8✔
38
import React from "react";
39

40
export type Props = {
41
  style: string;
42
};
43
/**
44
 * Display the user's avatar, which can open a menu allowing the user to log out or go to their settings page.
45
 */
46
export const UserMenu = (props: Props) => {
97✔
47
  const runtimeData = useRuntimeData();
97✔
48
  const profileData = useProfiles();
97✔
49
  const usersData = useUsers();
97✔
50
  const l10n = useL10n();
97✔
51
  const gaEvent = useGaEvent();
97✔
52

53
  const itemKeys = {
97✔
54
    account: "account",
55
    settings: "settings",
56
    contact: "contact",
57
    help: "help",
58
    signout: "signout",
59
  };
60
  const accountLinkRef = useRef<HTMLAnchorElement>(null);
97✔
61
  const settingsLinkRef = useRef<HTMLAnchorElement>(null);
97✔
62
  const contactLinkRef = useRef<HTMLAnchorElement>(null);
97✔
63
  const helpLinkRef = useRef<HTMLAnchorElement>(null);
97✔
64
  const logoutFormRef = useRef<HTMLFormElement>(null);
97✔
65

66
  if (
97✔
67
    !Array.isArray(usersData.data) ||
217✔
68
    usersData.data.length !== 1 ||
69
    !runtimeData.data
70
  ) {
71
    // Still fetching the user's account data...
72
    return null;
37✔
73
  }
74

75
  const onSelect = (itemKey: Key) => {
60✔
76
    if (itemKey === itemKeys.signout) {
×
NEW
77
      return;
×
78
    }
79
  };
80

81
  const contactLink =
82
    profileData.data?.[0]?.has_premium === true ? (
60!
83
      <Item
84
        key={itemKeys.contact}
85
        textValue={l10n.getString("nav-profile-contact")}
86
      >
87
        <a
88
          ref={contactLinkRef}
89
          href={`${runtimeData.data.FXA_ORIGIN}/support/?utm_source=${
90
            getRuntimeConfig().frontendOrigin
91
          }`}
92
          title={l10n.getString("nav-profile-contact-tooltip")}
93
          className={styles["menu-link"]}
94
          target="_blank"
95
          rel="noopener noreferrer"
96
        >
97
          <ContactIcon width={20} height={20} alt="" />
98
          {l10n.getString("nav-profile-contact")}
99
        </a>
100
      </Item>
101
    ) : null;
102

103
  return (
104
    <UserMenuTrigger
105
      style={props.style}
106
      label={
107
        <img
108
          src={profileData.data?.[0].avatar}
109
          alt={l10n.getString("label-open-menu")}
110
          width={42}
111
          height={42}
112
        />
113
      }
114
      onAction={onSelect}
115
    >
116
      <Item
117
        key={itemKeys.account}
118
        textValue={l10n.getString("nav-profile-manage-account")}
119
      >
120
        <span className={styles["account-menu-item"]}>
121
          <b className={styles["user-email"]}>{usersData.data[0].email}</b>
122
          <a
123
            href={`${runtimeData.data.FXA_ORIGIN}/settings/`}
124
            ref={accountLinkRef}
125
            target="_blank"
126
            rel="noopener noreferrer"
127
            className={styles["settings-link"]}
128
          >
129
            {l10n.getString("nav-profile-manage-account")}
130
            <NewTabIcon alt="" />
131
          </a>
132
        </span>
133
      </Item>
134
      <Item
135
        key={itemKeys.settings}
136
        textValue={l10n.getString("nav-profile-settings")}
137
      >
138
        <Link
139
          href="/accounts/settings"
140
          ref={settingsLinkRef}
141
          title={l10n.getString("nav-profile-settings-tooltip")}
142
          className={styles["menu-link"]}
143
        >
144
          <Cogwheel width={20} height={20} alt="" />
145
          {l10n.getString("nav-profile-settings")}
146
        </Link>
147
      </Item>
148
      {contactLink as React.JSX.Element}
149
      <Item key={itemKeys.help} textValue={l10n.getString("nav-profile-help")}>
150
        <a
151
          ref={helpLinkRef}
152
          href={`${getRuntimeConfig().supportUrl}?utm_source=${
153
            getRuntimeConfig().frontendOrigin
154
          }`}
155
          title={l10n.getString("nav-profile-help-tooltip")}
156
          className={styles["menu-link"]}
157
          target="_blank"
158
          rel="noopener noreferrer"
159
        >
160
          <SupportIcon width={20} height={20} alt="" />
161
          {l10n.getString("nav-profile-help")}
162
        </a>
163
      </Item>
164
      <Item key={itemKeys.signout}>
165
        <form
166
          method="POST"
167
          action={getRuntimeConfig().fxaLogoutUrl}
168
          ref={logoutFormRef}
169
        >
170
          <input
171
            type="hidden"
172
            name="csrfmiddlewaretoken"
173
            value={getCsrfToken()}
174
          />
175
          <button
176
            type="button"
177
            className={styles["menu-button"]}
178
            onClick={() => {
NEW
179
              gaEvent({
×
180
                category: "Sign Out",
181
                action: "Click",
182
                label: "Website Sign Out",
183
              });
NEW
184
              setCookie("user-sign-out", "true", { maxAgeInSeconds: 60 * 60 });
×
NEW
185
              logoutFormRef.current?.submit();
×
186
            }}
187
          >
188
            <SignOutIcon alt="" />
189
            {l10n.getString("nav-profile-sign-out")}
190
          </button>
191
        </form>
192
      </Item>
193
    </UserMenuTrigger>
194
  );
195
};
196

197
type UserMenuTriggerProps = Parameters<typeof useMenuTriggerState>[0] & {
198
  label: ReactNode;
199
  style: string;
200
  children: TreeProps<Record<string, never>>["children"];
201
  onAction: AriaMenuItemProps["onAction"];
202
};
203
const UserMenuTrigger = ({
8✔
204
  label,
205
  style,
206
  ...otherProps
207
}: UserMenuTriggerProps) => {
208
  const l10n = useL10n();
60✔
209
  const userMenuTriggerState = useMenuTriggerState(otherProps);
60✔
210

211
  const triggerButtonRef = useRef<HTMLButtonElement>(null);
60✔
212
  const { menuTriggerProps, menuProps } = useMenuTrigger(
60✔
213
    {},
214
    userMenuTriggerState,
215
    triggerButtonRef,
216
  );
217
  // `menuProps` has an `autoFocus` property that is not compatible with the
218
  // `autoFocus` property for HTMLElements, because it can also be of type
219
  // `FocusStrategy` (i.e. the string "first" or "last") at the time of writing.
220
  // Since its values get spread onto an HTMLUListElement, we ignore those
221
  // values. See
222
  // https://github.com/mozilla/fx-private-relay/pull/3261#issuecomment-1493840024
223
  const menuPropsWithoutAutofocus = {
60✔
224
    ...menuProps,
225
    autoFocus:
226
      typeof menuProps.autoFocus === "boolean"
60!
227
        ? menuProps.autoFocus
228
        : undefined,
229
  };
230

231
  const triggerButtonProps = useButton(
60✔
232
    menuTriggerProps,
233
    triggerButtonRef,
234
  ).buttonProps;
235

236
  return (
237
    <div className={`${styles.wrapper} ${style}`}>
238
      <button
239
        {...triggerButtonProps}
240
        ref={triggerButtonRef}
241
        title={l10n.getString("avatar-tooltip")}
242
        className={styles.trigger}
243
      >
244
        {label}
245
      </button>
246
      {userMenuTriggerState.isOpen && (
60✔
247
        <UserMenuPopup
248
          {...otherProps}
249
          aria-label={l10n.getString("avatar-tooltip")}
250
          domProps={menuPropsWithoutAutofocus}
251
          autoFocus={userMenuTriggerState.focusStrategy}
252
          onClose={() => userMenuTriggerState.close()}
×
253
        />
254
      )}
255
    </div>
256
  );
257
};
258

259
type UserMenuPopupProps = MenuPopupProps<Record<string, never>>;
260
const UserMenuPopup = (props: UserMenuPopupProps) => {
8✔
261
  const popupState = useTreeState({ ...props, selectionMode: "none" });
×
262

263
  const popupRef = useRef<HTMLUListElement>(null);
×
264
  const popupProps = useMenu(props, popupState, popupRef).menuProps;
×
265

266
  const overlayRef = useRef<HTMLDivElement>(null);
×
267
  const { overlayProps } = useOverlay(
×
268
    {
269
      onClose: props.onClose,
270
      shouldCloseOnBlur: true,
271
      isOpen: true,
272
      isDismissable: true,
273
    },
274
    overlayRef,
275
  );
276

277
  // <FocusScope> ensures that focus is restored back to the
278
  // trigger when the menu is closed.
279
  // The <DismissButton> components allow screen reader users
280
  // to dismiss the popup easily.
281
  return (
282
    <FocusScope restoreFocus>
283
      <div {...overlayProps} ref={overlayRef}>
284
        <DismissButton onDismiss={props.onClose} />
285
        <ul
286
          {...mergeProps(popupProps, props.domProps)}
287
          ref={popupRef}
288
          className={styles.popup}
289
        >
290
          {Array.from(popupState.collection).map((item) => (
291
            <UserMenuItem
×
292
              key={item.key}
293
              // TODO: Fix the typing (likely: report to react-aria that the type does not include an isDisabled prop)
294
              item={item as unknown as UserMenuItemProps["item"]}
295
              state={popupState}
296
              onAction={props.onAction}
297
              onClose={props.onClose}
298
            />
299
          ))}
300
        </ul>
301
        <DismissButton onDismiss={props.onClose} />
302
      </div>
303
    </FocusScope>
304
  );
305
};
306

307
type UserMenuItemProps = {
308
  // TODO: Figure out correct type:
309
  item: {
310
    key: AriaMenuItemProps["key"];
311
    isDisabled: AriaMenuItemProps["isDisabled"];
312
    rendered?: ReactNode;
313
  };
314
  state: TreeState<unknown>;
315
  onAction: AriaMenuItemProps["onAction"];
316
  onClose: AriaMenuItemProps["onClose"];
317
};
318

319
const UserMenuItem = (props: UserMenuItemProps) => {
8✔
320
  const menuItemRef = useRef<HTMLLIElement>(null);
×
321
  const menuItemProps = useMenuItem(
×
322
    {
323
      key: props.item.key,
324
      isDisabled: props.item.isDisabled,
325
      onAction: props.onAction,
326
      onClose: props.onClose,
327
    },
328
    props.state,
329
    menuItemRef,
330
  ).menuItemProps;
331

332
  const [_isFocused, setIsFocused] = useState(false);
×
333
  const focusProps = useFocus({ onFocusChange: setIsFocused }).focusProps;
×
334

335
  return (
336
    <li
337
      {...mergeProps(menuItemProps, focusProps)}
338
      ref={menuItemRef}
339
      className={styles["menu-item-wrapper"]}
340
    >
341
      {props.item.rendered}
342
    </li>
343
  );
344
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc