diff --git a/web/packages/shared/utils/assertUnreachable.ts b/web/packages/shared/utils/assertUnreachable.ts
new file mode 100644
index 0000000000000..4d91e495874f1
--- /dev/null
+++ b/web/packages/shared/utils/assertUnreachable.ts
@@ -0,0 +1,19 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function assertUnreachable(x: never): never {
+ throw new Error(`Unhandled case: ${x}`);
+}
diff --git a/web/packages/teleport/src/Navigation/Navigation.tsx b/web/packages/teleport/src/Navigation/Navigation.tsx
index a979dff351262..2c77ed0f60906 100644
--- a/web/packages/teleport/src/Navigation/Navigation.tsx
+++ b/web/packages/teleport/src/Navigation/Navigation.tsx
@@ -17,20 +17,17 @@ limitations under the License.
import React, { useCallback, useEffect, useState } from 'react';
import styled, { useTheme } from 'styled-components';
import { matchPath, useHistory, useLocation } from 'react-router';
-
import { Image } from 'design';
import { NavigationSwitcher } from 'teleport/Navigation/NavigationSwitcher';
import cfg from 'teleport/config';
-
import {
NAVIGATION_CATEGORIES,
NavigationCategory,
} from 'teleport/Navigation/categories';
-
import { useFeatures } from 'teleport/FeaturesContext';
-
import { NavigationCategoryContainer } from 'teleport/Navigation/NavigationCategoryContainer';
+import { NotificationKind } from 'teleport/stores/storeNotifications';
import { useTeleport } from '..';
@@ -176,7 +173,15 @@ export function Navigation({
)}
diff --git a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx
index 1c97c35efc140..31be645607358 100644
--- a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx
+++ b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx
@@ -20,7 +20,7 @@ import { render, screen } from 'design/utils/testing';
import { generatePath, Router } from 'react-router';
-import { createMemoryHistory } from 'history';
+import { createMemoryHistory, MemoryHistory } from 'history';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
import TeleportContext from 'teleport/teleportContext';
@@ -30,8 +30,9 @@ import { NavigationCategory } from 'teleport/Navigation/categories';
import { NavigationItem } from 'teleport/Navigation/NavigationItem';
import { NavigationItemSize } from 'teleport/Navigation/common';
import { makeUserContext } from 'teleport/services/user';
+import { NotificationKind } from 'teleport/stores/storeNotifications';
-class MockFeature implements TeleportFeature {
+class MockUserFeature implements TeleportFeature {
category = NavigationCategory.Resources;
route = {
@@ -55,32 +56,50 @@ class MockFeature implements TeleportFeature {
};
}
+class MockAccessListFeature implements TeleportFeature {
+ category = NavigationCategory.Resources;
+
+ route = {
+ title: 'Users',
+ path: '/web/cluster/:clusterId/feature',
+ exact: true,
+ component: () =>
Test!
,
+ };
+
+ hasAccess() {
+ return true;
+ }
+
+ navigationItem = {
+ title: NavTitle.AccessLists,
+ icon: ,
+ exact: true,
+ getLink(clusterId: string) {
+ return generatePath('/web/cluster/:clusterId/feature', { clusterId });
+ },
+ };
+}
+
describe('navigation items', () => {
- it('should render the feature link correctly', () => {
- const history = createMemoryHistory({
+ let ctx: TeleportContext;
+ let history: MemoryHistory;
+
+ beforeEach(() => {
+ history = createMemoryHistory({
initialEntries: ['/web/cluster/root/feature'],
});
- const ctx = new TeleportContext();
+ ctx = new TeleportContext();
ctx.storeUser.state = makeUserContext({
cluster: {
name: 'test-cluster',
lastConnected: Date.now(),
},
});
+ });
- render(
-
-
-
-
-
- );
+ it('should render the feature link correctly', () => {
+ render(getNavigationItem({ ctx, history }));
expect(screen.getByText('Users').closest('a')).toHaveAttribute(
'href',
@@ -89,30 +108,7 @@ describe('navigation items', () => {
});
it('should change the feature link to the leaf cluster when navigating to a leaf cluster', () => {
- const history = createMemoryHistory({
- initialEntries: ['/web/cluster/root/feature'],
- });
-
- const ctx = new TeleportContext();
- ctx.storeUser.state = makeUserContext({
- cluster: {
- name: 'test-cluster',
- lastConnected: Date.now(),
- },
- });
-
- render(
-
-
-
-
-
- );
+ render(getNavigationItem({ ctx, history }));
expect(screen.getByText('Users').closest('a')).toHaveAttribute(
'href',
@@ -126,4 +122,56 @@ describe('navigation items', () => {
'/web/cluster/leaf/feature'
);
});
+
+ it('rendeirng of attention dot for access list', () => {
+ const { rerender } = render(
+ getNavigationItem({ ctx, history, feature: new MockAccessListFeature() })
+ );
+
+ expect(
+ screen.queryByTestId('nav-item-attention-dot')
+ ).not.toBeInTheDocument();
+
+ // Add in some notifications
+ ctx.storeNotifications.setNotifications([
+ {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'banana',
+ route: '',
+ },
+ id: 'abc',
+ date: new Date(),
+ },
+ ]);
+
+ rerender(
+ getNavigationItem({ ctx, history, feature: new MockAccessListFeature() })
+ );
+
+ expect(screen.getByTestId('nav-item-attention-dot')).toBeInTheDocument();
+ });
});
+
+function getNavigationItem({
+ ctx,
+ history,
+ feature = new MockUserFeature(),
+}: {
+ ctx: TeleportContext;
+ history: MemoryHistory;
+ feature?: TeleportFeature;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/web/packages/teleport/src/Navigation/NavigationItem.tsx b/web/packages/teleport/src/Navigation/NavigationItem.tsx
index 7b96af039d85a..7022d58216a6b 100644
--- a/web/packages/teleport/src/Navigation/NavigationItem.tsx
+++ b/web/packages/teleport/src/Navigation/NavigationItem.tsx
@@ -28,14 +28,11 @@ import {
LinkContent,
NavigationItemSize,
} from 'teleport/Navigation/common';
-
import useStickyClusterId from 'teleport/useStickyClusterId';
-
import localStorage from 'teleport/services/localStorage';
-
import { useTeleport } from 'teleport';
-
import { NavTitle, RecommendationStatus } from 'teleport/types';
+import { NotificationKind } from 'teleport/stores/storeNotifications';
import type {
TeleportFeature,
@@ -170,6 +167,18 @@ export function NavigationItem(props: NavigationItemProps) {
// renderHighlightFeature returns red dot component if the feature recommendation state is 'NOTIFY'
function renderHighlightFeature(featureName: NavTitle): JSX.Element {
+ if (featureName === NavTitle.AccessLists) {
+ const hasNotifications = ctx.storeNotifications.hasNotificationsByKind(
+ NotificationKind.AccessList
+ );
+
+ if (hasNotifications) {
+ return ;
+ }
+
+ return null;
+ }
+
// Get onboarding status. We'll only recommend features once user completes
// initial onboarding (i.e. connect resources to Teleport cluster).
const onboard = localStorage.getOnboardDiscover();
@@ -183,7 +192,7 @@ export function NavigationItem(props: NavigationItemProps) {
featureName === NavTitle.TrustedDevices &&
recommendFeatureStatus?.TrustedDevices === RecommendationStatus.Notify
) {
- return ;
+ return ;
}
return null;
}
@@ -264,7 +273,9 @@ export function NavigationItem(props: NavigationItemProps) {
);
}
-const RedDot = styled.div`
+const AttentionDot = styled.div.attrs(() => ({
+ 'data-testid': 'nav-item-attention-dot',
+}))`
margin-left: 15px;
margin-top: 2px;
width: 7px;
diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx
new file mode 100644
index 0000000000000..0d66caf2a1f5a
--- /dev/null
+++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx
@@ -0,0 +1,79 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import Flex from 'design/Flex';
+
+import { NavigationSwitcher } from './NavigationSwitcher';
+import { NavigationCategory } from './categories';
+
+export default {
+ title: 'Teleport/Navigation',
+};
+
+const navItems = [
+ { category: NavigationCategory.Management },
+ { category: NavigationCategory.Resources },
+];
+
+export function SwitcherResource() {
+ return (
+ null}
+ items={navItems}
+ value={NavigationCategory.Resources}
+ />
+ );
+}
+
+export function SwitcherManagement() {
+ return (
+ null}
+ items={navItems}
+ value={NavigationCategory.Management}
+ />
+ );
+}
+
+export function SwitcherRequiresManagementAttention() {
+ return (
+
+ null}
+ items={[
+ { category: NavigationCategory.Resources },
+ { category: NavigationCategory.Management, requiresAttention: true },
+ ]}
+ value={NavigationCategory.Resources}
+ />
+
+ );
+}
+
+export function SwitcherRequiresResourcesAttention() {
+ return (
+
+ null}
+ items={[
+ { category: NavigationCategory.Resources, requiresAttention: true },
+ { category: NavigationCategory.Management },
+ ]}
+ value={NavigationCategory.Management}
+ />
+
+ );
+}
diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx
new file mode 100644
index 0000000000000..9cb3b3e1043bd
--- /dev/null
+++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx
@@ -0,0 +1,91 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { render, screen, userEvent } from 'design/utils/testing';
+
+import { NavigationSwitcher } from './NavigationSwitcher';
+import { NavigationCategory } from './categories';
+
+test('not requiring attention', async () => {
+ render(
+ null}
+ items={[
+ { category: NavigationCategory.Management },
+ { category: NavigationCategory.Resources },
+ ]}
+ value={NavigationCategory.Resources}
+ />
+ );
+
+ expect(
+ screen.queryByTestId('nav-switch-attention-dot')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument();
+
+ // Test clicking
+ await userEvent.click(screen.getByTestId('nav-switch-button'));
+ expect(
+ screen.queryByTestId('nav-switch-attention-dot')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument();
+});
+
+test('requires attention: not at nav category target (management)', async () => {
+ render(
+ null}
+ items={[
+ { category: NavigationCategory.Management, requiresAttention: true },
+ { category: NavigationCategory.Resources },
+ ]}
+ value={NavigationCategory.Resources}
+ />
+ );
+
+ expect(screen.getByTestId('nav-switch-attention-dot')).toBeInTheDocument();
+ expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeVisible();
+
+ // Test clicking
+ await userEvent.click(screen.getByTestId('nav-switch-button'));
+ expect(screen.getByTestId('nav-switch-attention-dot')).toBeInTheDocument();
+ expect(screen.getByTestId('dd-item-attention-dot')).toBeVisible();
+});
+
+test('requires attention: being at the nav category target (management) should NOT render attention dot', async () => {
+ render(
+ null}
+ items={[
+ { category: NavigationCategory.Management, requiresAttention: true },
+ { category: NavigationCategory.Resources },
+ ]}
+ value={NavigationCategory.Management}
+ />
+ );
+
+ expect(
+ screen.queryByTestId('nav-switch-attention-dot')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument();
+
+ // Test clicking
+ await userEvent.click(screen.getByTestId('nav-switch-button'));
+ expect(
+ screen.queryByTestId('nav-switch-attention-dot')
+ ).not.toBeInTheDocument();
+ expect(screen.queryByTestId('dd-item-attention-dot')).not.toBeInTheDocument();
+});
diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx
index 86783ca0cf96b..24bb2d7b29e4d 100644
--- a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx
+++ b/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx
@@ -21,10 +21,15 @@ import { ChevronDownIcon } from 'design/SVGIcon/ChevronDown';
import { NavigationCategory } from 'teleport/Navigation/categories';
+type NavigationItems = {
+ category: NavigationCategory;
+ requiresAttention?: boolean;
+};
+
interface NavigationSwitcherProps {
onChange: (value: NavigationCategory) => void;
value: NavigationCategory;
- items: NavigationCategory[];
+ items: NavigationItems[];
}
interface OpenProps {
@@ -117,7 +122,10 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) {
const activeValueRef = useRef();
const firstValueRef = useRef();
- const activeItem = props.items.find(item => item === props.value);
+ const activeItem = props.items.find(item => item.category === props.value);
+ const requiresAttentionButNotActive = props.items.some(
+ item => item.requiresAttention && item.category !== activeItem.category
+ );
const handleClickOutside = useCallback(
(event: MouseEvent) => {
@@ -219,28 +227,35 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) {
items.push(
handleKeyDownLink(event, item)}
+ onKeyDown={event => handleKeyDownLink(event, item.category)}
tabIndex={open ? 0 : -1}
- onClick={() => handleChange(item)}
+ onClick={() => handleChange(item.category)}
key={index}
open={open}
- active={item === props.value}
+ active={item.category === props.value}
>
- {item}
+ {item.category}
+ {item.requiresAttention && item.category !== activeItem.category && (
+
+ )}
);
}
return (
+ {requiresAttentionButNotActive && (
+
+ )}
setOpen(!open)}
open={open}
tabIndex={0}
onKeyDown={handleKeyDown}
+ data-testid="nav-switch-button"
>
- {activeItem}
+ {activeItem.category}
@@ -251,3 +266,24 @@ export function NavigationSwitcher(props: NavigationSwitcherProps) {
);
}
+
+const NavSwitcherAttentionDot = styled.div`
+ position: absolute;
+ background-color: ${props => props.theme.colors.error.main};
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ right: -3px;
+ top: -4px;
+ z-index: 100;
+`;
+
+const DropDownItemAttentionDot = styled.div`
+ display: inline-block;
+ margin-left: 10px;
+ margin-top: 2px;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background-color: ${props => props.theme.colors.error.main};
+`;
diff --git a/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx b/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx
index e813671b77034..edddfe6f7e7e7 100644
--- a/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx
+++ b/web/packages/teleport/src/TopBar/ClusterSelector/ClusterSelector.tsx
@@ -82,7 +82,11 @@ export default function ClusterSelector({
}
return (
-
+
({ open, setOpen });
+
+ let transitionDelay = STARTING_TRANSITION_DELAY;
+ const items = notices.map(notice => {
+ const currentTransitionDelay = transitionDelay;
+ transitionDelay += INCREMENT_TRANSITION_DELAY;
+
+ return (
+
+ setOpen(false)} />
+
+ );
+ });
+
+ return (
+
+ setOpen(!open)}
+ data-testid="tb-note-button"
+ >
+ {items.length > 0 && }
+
+
+
+
+ {items.length ? (
+ items
+ ) : (
+
+ No notifications
+
+ )}
+
+
+ );
+}
+
+function NotificationItem({
+ notice,
+ close,
+}: {
+ notice: Notification;
+ close(): void;
+}) {
+ switch (notice.item.kind) {
+ case NotificationKind.AccessList:
+ return (
+
+
+
+
+
+
+ Access list {notice.item.resourceName} needs your review
+ within {formatDistanceToNow(notice.date)}.
+
+
+
+ );
+ default:
+ assertUnreachable(notice.item.kind);
+ }
+}
+
+const NotificationButtonContainer = styled.div`
+ position: relative;
+`;
+
+const AttentionDot = styled.div`
+ position: absolute;
+ width: 7px;
+ height: 7px;
+ border-radius: 100px;
+ background-color: ${p => p.theme.colors.buttons.warning.default};
+ top: 10px;
+ right: 15px;
+`;
+
+const NotificationItemButton = styled(DropdownItemButton)`
+ align-items: flex-start;
+ line-height: 20px;
+`;
+
+const NotificationLink = styled(DropdownItemLink)`
+ padding: 0;
+`;
diff --git a/web/packages/teleport/src/TopBar/Notifications/index.ts b/web/packages/teleport/src/TopBar/Notifications/index.ts
new file mode 100644
index 0000000000000..0d5bf6f3eb944
--- /dev/null
+++ b/web/packages/teleport/src/TopBar/Notifications/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { Notifications } from './Notifications';
diff --git a/web/packages/teleport/src/TopBar/Shared.tsx b/web/packages/teleport/src/TopBar/Shared.tsx
new file mode 100644
index 0000000000000..59fe7d617738a
--- /dev/null
+++ b/web/packages/teleport/src/TopBar/Shared.tsx
@@ -0,0 +1,33 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import styled from 'styled-components';
+
+export const ButtonIconContainer = styled.div`
+ padding: 0 10px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 5px;
+ cursor: pointer;
+ user-select: none;
+ margin-right: 5px;
+
+ &:hover {
+ background: ${props => props.theme.colors.spotBackground[0]};
+ }
+`;
diff --git a/web/packages/teleport/src/TopBar/TopBar.story.tsx b/web/packages/teleport/src/TopBar/TopBar.story.tsx
index 85b3673cc1ead..10244365e470c 100644
--- a/web/packages/teleport/src/TopBar/TopBar.story.tsx
+++ b/web/packages/teleport/src/TopBar/TopBar.story.tsx
@@ -24,6 +24,7 @@ import TeleportContext from 'teleport/teleportContext';
import { makeUserContext } from 'teleport/services/user';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
import { LayoutContextProvider } from 'teleport/Main/LayoutContext';
+import { NotificationKind } from 'teleport/stores/storeNotifications';
import { TopBar } from './TopBar';
@@ -55,5 +56,59 @@ export function Story() {
);
}
-
Story.storyName = 'TopBar';
+
+export function TopBarWithNotifications() {
+ const ctx = new TeleportContext();
+
+ ctx.storeUser.state = makeUserContext({
+ userName: 'admin',
+ cluster: {
+ name: 'test-cluster',
+ lastConnected: Date.now(),
+ },
+ });
+ ctx.storeNotifications.state = {
+ notifications: [
+ {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'banana',
+ route: '',
+ },
+ id: '111',
+ date: new Date(),
+ },
+ {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'apple',
+ route: '',
+ },
+ id: '222',
+ date: new Date(),
+ },
+ {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'carrot',
+ route: '',
+ },
+ id: '333',
+ date: new Date(),
+ },
+ ],
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/packages/teleport/src/TopBar/TopBar.test.tsx b/web/packages/teleport/src/TopBar/TopBar.test.tsx
index 41a145f506ff6..12ce0141dbf80 100644
--- a/web/packages/teleport/src/TopBar/TopBar.test.tsx
+++ b/web/packages/teleport/src/TopBar/TopBar.test.tsx
@@ -15,8 +15,7 @@
*/
import React from 'react';
-import { render, screen } from 'design/utils/testing';
-
+import { render, screen, userEvent } from 'design/utils/testing';
import { Router } from 'react-router';
import { createMemoryHistory } from 'history';
@@ -27,17 +26,16 @@ import { getOSSFeatures } from 'teleport/features';
import TeleportContext, {
disabledFeatureFlags,
} from 'teleport/teleportContext';
-
import { makeUserContext } from 'teleport/services/user';
-
import { mockUserContextProviderWith } from 'teleport/User/testHelpers/mockUserContextWith';
import { makeTestUserContext } from 'teleport/User/testHelpers/makeTestUserContext';
+import { NotificationKind } from 'teleport/stores/storeNotifications';
import { clusters } from 'teleport/Clusters/fixtures';
import { TopBar } from './TopBar';
-let ctx;
+let ctx: TeleportContext;
function setup(): void {
ctx = new TeleportContext();
@@ -57,56 +55,82 @@ function setup(): void {
mockUserContextProviderWith(makeTestUserContext());
}
-test('does not show assist popup if hidePopup is true', () => {
+test('does not show assist popup if hidePopup is true', async () => {
setup();
- render(
-
-
-
-
-
-
-
-
-
- );
+ render(getTopBar({ hidePopup: true }));
+ await screen.findByTestId('cluster-selector');
expect(screen.queryByTestId('assistPopup')).not.toBeInTheDocument();
});
-test('shows assist popup if hidePopup is absent', () => {
+test('shows assist popup if hidePopup is absent', async () => {
setup();
- render(
-
-
-
-
-
-
-
-
-
- );
+ render(getTopBar({}));
+ await screen.findByTestId('cluster-selector');
+
+ expect(screen.getByTestId('assistPopup')).toBeInTheDocument();
+});
+
+test('shows assist popup if hidePopup is false', async () => {
+ setup();
+
+ render(getTopBar({ hidePopup: false }));
+ await screen.findByTestId('cluster-selector');
expect(screen.getByTestId('assistPopup')).toBeInTheDocument();
});
-test('shows assist popup if hidePopup is false', () => {
+test('notification bell without notification', async () => {
setup();
- render(
+ render(getTopBar({}));
+ await screen.findByTestId('cluster-selector');
+
+ expect(screen.getByTestId('tb-note')).toBeInTheDocument();
+ expect(screen.queryByTestId('tb-note-attention')).not.toBeInTheDocument();
+});
+
+test('notification bell with notification', async () => {
+ setup();
+ ctx.storeNotifications.state = {
+ notifications: [
+ {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'banana',
+ route: '',
+ },
+ id: 'abc',
+ date: new Date(),
+ },
+ ],
+ };
+
+ render(getTopBar({}));
+ await screen.findByTestId('cluster-selector');
+
+ expect(screen.getByTestId('tb-note')).toBeInTheDocument();
+ expect(screen.getByTestId('tb-note-attention')).toBeInTheDocument();
+
+ // Test clicking and rendering of dropdown.
+ expect(screen.getByTestId('tb-note-dropdown')).not.toBeVisible();
+
+ await userEvent.click(screen.getByTestId('tb-note-button'));
+ expect(screen.getByTestId('tb-note-dropdown')).toBeVisible();
+});
+
+const getTopBar = ({ hidePopup = null }: { hidePopup?: boolean }) => {
+ return (
-
+
);
-
- expect(screen.getByTestId('assistPopup')).toBeInTheDocument();
-});
+};
diff --git a/web/packages/teleport/src/TopBar/TopBar.tsx b/web/packages/teleport/src/TopBar/TopBar.tsx
index edd0d3a9288be..743602e806baf 100644
--- a/web/packages/teleport/src/TopBar/TopBar.tsx
+++ b/web/packages/teleport/src/TopBar/TopBar.tsx
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { Suspense, useState } from 'react';
+import React, { Suspense, useState, lazy } from 'react';
import styled, { useTheme } from 'styled-components';
import { Flex, Text, TopNav } from 'design';
@@ -46,24 +46,10 @@ import {
} from 'teleport/Assist/Popup/Popup';
import ClusterSelector from './ClusterSelector';
+import { Notifications } from './Notifications';
+import { ButtonIconContainer } from './Shared';
-const Assist = React.lazy(() => import('teleport/Assist'));
-
-const AssistButton = styled.div`
- padding: 0 10px;
- height: 48px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 5px;
- cursor: pointer;
- user-select: none;
- margin-right: 5px;
-
- &:hover {
- background: ${props => props.theme.colors.spotBackground[0]};
- }
-`;
+const Assist = lazy(() => import('teleport/Assist'));
const AssistButtonContainer = styled.div`
position: relative;
@@ -189,9 +175,9 @@ export function TopBar({ hidePopup = false }: TopBarProps) {
{!hasDockedElement && assistEnabled && (
- setShowAssist(true)}>
+ setShowAssist(true)}>
-
+
{showAssistPopup && !hidePopup && (
<>
@@ -217,6 +203,7 @@ export function TopBar({ hidePopup = false }: TopBarProps) {
)}
)}
+
diff --git a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx
index 1350b42eb8472..e4d701a3b5bf6 100644
--- a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx
+++ b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx
@@ -67,17 +67,11 @@ export const DropdownItem = styled.div`
`;
export const commonDropdownItemStyles = css`
- opacity: 0.8;
align-items: center;
display: flex;
padding: ${p => p.theme.space[1] * 3}px;
color: ${props => props.theme.colors.text.main};
text-decoration: none;
- transition: opacity 0.15s ease-in;
-
- &:hover {
- opacity: 1;
- }
svg {
height: 18px;
diff --git a/web/packages/teleport/src/stores/index.ts b/web/packages/teleport/src/stores/index.ts
index 0be09b9c0c712..f3c4288599038 100644
--- a/web/packages/teleport/src/stores/index.ts
+++ b/web/packages/teleport/src/stores/index.ts
@@ -16,4 +16,6 @@
import StoreNav, { defaultNavState } from './storeNav';
import StoreUserContext from './storeUserContext';
-export { StoreNav, StoreUserContext, defaultNavState };
+import { StoreNotifications } from './storeNotifications';
+
+export { StoreNav, StoreUserContext, StoreNotifications, defaultNavState };
diff --git a/web/packages/teleport/src/stores/storeNotifications.test.ts b/web/packages/teleport/src/stores/storeNotifications.test.ts
new file mode 100644
index 0000000000000..1a870efb2a9eb
--- /dev/null
+++ b/web/packages/teleport/src/stores/storeNotifications.test.ts
@@ -0,0 +1,91 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ Notification,
+ NotificationKind,
+ StoreNotifications,
+} from './storeNotifications';
+
+test('get/set/update notifications', async () => {
+ const store = new StoreNotifications();
+
+ expect(store.getNotifications()).toStrictEqual([]);
+ expect(store.hasNotificationsByKind(NotificationKind.AccessList)).toBeFalsy();
+
+ // set some notifications, sorted by earliest date.
+ const newerNote: Notification = {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'apple',
+ route: '',
+ },
+ id: '111',
+ date: new Date('2023-10-04T09:09:22-07:00'),
+ };
+ const olderNote: Notification = {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'banana',
+ route: '',
+ },
+ id: '222',
+ date: new Date('2023-10-01T09:09:22-07:00'),
+ };
+
+ store.setNotifications([newerNote, olderNote]);
+ expect(store.getNotifications()).toStrictEqual([olderNote, newerNote]);
+
+ // Update notes, sorted by earliest date.
+ const newestNote: Notification = {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'carrot',
+ route: '',
+ },
+ id: '333',
+ date: new Date('2023-11-23T09:09:22-07:00'),
+ };
+ const newestOlderNote: Notification = {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'carrot',
+ route: '',
+ },
+ id: '444',
+ date: new Date('2023-10-03T09:09:22-07:00'),
+ };
+ const newestOldestNote: Notification = {
+ item: {
+ kind: NotificationKind.AccessList,
+ resourceName: 'carrot',
+ route: '',
+ },
+ id: '444',
+ date: new Date('2023-10-01T09:09:22-07:00'),
+ };
+ store.setNotifications([newestNote, newestOldestNote, newestOlderNote]);
+ expect(store.getNotifications()).toStrictEqual([
+ newestOldestNote,
+ newestOlderNote,
+ newestNote,
+ ]);
+
+ // Test has notifications
+ expect(
+ store.hasNotificationsByKind(NotificationKind.AccessList)
+ ).toBeTruthy();
+});
diff --git a/web/packages/teleport/src/stores/storeNotifications.ts b/web/packages/teleport/src/stores/storeNotifications.ts
new file mode 100644
index 0000000000000..4d2f9169ba905
--- /dev/null
+++ b/web/packages/teleport/src/stores/storeNotifications.ts
@@ -0,0 +1,86 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Store } from 'shared/libs/stores';
+import { assertUnreachable } from 'shared/utils/assertUnreachable';
+
+export enum NotificationKind {
+ AccessList = 'access-list',
+}
+
+type AccessListNotification = {
+ kind: NotificationKind.AccessList;
+ resourceName: string;
+ route: string;
+};
+
+export type Notification = {
+ item: AccessListNotification;
+ id: string;
+ date: Date;
+};
+
+// TODO?: based on a feedback, consider representing
+// notifications as a collection of maps indexed by id
+// which is then converted to a sorted list as needed
+// (may be easier to work with)
+export type NotificationState = {
+ notifications: Notification[];
+};
+
+const defaultNotificationState: NotificationState = {
+ notifications: [],
+};
+
+export class StoreNotifications extends Store {
+ state: NotificationState = defaultNotificationState;
+
+ getNotifications() {
+ return this.state.notifications;
+ }
+
+ setNotifications(notices: Notification[]) {
+ // Sort by earliest dates.
+ const sortedNotices = notices.sort((a, b) => {
+ return a.date.getTime() - b.date.getTime();
+ });
+ this.setState({ notifications: [...sortedNotices] });
+ }
+
+ updateNotificationsByKind(notices: Notification[], kind: NotificationKind) {
+ switch (kind) {
+ case NotificationKind.AccessList:
+ const filtered = this.state.notifications.filter(
+ n => n.item.kind !== NotificationKind.AccessList
+ );
+ this.setNotifications([...filtered, ...notices]);
+ return;
+ default:
+ assertUnreachable(kind);
+ }
+ }
+
+ hasNotificationsByKind(kind: NotificationKind) {
+ switch (kind) {
+ case NotificationKind.AccessList:
+ return this.getNotifications().some(
+ n => n.item.kind === NotificationKind.AccessList
+ );
+ default:
+ assertUnreachable(kind);
+ }
+ }
+}
diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx
index e7bcf83c836fe..babefbe3329de 100644
--- a/web/packages/teleport/src/teleportContext.tsx
+++ b/web/packages/teleport/src/teleportContext.tsx
@@ -16,7 +16,7 @@ limitations under the License.
import cfg from 'teleport/config';
-import { StoreNav, StoreUserContext } from './stores';
+import { StoreNav, StoreUserContext, StoreNotifications } from './stores';
import * as types from './types';
import AuditService from './services/audit';
import RecordingsService from './services/recordings';
@@ -39,6 +39,7 @@ class TeleportContext implements types.Context {
// stores
storeNav = new StoreNav();
storeUser = new StoreUserContext();
+ storeNotifications = new StoreNotifications();
// services
auditService = new AuditService();
diff --git a/web/packages/teleterm/src/ui/utils/assertUnreachable.ts b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts
index 0b9aed7fc3b88..0eb634554301e 100644
--- a/web/packages/teleterm/src/ui/utils/assertUnreachable.ts
+++ b/web/packages/teleterm/src/ui/utils/assertUnreachable.ts
@@ -14,6 +14,9 @@
* limitations under the License.
*/
-export function assertUnreachable(x: never): never {
- throw new Error(`Unhandled case: ${x}`);
-}
+import { assertUnreachable } from 'shared/utils/assertUnreachable';
+
+/**
+ * @deprecated Import assertUnreachable from `shared/utils/assertUnreachable` instead.
+ */
+export { assertUnreachable };