diff --git a/src/features/layout/LeftNavBar.module.scss b/src/features/layout/LeftNavBar.module.scss
index a79a15eb..119b4c75 100644
--- a/src/features/layout/LeftNavBar.module.scss
+++ b/src/features/layout/LeftNavBar.module.scss
@@ -45,6 +45,13 @@ ul.nav_list {
background: use-rgba-color("silver", 0.5);
}
+ &.disabled,
+ &.disabled:hover {
+ background: use-color("white");
+ color: use-color("base-light");
+ cursor: not-allowed;
+ }
+
.nav_icon {
font-size: 2rem;
width: 100%;
diff --git a/src/features/layout/LeftNavBar.test.tsx b/src/features/layout/LeftNavBar.test.tsx
new file mode 100644
index 00000000..6f6f4a28
--- /dev/null
+++ b/src/features/layout/LeftNavBar.test.tsx
@@ -0,0 +1,64 @@
+import { render, screen } from '@testing-library/react';
+import fetchMock from 'jest-fetch-mock';
+import { Provider } from 'react-redux';
+import { MemoryRouter } from 'react-router-dom';
+import { createTestStore } from '../../app/store';
+import { MfaStatus } from '../../common/types/auth';
+import { TokenInfo } from '../auth/authSlice';
+import LeftNavBar from './LeftNavBar';
+
+const renderNav = (customroles: string[], mfa: MfaStatus) => {
+ fetchMock.mockResponse(async (req) => {
+ if (req.url.includes('/api/V2/me'))
+ return JSON.stringify({ user: 'someuser', roles: [], customroles });
+ return JSON.stringify({ unseen: { global: 0, user: 0 } });
+ });
+ const store = createTestStore({
+ auth: {
+ initialized: true,
+ token: 'sometoken',
+ username: 'someuser',
+ tokenInfo: { mfa } as TokenInfo,
+ },
+ });
+ render(
+
+
+
+
+
+ );
+};
+
+describe('LeftNavBar Lakehouse item', () => {
+ beforeEach(() => {
+ fetchMock.enableMocks();
+ fetchMock.resetMocks();
+ });
+ afterEach(() => {
+ fetchMock.disableMocks();
+ });
+
+ test('is absent without the BERDL_USER role', async () => {
+ renderNav([], 'Used');
+ await screen.findByText('Navigator');
+ expect(screen.queryByText('Lakehouse')).not.toBeInTheDocument();
+ });
+
+ test('links to the hub redirect for an MFA log in session', async () => {
+ renderNav(['BERDL_USER'], 'Used');
+ const item = await screen.findByText('Lakehouse');
+ expect(item.closest('a')).toHaveAttribute('href', '/lakehouse/redirect');
+ expect(screen.queryByText('alpha')).not.toBeInTheDocument();
+ });
+
+ test.each(['NotUsed', 'Unknown'])(
+ 'is disabled for a %s log in session',
+ async (mfa) => {
+ renderNav(['BERDL_USER'], mfa);
+ const item = await screen.findByText('Lakehouse');
+ expect(item.closest('a')).toBeNull();
+ expect(item.closest('[aria-disabled="true"]')).not.toBeNull();
+ }
+ );
+});
diff --git a/src/features/layout/LeftNavBar.tsx b/src/features/layout/LeftNavBar.tsx
index f50c0923..12949145 100644
--- a/src/features/layout/LeftNavBar.tsx
+++ b/src/features/layout/LeftNavBar.tsx
@@ -23,12 +23,13 @@ import { getFeedsUnseenCount } from '../../common/api/feedsService';
import { useAppSelector } from '../../common/hooks';
import { authToken } from '../auth/authSlice';
import classes from './LeftNavBar.module.scss';
-import { Button, Menu, MenuItem } from '@mui/material';
+import { Button, Menu, MenuItem, Tooltip } from '@mui/material';
import { getMe } from '../../common/api/authService';
import { skipToken } from '@reduxjs/toolkit/dist/query';
const LeftNavBar: FC = () => {
const token = useAppSelector(authToken);
+ const mfa = useAppSelector(({ auth }) => auth.tokenInfo?.mfa);
const { data: feeds } = getFeedsUnseenCount.useQuery(undefined, {
skip: !token,
pollingInterval: 10000,
@@ -70,9 +71,13 @@ const LeftNavBar: FC = () => {
path={'/lakehouse/redirect'}
desc="Lakehouse"
icon={faDatabase}
- badge={'alpha'}
- badgeColor={'warning'}
requiredRole="BERDL_USER"
+ // The hub admits only log in sessions that used a second factor.
+ disabledReason={
+ mfa === 'Used'
+ ? undefined
+ : 'The Lakehouse requires two-factor authentication. Log out, then log in with ORCID using 2FA.'
+ }
/>
@@ -161,7 +166,17 @@ const NavItem: FC<{
badge?: number | string;
badgeColor?: string;
requiredRole?: string;
-}> = ({ path, onClick, desc, icon, badge, badgeColor, requiredRole }) => {
+ disabledReason?: string;
+}> = ({
+ path,
+ onClick,
+ desc,
+ icon,
+ badge,
+ badgeColor,
+ requiredRole,
+ disabledReason,
+}) => {
const location = useLocation();
const token = useAppSelector(authToken);
const { data: me } = getMe.useQuery(token ? { token } : skipToken);
@@ -174,23 +189,39 @@ const NavItem: FC<{
itemClasses += ` ${classes.active}`;
}
if (requiredRole && !myRoles.has(requiredRole)) return <>>;
+ const content = (
+ <>
+
+ {desc}
+ {badge ? (
+
+ {badge}
+
+ ) : (
+ <>>
+ )}
+ >
+ );
+ if (disabledReason) {
+ return (
+ -
+
+
+ {content}
+
+
+
+ );
+ }
return (
-
-
- {desc}
- {badge ? (
-
- {badge}
-
- ) : (
- <>>
- )}
+ {content}
);