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

mendersoftware / mender-server / 10423

11 Nov 2025 04:53PM UTC coverage: 74.435% (-0.1%) from 74.562%
10423

push

gitlab-ci

web-flow
Merge pull request #1071 from mendersoftware/dependabot/npm_and_yarn/frontend/main/development-dependencies-92732187be

3868 of 5393 branches covered (71.72%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 2 files covered. (100.0%)

176 existing lines in 95 files now uncovered.

64605 of 86597 relevant lines covered (74.6%)

7.74 hits per line

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

95.19
/frontend/src/js/components/header/Header.tsx
1
// Copyright 2015 Northern.tech AS
2✔
2
//
2✔
3
//    Licensed under the Apache License, Version 2.0 (the "License");
2✔
4
//    you may not use this file except in compliance with the License.
2✔
5
//    You may obtain a copy of the License at
2✔
6
//
2✔
7
//        http://www.apache.org/licenses/LICENSE-2.0
2✔
8
//
2✔
9
//    Unless required by applicable law or agreed to in writing, software
2✔
10
//    distributed under the License is distributed on an "AS IS" BASIS,
2✔
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
2✔
12
//    See the License for the specific language governing permissions and
2✔
13
//    limitations under the License.
2✔
14
import { useCallback, useEffect, useRef, useState } from 'react';
2✔
15
import { useDispatch, useSelector } from 'react-redux';
2✔
16
import { Link } from 'react-router-dom';
2✔
17

2✔
18
import { AccountCircle as AccountCircleIcon, ExitToApp as ExitIcon, ExpandMore } from '@mui/icons-material';
2✔
19
import {
2✔
20
  Accordion,
2✔
21
  AccordionDetails,
2✔
22
  AccordionSummary,
2✔
23
  Button,
2✔
24
  Chip,
2✔
25
  Divider,
2✔
26
  ListItemIcon,
2✔
27
  ListItemText,
2✔
28
  Menu,
2✔
29
  MenuItem,
2✔
30
  Toolbar,
2✔
31
  Typography,
2✔
32
  accordionClasses,
2✔
33
  accordionSummaryClasses,
2✔
34
  listItemTextClasses,
2✔
35
  menuItemClasses
2✔
36
} from '@mui/material';
2✔
37
import { makeStyles } from 'tss-react/mui';
2✔
38

2✔
39
import Search from '@northern.tech/common-ui/Search';
2✔
40
import storeActions from '@northern.tech/store/actions';
2✔
41
import { READ_STATES, TIMEOUTS } from '@northern.tech/store/constants';
2✔
42
import {
2✔
43
  getAcceptedDevices,
2✔
44
  getCurrentSession,
2✔
45
  getCurrentUser,
2✔
46
  getDeploymentsByStatus,
2✔
47
  getDeviceCountsByStatus,
2✔
48
  getDeviceLimit,
2✔
49
  getFeatures,
2✔
50
  getFeedbackProbability,
2✔
51
  getHostedAnnouncement,
2✔
52
  getIsEnterprise,
2✔
53
  getIsFirstLogin,
2✔
54
  getIsServiceProvider,
2✔
55
  getOrganization,
2✔
56
  getReadAllHelptips,
2✔
57
  getSearchState,
2✔
58
  getTooltipsById,
2✔
59
  getUserRoles,
2✔
60
  getUserSettings,
2✔
61
  getUserSettingsInitialized
2✔
62
} from '@northern.tech/store/selectors';
2✔
63
import { useAppInit } from '@northern.tech/store/storehooks';
2✔
64
import {
2✔
65
  getAllDeviceCounts,
2✔
66
  getUserOrganization,
2✔
67
  initializeSelf,
2✔
68
  logoutUser,
2✔
69
  setAllTooltipsReadState,
2✔
70
  setFirstLoginAfterSignup,
2✔
71
  setHideAnnouncement,
2✔
72
  setSearchState,
2✔
73
  switchUserOrganization
2✔
74
} from '@northern.tech/store/thunks';
2✔
75
import { useDebounce } from '@northern.tech/utils/debouncehook';
2✔
76
import { toggle } from '@northern.tech/utils/helpers';
2✔
77
import dayjs from 'dayjs';
2✔
78
import durationDayJs from 'dayjs/plugin/duration.js';
2✔
79
import { jwtDecode } from 'jwt-decode';
2✔
80
import Cookies from 'universal-cookie';
2✔
81

2✔
82
import enterpriseLogo from '../../../assets/img/headerlogo-enterprise.png';
2✔
83
import logo from '../../../assets/img/headerlogo.png';
2✔
84
import whiteEnterpriseLogo from '../../../assets/img/whiteheaderlogo-enterprise.png';
2✔
85
import whiteLogo from '../../../assets/img/whiteheaderlogo.png';
2✔
86
import Tracking from '../../tracking';
2✔
87
import Announcement from './Announcement';
2✔
88
import DeploymentNotifications from './DeploymentNotifications';
2✔
89
import { DeviceCount } from './DeviceCount';
2✔
90
import DeviceNotifications from './DeviceNotifications';
2✔
91
import OfferHeader from './OfferHeader';
2✔
92
import TrialNotification from './TrialNotification';
2✔
93

2✔
94
dayjs.extend(durationDayJs);
4✔
95

2✔
96
const { setShowFeedbackDialog } = storeActions;
4✔
97

2✔
98
// Change this when a new feature/offer is introduced
2✔
99
const currentOffer = {
4✔
100
  name: 'add-ons',
2✔
101
  expires: '2021-12-30',
2✔
102
  trial: true,
2✔
103
  os: true,
2✔
104
  professional: true,
2✔
105
  enterprise: true
2✔
106
};
2✔
107

2✔
108
const cookies = new Cookies();
4✔
109

2✔
110
const useStyles = makeStyles()(theme => ({
48✔
111
  accordion: {
2✔
112
    ul: { paddingInlineStart: 0 },
2✔
113
    [`&.${accordionClasses.disabled}, &.${accordionClasses.expanded}`]: {
2✔
114
      backgroundColor: theme.palette.background.paper
2✔
115
    },
2✔
116
    [`.${accordionSummaryClasses.root}:hover`]: {
2✔
117
      backgroundColor: theme.palette.grey[400],
2✔
118
      color: theme.palette.text.link
2✔
119
    },
2✔
120
    [`.${menuItemClasses.root}:hover`]: {
2✔
121
      color: theme.palette.text.link
2✔
122
    }
2✔
123
  },
2✔
124
  banner: { gridTemplateRows: `1fr ${theme.mixins.toolbar.minHeight}px` },
2✔
125
  buttonColor: { color: theme.palette.grey[600] },
2✔
126
  demoAnnouncementIcon: {
2✔
127
    height: 16,
2✔
128
    color: theme.palette.primary.main,
2✔
129
    '&.MuiButton-textPrimary': {
2✔
130
      color: theme.palette.primary.main,
2✔
131
      height: 'inherit'
2✔
132
    }
2✔
133
  },
2✔
134
  demoTrialAnnouncement: {
2✔
135
    fontSize: 14,
2✔
136
    height: 'auto'
2✔
137
  },
2✔
138
  dropDown: {
2✔
139
    height: '100%',
2✔
140
    textTransform: 'none',
2✔
141
    [`.${menuItemClasses.root}:hover, .${listItemTextClasses.root}:hover`]: {
2✔
142
      color: theme.palette.text.link
2✔
143
    }
2✔
144
  },
2✔
145
  exitIcon: { color: theme.palette.grey[600], fill: theme.palette.grey[600] },
2✔
146
  header: {
2✔
147
    borderBottom: `1px solid ${theme.palette.divider}`,
2✔
148
    display: 'grid',
2✔
149
    '#logo': {
2✔
150
      minWidth: 142,
2✔
151
      height: theme.spacing(6),
2✔
152
      marginRight: 25
2✔
153
    }
2✔
154
  },
2✔
155
  headerSection: {
2✔
156
    display: 'flex',
2✔
157
    alignItems: 'center',
2✔
158
    height: theme.spacing(3),
2✔
159
    '&:hover': {
2✔
160
      color: theme.palette.grey[700]
2✔
161
    }
2✔
162
  },
2✔
163
  organization: { marginBottom: theme.spacing() },
2✔
164
  redAnnouncementIcon: {
2✔
165
    color: theme.palette.error.dark
2✔
166
  },
2✔
167
  search: { alignSelf: 'center' }
2✔
168
}));
2✔
169

2✔
170
const AccountMenu = ({ className }) => {
4✔
171
  const [anchorEl, setAnchorEl] = useState(null);
89✔
172
  const [tenantSwitcherShowing, setTenantSwitcherShowing] = useState(false);
89✔
173
  const hasReadHelptips = useSelector(getReadAllHelptips);
89✔
174
  const { email, tenants = [] } = useSelector(getCurrentUser);
89✔
175
  const tooltips = useSelector(getTooltipsById);
89✔
176
  const { name } = useSelector(getOrganization);
89✔
177
  const isEnterprise = useSelector(getIsEnterprise);
89✔
178
  const { hasMultitenancy, isHosted } = useSelector(getFeatures);
89✔
179
  const multitenancy = hasMultitenancy || isEnterprise || isHosted;
89✔
180
  const dispatch = useDispatch();
89✔
181

2✔
182
  const { classes } = useStyles();
89✔
183

2✔
184
  const handleClose = () => {
89✔
185
    setAnchorEl(null);
2✔
186
    setTenantSwitcherShowing(false);
2✔
187
  };
2✔
188

2✔
189
  const handleSwitchTenant = id => dispatch(switchUserOrganization(id));
89✔
190

2✔
191
  const onLogoutClick = () => {
89✔
192
    setAnchorEl(null);
3✔
193
    dispatch(logoutUser()).then(() => window.location.replace('/ui/'));
3✔
194
  };
2✔
195

2✔
196
  const onToggleTooltips = () =>
89✔
197
    dispatch(setAllTooltipsReadState({ readState: hasReadHelptips ? READ_STATES.unread : READ_STATES.read, tooltipIds: Object.keys(tooltips) }));
2!
198

2✔
199
  return (
89✔
200
    <>
2✔
201
      <Button
2✔
202
        className={`${className} ${classes.dropDown}`}
2✔
203
        onClick={e => setAnchorEl(e.currentTarget)}
3✔
204
        startIcon={<AccountCircleIcon className={classes.buttonColor} />}
2✔
205
      >
2✔
206
        {email}
2✔
207
      </Button>
2✔
208
      <Menu
2✔
209
        anchorEl={anchorEl}
2✔
210
        className={classes.dropDown}
2✔
211
        onClose={handleClose}
2✔
212
        open={Boolean(anchorEl)}
2✔
213
        anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
2✔
214
        transformOrigin={{ horizontal: 'right', vertical: 'top' }}
2✔
215
      >
2✔
216
        <MenuItem component={Link} to="/settings/my-profile" onClick={handleClose}>
2✔
217
          My profile
2✔
218
        </MenuItem>
2✔
219
        <Divider />
2✔
220
        {!!(multitenancy && name) && (
2✔
221
          <MenuItem component={Link} dense to="/settings/organization" onClick={handleClose} className={classes.organization}>
2✔
222
            <div>
2✔
223
              <Typography variant="caption" className="muted">
2✔
224
                My organization
2✔
225
              </Typography>
2✔
226
              <Typography variant="subtitle2">{name}</Typography>
2✔
227
            </div>
2✔
228
          </MenuItem>
2✔
229
        )}
2✔
230
        {tenants.length > 1 && (
2!
231
          <div>
2✔
232
            <Divider style={{ marginBottom: 0 }} />
2✔
233
            <Accordion className={classes.accordion} square expanded={tenantSwitcherShowing} onChange={() => setTenantSwitcherShowing(toggle)}>
2✔
234
              <AccordionSummary expandIcon={<ExpandMore />}>Switch organization</AccordionSummary>
2✔
235
              <AccordionDetails className="padding-left-none padding-right-none">
2✔
236
                {tenants.map(({ id, name }) => (
2✔
237
                  <MenuItem className="padding-left padding-right" key={id} onClick={() => handleSwitchTenant(id)}>
2✔
238
                    {name}
2✔
239
                  </MenuItem>
2✔
240
                ))}
2✔
241
              </AccordionDetails>
2✔
242
            </Accordion>
2✔
243
          </div>
2✔
244
        )}
2✔
245
        <Divider />
2✔
246
        <MenuItem component={Link} to="/settings/global-settings" onClick={handleClose}>
2✔
247
          Settings
2✔
248
        </MenuItem>
2✔
249
        <MenuItem onClick={onToggleTooltips}>{`Mark help tips as ${hasReadHelptips ? 'un' : ''}read`}</MenuItem>
2!
250
        <MenuItem component={Link} to="/help/get-started" onClick={handleClose}>
2✔
251
          Help & support
2✔
252
        </MenuItem>
2✔
253
        <MenuItem onClick={onLogoutClick}>
2✔
254
          <ListItemText primary="Log out" />
2✔
255
          <ListItemIcon>
2✔
256
            <ExitIcon className={classes.exitIcon} />
2✔
257
          </ListItemIcon>
2✔
258
        </MenuItem>
2✔
259
      </Menu>
2✔
260
    </>
2✔
261
  );
2✔
262
};
2✔
263

2✔
264
const HEX_BASE = 16;
4✔
265
const date = dayjs().toISOString().split('T')[0];
4✔
266
const pickAUser = ({ jti, probability }) => {
4✔
267
  const daySessionUniqueId = `${jti}-${date}`; // jti can be unique for multiple user sessions, combined with a check at most once per day should be enough
2✔
268
  const hashBuffer = new TextEncoder().encode(daySessionUniqueId);
2✔
269
  return crypto.subtle.digest('SHA-256', hashBuffer).then(hashArrayBuffer => {
2✔
270
    // convert the hash buffer to a hex string for easier processing towards a number
2✔
271
    const hashHex = Array.from(new Uint8Array(hashArrayBuffer))
2✔
272
      .map(byte => byte.toString(HEX_BASE).padStart(2, '0'))
2✔
273
      .join('');
2✔
274
    const hashInt = parseInt(hashHex.slice(0, 8), HEX_BASE); // convert the hex string to an integer, use first 8 chars for simplicity
2✔
275
    const normalizedValue = hashInt / Math.pow(2, 32); // normalize the integer to a value between 0 and 1, within the 32bit range browsers default to
2✔
276
    // select the user if the normalized value is below the probability threshold
2✔
277
    return normalizedValue < probability;
2✔
278
  });
2✔
279
};
2✔
280
export const Header = ({ isDarkMode }) => {
4✔
281
  const { classes } = useStyles();
87✔
282
  const [gettingUser, setGettingUser] = useState(false);
87✔
283
  const [hasOfferCookie, setHasOfferCookie] = useState(false);
87✔
284

2✔
285
  const organization = useSelector(getOrganization);
87✔
286
  const { total: acceptedDevices = 0 } = useSelector(getAcceptedDevices);
87✔
287
  const announcement = useSelector(getHostedAnnouncement);
87✔
288
  const deviceLimit = useSelector(getDeviceLimit);
87✔
289
  const feedbackProbability = useSelector(getFeedbackProbability);
87✔
290
  const firstLoginAfterSignup = useSelector(getIsFirstLogin);
87✔
291
  const { feedbackCollectedAt, trackingConsentGiven: hasTrackingEnabled } = useSelector(getUserSettings);
87✔
292
  const { isAdmin } = useSelector(getUserRoles);
87✔
293
  const { inprogress: inprogressDeployments } = useSelector(getDeploymentsByStatus);
87✔
294
  const { total: inProgress } = inprogressDeployments;
87✔
295
  const isEnterprise = useSelector(getIsEnterprise);
87✔
296
  const { hasFeedbackEnabled, isHosted } = useSelector(getFeatures);
87✔
297
  const { isSearching, searchTerm, refreshTrigger } = useSelector(getSearchState);
87✔
298
  const { pending: pendingDevices } = useSelector(getDeviceCountsByStatus);
87✔
299
  const userSettingInitialized = useSelector(getUserSettingsInitialized);
87✔
300
  const user = useSelector(getCurrentUser);
87✔
301
  const { token } = useSelector(getCurrentSession);
87✔
302
  const userId = useDebounce(user.id, TIMEOUTS.debounceDefault);
87✔
303
  const isSp = useSelector(getIsServiceProvider);
87✔
304
  const { device_count: spDeviceUtilization, device_limit: tenantDeviceLimit, service_provider } = useSelector(getOrganization);
87✔
305
  const dispatch = useDispatch();
87✔
306
  const deviceTimer = useRef();
87✔
307
  const feedbackTimer = useRef();
87✔
308

2✔
309
  useAppInit(userId);
87✔
310

2✔
311
  useEffect(() => {
87✔
312
    if ((!userId || !user.email?.length || !userSettingInitialized) && !gettingUser && token) {
20✔
313
      setGettingUser(true);
3✔
314
      dispatch(getUserOrganization());
3✔
315
      dispatch(initializeSelf());
3✔
316
      return;
3✔
317
    }
2✔
318
    Tracking.setTrackingEnabled(hasTrackingEnabled);
19✔
319
    if (hasTrackingEnabled && user.id && organization.id) {
19!
320
      Tracking.setOrganizationUser(organization, user);
2✔
321
      if (firstLoginAfterSignup) {
2!
322
        Tracking.pageview('/signup/complete');
2✔
323
        dispatch(setFirstLoginAfterSignup(false));
2✔
324
      }
2✔
325
    }
2✔
326
  }, [dispatch, firstLoginAfterSignup, gettingUser, hasTrackingEnabled, organization, token, user, user.email, userId, userSettingInitialized]);
2✔
327

2✔
328
  useEffect(() => {
87✔
329
    const showOfferCookie = cookies.get('offer') === currentOffer.name;
8✔
330
    setHasOfferCookie(showOfferCookie);
8✔
331
    clearInterval(deviceTimer.current);
8✔
332
    if (!service_provider) {
8!
333
      deviceTimer.current = setInterval(() => dispatch(getAllDeviceCounts()), TIMEOUTS.refreshDefault);
101✔
334
    }
2✔
335
    return () => {
8✔
336
      clearInterval(deviceTimer.current);
8✔
337
      clearTimeout(feedbackTimer.current);
8✔
338
    };
2✔
339
  }, [dispatch, service_provider]);
2✔
340

2✔
341
  useEffect(() => {
87✔
342
    const today = dayjs();
13✔
343
    const diff = dayjs.duration(dayjs(feedbackCollectedAt).diff(today));
13✔
344
    const isFeedbackEligible = diff.asMonths() > 3;
13✔
345
    if (!hasFeedbackEnabled || !userSettingInitialized || !token || (feedbackCollectedAt && !isFeedbackEligible)) {
13!
346
      return;
13✔
347
    }
2✔
348
    const { jti } = jwtDecode(token);
2✔
349
    pickAUser({ jti, probability: feedbackProbability }).then(isSelected => {
2✔
350
      feedbackTimer.current = setTimeout(() => dispatch(setShowFeedbackDialog(isSelected)), TIMEOUTS.threeSeconds);
2✔
351
    });
2✔
352
  }, [dispatch, feedbackCollectedAt, feedbackProbability, hasFeedbackEnabled, isAdmin, userSettingInitialized, token]);
2✔
353

2✔
354
  const onSearch = useCallback((searchTerm, refreshTrigger) => dispatch(setSearchState({ refreshTrigger, searchTerm, page: 1 })), [dispatch]);
87✔
355

2✔
356
  const setHideOffer = () => {
87✔
357
    cookies.set('offer', currentOffer.name, { path: '/', maxAge: 2629746 });
2✔
358
    setHasOfferCookie(true);
2✔
359
  };
2✔
360

2✔
361
  const showOffer =
2✔
362
    isHosted && dayjs().isBefore(currentOffer.expires) && (organization.trial ? currentOffer.trial : currentOffer[organization.plan]) && !hasOfferCookie;
87✔
363

2✔
364
  const headerLogo = isDarkMode ? (isEnterprise ? whiteEnterpriseLogo : whiteLogo) : isEnterprise ? enterpriseLogo : logo;
87!
365

2✔
366
  return (
87✔
367
    <Toolbar id="fixedHeader" className={showOffer ? `${classes.header} ${classes.banner}` : classes.header}>
2✔
368
      {!!announcement && (
2✔
369
        <Announcement
2✔
370
          announcement={announcement}
2✔
371
          errorIconClassName={classes.redAnnouncementIcon}
2✔
372
          iconClassName={classes.demoAnnouncementIcon}
2✔
373
          sectionClassName={classes.demoTrialAnnouncement}
2✔
UNCOV
374
          onHide={() => dispatch(setHideAnnouncement({ shouldHide: true }))}
2✔
375
        />
2✔
376
      )}
2✔
377
      {showOffer && <OfferHeader onHide={setHideOffer} />}
2✔
378
      <div className="flexbox space-between">
2✔
379
        <div className="flexbox center-aligned">
2✔
380
          <Link to="/">
2✔
381
            <img id="logo" src={headerLogo} />
2✔
382
          </Link>
2✔
383
          {organization.trial && (
2!
384
            <TrialNotification
2✔
385
              expiration={organization.trial_expiration}
2✔
386
              iconClassName={classes.demoAnnouncementIcon}
2✔
387
              sectionClassName={classes.demoTrialAnnouncement}
2✔
388
            />
2✔
389
          )}
2✔
390
        </div>
2✔
391
        {isSp ? (
2!
392
          <>
2✔
393
            {tenantDeviceLimit > 0 && <DeviceCount current={spDeviceUtilization} max={tenantDeviceLimit} variant="common" />}
2!
394
            <div className="flexbox center-aligned">
2✔
395
              <div className={classes.headerSection}>
2✔
396
                <Chip className="bold muted uppercased" label="Service Provider" />
2✔
397
              </div>
2✔
398
              <AccountMenu />
2✔
399
            </div>
2✔
400
          </>
2✔
401
        ) : (
2✔
402
          <>
2✔
403
            <Search className={classes.search} isSearching={isSearching} searchTerm={searchTerm} onSearch={onSearch} trigger={refreshTrigger} />
2✔
404
            <div className="flexbox center-aligned">
2✔
405
              <DeviceNotifications className={classes.headerSection} pending={pendingDevices} total={acceptedDevices} limit={deviceLimit} />
2✔
406
              <Divider className={`margin-left-small margin-right-small ${classes.headerSection}`} orientation="vertical" />
2✔
407
              <DeploymentNotifications className={classes.headerSection} inprogress={inProgress} />
2✔
408
              <Divider className={`margin-left-small margin-right-small ${classes.headerSection}`} orientation="vertical" />
2✔
409
              <AccountMenu className={classes.headerSection} />
2✔
410
            </div>
2✔
411
          </>
2✔
412
        )}
2✔
413
      </div>
2✔
414
    </Toolbar>
2✔
415
  );
2✔
416
};
2✔
417

2✔
418
export default Header;
2✔
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