diff --git a/web/packages/design/src/theme/themes/sharedStyles.ts b/web/packages/design/src/theme/themes/sharedStyles.ts index 28daeba111518..32bac18a5001f 100644 --- a/web/packages/design/src/theme/themes/sharedStyles.ts +++ b/web/packages/design/src/theme/themes/sharedStyles.ts @@ -36,10 +36,16 @@ export const sharedStyles: SharedStyles = { '0px 1px 10px 0px rgba(0, 0, 0, 0.12), 0px 4px 5px 0px rgba(0, 0, 0, 0.14), 0px 2px 4px -1px rgba(0, 0, 0, 0.20)', ], breakpoints: { + // TODO (avatus): remove mobile/tablet/desktop breakpoints in favor of screensize descriptions mobile: 400 + sidebarWidth, tablet: 800 + sidebarWidth, desktop: 1200 + sidebarWidth, + // use these from now on + small: 690, + medium: 1053, + large: 1280, }, + topBarHeight: [48, 56, 72], space: [0, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80], borders: [ 0, diff --git a/web/packages/design/src/theme/themes/types.ts b/web/packages/design/src/theme/themes/types.ts index fad696d7c511c..3cb6f7ee86f86 100644 --- a/web/packages/design/src/theme/themes/types.ts +++ b/web/packages/design/src/theme/themes/types.ts @@ -272,7 +272,11 @@ export type SharedStyles = { mobile: number; tablet: number; desktop: number; + small: number; + medium: number; + large: number; }; + topBarHeight: number[]; space: number[]; borders: (string | number)[]; typography: typeof typography; diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/shared/components/ToolTip/HoverTooltip.tsx index 2737295ecef86..ef82bb0c08704 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/shared/components/ToolTip/HoverTooltip.tsx @@ -20,13 +20,27 @@ import React, { PropsWithChildren, useState } from 'react'; import styled from 'styled-components'; import { Popover, Flex, Text } from 'design'; +type OriginProps = { + vertical: string; + horizontal: string; +}; + export const HoverTooltip: React.FC< PropsWithChildren<{ tipContent: string | undefined; showOnlyOnOverflow?: boolean; className?: string; + anchorOrigin?: OriginProps; + transformOrigin?: OriginProps; }> -> = ({ tipContent, children, showOnlyOnOverflow = false, className }) => { +> = ({ + tipContent, + children, + showOnlyOnOverflow = false, + className, + anchorOrigin = { vertical: 'top', horizontal: 'center' }, + transformOrigin = { vertical: 'bottom', horizontal: 'center' }, +}) => { const [anchorEl, setAnchorEl] = useState(); const open = Boolean(anchorEl); @@ -70,14 +84,8 @@ export const HoverTooltip: React.FC< onClose={handlePopoverClose} open={open} anchorEl={anchorEl} - anchorOrigin={{ - vertical: 'top', - horizontal: 'center', - }} - transformOrigin={{ - vertical: 'bottom', - horizontal: 'center', - }} + anchorOrigin={anchorOrigin} + transformOrigin={transformOrigin} disableRestoreFocus > { ); - expect(screen.getByTestId('title')).toBeInTheDocument(); + expect(screen.getByTestId('teleport-logo')).toBeInTheDocument(); }); test('displays invite collaborators feedback if present', () => { @@ -142,5 +142,5 @@ test('renders without invite collaborators feedback enabled', () => { ); - expect(screen.getByTestId('title')).toBeInTheDocument(); + expect(screen.getByTestId('teleport-logo')).toBeInTheDocument(); }); diff --git a/web/packages/teleport/src/Main/Main.tsx b/web/packages/teleport/src/Main/Main.tsx index 1c76613456f3d..cd5206e8312b7 100644 --- a/web/packages/teleport/src/Main/Main.tsx +++ b/web/packages/teleport/src/Main/Main.tsx @@ -42,22 +42,15 @@ import useTeleport from 'teleport/useTeleport'; import { TopBar } from 'teleport/TopBar'; import { BannerList } from 'teleport/components/BannerList'; import { storageService } from 'teleport/services/storageService'; - import { ClusterAlert, LINK_LABEL } from 'teleport/services/alerts/alerts'; - -import { Navigation } from 'teleport/Navigation'; - import { useAlerts } from 'teleport/components/BannerList/useAlerts'; - import { FeaturesContextProvider, useFeatures } from 'teleport/FeaturesContext'; - import { getFirstRouteForCategory, - NavigationProps, + Navigation, } from 'teleport/Navigation/Navigation'; - import { NavigationCategory } from 'teleport/Navigation/categories'; - +import { TopBarProps } from 'teleport/TopBar/TopBar'; import { QuestionnaireProps } from 'teleport/Welcome/NewCredentials'; import { MainContainer } from './MainContainer'; @@ -72,7 +65,7 @@ export interface MainProps { features: TeleportFeature[]; billingBanners?: ReactNode[]; Questionnaire?: (props: QuestionnaireProps) => React.ReactElement; - navigationProps?: NavigationProps; + topBarProps?: TopBarProps; inviteCollaboratorsFeedback?: ReactNode; } @@ -170,6 +163,13 @@ export function Main(props: MainProps) { return ( + - + - diff --git a/web/packages/teleport/src/Main/MainContainer.tsx b/web/packages/teleport/src/Main/MainContainer.tsx index d1d337d854c52..6d39976016013 100644 --- a/web/packages/teleport/src/Main/MainContainer.tsx +++ b/web/packages/teleport/src/Main/MainContainer.tsx @@ -28,4 +28,11 @@ export const MainContainer = styled.div` flex: 1; min-height: 0; --sidebar-width: 256px; + margin-top: ${p => p.theme.topBarHeight[0]}px; + @media screen and (min-width: ${p => p.theme.breakpoints.small}px) { + margin-top: ${p => p.theme.topBarHeight[1]}px; + } + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + margin-top: ${p => p.theme.topBarHeight[2]}px; + } `; diff --git a/web/packages/teleport/src/Navigation/Navigation.tsx b/web/packages/teleport/src/Navigation/Navigation.tsx index cda1e55a620a9..aae2cb0138945 100644 --- a/web/packages/teleport/src/Navigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/Navigation.tsx @@ -16,12 +16,10 @@ * along with this program. If not, see . */ -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 React from 'react'; +import styled from 'styled-components'; +import { matchPath, useLocation, useHistory } from 'react-router'; -import { NavigationSwitcher } from 'teleport/Navigation/NavigationSwitcher'; import cfg from 'teleport/config'; import { NAVIGATION_CATEGORIES, @@ -29,13 +27,6 @@ import { } from 'teleport/Navigation/categories'; import { useFeatures } from 'teleport/FeaturesContext'; import { NavigationCategoryContainer } from 'teleport/Navigation/NavigationCategoryContainer'; -import { NotificationKind } from 'teleport/stores/storeNotifications'; - -import { useTeleport } from '..'; - -import logoLight from './logoLight.svg'; -import logoDark from './logoDark.svg'; -import logoPoweredBy from './logoPoweredBy.svg'; import type * as history from 'history'; @@ -104,74 +95,14 @@ function getCategoryForRoute( return feature.category; } -export function Navigation({ - CustomLogo, - showPoweredByLogo = false, -}: NavigationProps) { +export function Navigation() { const features = useFeatures(); const history = useHistory(); const location = useLocation(); - const ctx = useTeleport(); - const [view, setView] = useState( + const view = getCategoryForRoute(features, history.location) || - NavigationCategory.Resources - ); - - const [previousRoute, setPreviousRoute] = useState<{ - [category: string]: string; - }>({}); - - const handleLocationChange = useCallback( - (next: history.Location | Location) => { - const previousPathName = location.pathname; - - const category = getCategoryForRoute(features, next); - const previousCategory = getCategoryForRoute(features, location); - - if (category && category !== view) { - setView(category); - - if (previousCategory) { - setPreviousRoute(previous => ({ - ...previous, - [previousCategory]: previousPathName, - })); - } - } - }, - [location, view] - ); - - useEffect(() => { - return history.listen(handleLocationChange); - }, [history, location.pathname, features, view]); - - const handlePopState = useCallback( - (event: PopStateEvent) => { - handleLocationChange((event.currentTarget as Window).location); - }, - [view] - ); - - useEffect(() => { - window.addEventListener('popstate', handlePopState); - - return () => window.removeEventListener('popstate', handlePopState); - }, [handlePopState]); - - const handleCategoryChange = useCallback( - (category: NavigationCategory) => { - if (view === category) { - return; - } - - history.push( - previousRoute[category] || getFirstRouteForCategory(features, category) - ); - }, - [view, previousRoute] - ); + NavigationCategory.Resources; const categories = NAVIGATION_CATEGORIES.map((category, index) => ( - {CustomLogo ? : } - - {ctx.getFeatureFlags().managementSection && ( - - )} - {categories} - {showPoweredByLogo && } ); } - -const NavigationLogo = () => { - const theme = useTheme(); - - return ( - teleport logo - ); -}; - -const PoweredByLogo = () => { - return ( - powered by teleport - ); -}; - -export type NavigationProps = { - CustomLogo?: () => React.ReactElement; - showPoweredByLogo?: boolean; -}; diff --git a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx index e6010fd102c34..55db6745063d5 100644 --- a/web/packages/teleport/src/Navigation/NavigationItem.test.tsx +++ b/web/packages/teleport/src/Navigation/NavigationItem.test.tsx @@ -17,6 +17,7 @@ */ import React from 'react'; +import styled from 'styled-components'; import { render, screen } from 'design/utils/testing'; @@ -52,7 +53,7 @@ class MockUserFeature implements TeleportFeature { navigationItem = { title: NavTitle.Users, - icon:
, + icon: styled.div, exact: true, getLink(clusterId: string) { return generatePath('/web/cluster/:clusterId/feature', { clusterId }); @@ -76,7 +77,7 @@ class MockAccessListFeature implements TeleportFeature { navigationItem = { title: NavTitle.AccessLists, - icon:
, + icon: styled.div, exact: true, getLink(clusterId: string) { return generatePath('/web/cluster/:clusterId/feature', { clusterId }); diff --git a/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx b/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx deleted file mode 100644 index a6296651b62f9..0000000000000 --- a/web/packages/teleport/src/Navigation/NavigationSwitcher.story.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -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 deleted file mode 100644 index 81e245ad80fe7..0000000000000 --- a/web/packages/teleport/src/Navigation/NavigationSwitcher.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -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 deleted file mode 100644 index 9932a36888470..0000000000000 --- a/web/packages/teleport/src/Navigation/NavigationSwitcher.tsx +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; - -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: NavigationItems[]; -} - -interface OpenProps { - open: boolean; -} - -interface ActiveProps { - active: boolean; -} - -const Container = styled.div` - position: relative; - align-self: center; - user-select: none; - margin-bottom: 25px; - margin-top: 26px; -`; - -const ActiveValue = styled.div` - border: 1px solid ${props => props.theme.colors.text.slightlyMuted}; - border-radius: 4px; - padding: 12px 16px; - width: 190px; - box-sizing: border-box; - position: relative; - cursor: pointer; - - &:focus { - background: ${props => props.theme.colors.spotBackground[0]}; - } -`; - -const Dropdown = styled.div` - position: absolute; - top: 46px; - left: 0; - overflow: hidden; - background: ${({ theme }) => theme.colors.levels.popout}; - border-radius: 4px; - z-index: 99; - box-shadow: ${({ theme }) => theme.boxShadow[1]}; - opacity: ${p => (p.open ? 1 : 0)}; - visibility: ${p => (p.open ? 'visible' : 'hidden')}; - transform-origin: top center; - transition: opacity 0.2s ease, visibility 0.2s ease, - transform 0.3s cubic-bezier(0.45, 0.6, 0.5, 1.25); - transform: translate3d(0, ${p => (p.open ? '12px' : 0)}, 0); -`; - -const DropdownItem = styled.div` - color: ${props => props.theme.colors.text.main}; - padding: 12px 16px; - width: 190px; - font-weight: ${p => (p.active ? 700 : 400)}; - box-sizing: border-box; - cursor: pointer; - opacity: ${p => (p.open ? 1 : 0)}; - transition: transform 0.3s ease, opacity 0.7s ease; - transform: translate3d(0, ${p => (p.open ? 0 : '-10px')}, 0); - - &:hover, - &:focus { - outline: none; - background: ${({ theme }) => theme.colors.spotBackground[0]}; - } -`; - -const Arrow = styled.div` - position: absolute; - top: 50%; - right: 16px; - transform: translate(0, -50%); - color: ${props => props.theme.colors.text.main} - line-height: 0; - - svg { - transform: ${p => (p.open ? 'rotate(-180deg)' : 'none')}; - transition: 0.1s linear transform; - - path { - fill: ${props => props.theme.colors.text.main} - } - } -`; - -export function NavigationSwitcher(props: NavigationSwitcherProps) { - const [open, setOpen] = useState(false); - - const ref = useRef(); - const activeValueRef = useRef(); - const firstValueRef = useRef(); - - 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) => { - if (ref.current && !ref.current.contains(event.target as HTMLElement)) { - setOpen(false); - } - }, - [ref.current] - ); - - useEffect(() => { - if (open) { - document.addEventListener('mousedown', handleClickOutside); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - } - }, [ref, open, handleClickOutside]); - - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case 'Enter': - setOpen(open => !open); - - break; - - case 'Escape': - setOpen(false); - - break; - - case 'ArrowDown': - if (!open) { - setOpen(true); - } - - firstValueRef.current.focus(); - - break; - - case 'ArrowUp': - setOpen(false); - - break; - } - }, - [open] - ); - - const handleKeyDownLink = useCallback( - (event: React.KeyboardEvent, item: NavigationCategory) => { - switch (event.key) { - case 'Enter': - handleChange(item); - - break; - - case 'ArrowDown': - const nextSibling = event.currentTarget.nextSibling as HTMLDivElement; - if (nextSibling) { - nextSibling.focus(); - } - - break; - - case 'ArrowUp': - const previousSibling = event.currentTarget - .previousSibling as HTMLDivElement; - if (previousSibling) { - previousSibling.focus(); - - return; - } - - activeValueRef.current.focus(); - - break; - } - }, - [props.value] - ); - - const handleChange = useCallback( - (value: NavigationCategory) => { - if (props.value !== value) { - props.onChange(value); - } - - setOpen(false); - }, - [props.value] - ); - - const items = []; - - for (const [index, item] of props.items.entries()) { - items.push( - handleKeyDownLink(event, item.category)} - tabIndex={open ? 0 : -1} - onClick={() => handleChange(item.category)} - key={index} - open={open} - active={item.category === props.value} - > - {item.category} - {item.requiresAttention && item.category !== activeItem.category && ( - - )} - - ); - } - - return ( - - {requiresAttentionButNotActive && ( - - )} - setOpen(!open)} - open={open} - tabIndex={0} - onKeyDown={handleKeyDown} - data-testid="nav-switch-button" - > - {activeItem.category} - - - - - - - {items} - - ); -} - -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/Notifications/Notifications.tsx b/web/packages/teleport/src/TopBar/Notifications/Notifications.tsx index 5bee87fb3219f..ddb93aaee9c20 100644 --- a/web/packages/teleport/src/TopBar/Notifications/Notifications.tsx +++ b/web/packages/teleport/src/TopBar/Notifications/Notifications.tsx @@ -25,6 +25,7 @@ import { Notification as NotificationIcon, UserList } from 'design/Icon'; import { useRefClickOutside } from 'shared/hooks/useRefClickOutside'; import { useStore } from 'shared/libs/stores'; import { assertUnreachable } from 'shared/utils/assertUnreachable'; +import { HoverTooltip } from 'shared/components/ToolTip'; import { Dropdown, @@ -71,29 +72,38 @@ export function Notifications() { }); return ( - - setOpen(!open)} - data-testid="tb-note-button" - > - {items.length > 0 && } - - - - - {items.length ? ( - items - ) : ( - - No notifications - - )} - - + + + setOpen(!open)} + data-testid="tb-note-button" + > + {items.length > 0 && } + + + + + {items.length ? ( + items + ) : ( + + No notifications + + )} + + + ); } @@ -134,6 +144,7 @@ function NotificationItem({ const NotificationButtonContainer = styled.div` position: relative; + height: 100%; `; const AttentionDot = styled.div` @@ -142,8 +153,8 @@ const AttentionDot = styled.div` height: 7px; border-radius: 100px; background-color: ${p => p.theme.colors.buttons.warning.default}; - top: 10px; - right: 15px; + top: 20px; + right: 25px; `; const NotificationItemButton = styled(DropdownItemButton)` @@ -153,4 +164,5 @@ const NotificationItemButton = styled(DropdownItemButton)` const NotificationLink = styled(DropdownItemLink)` padding: 0; + z-index: 999; `; diff --git a/web/packages/teleport/src/TopBar/Shared.tsx b/web/packages/teleport/src/TopBar/Shared.tsx index 0011fc8c8cd0e..05258a02ca746 100644 --- a/web/packages/teleport/src/TopBar/Shared.tsx +++ b/web/packages/teleport/src/TopBar/Shared.tsx @@ -20,11 +20,16 @@ import styled from 'styled-components'; export const ButtonIconContainer = styled.div` padding: 0 10px; - height: 48px; + height: 100%; display: flex; align-items: center; justify-content: center; - border-radius: 5px; + padding-left: 12px; + padding-right: 12px; + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + padding-left: 24px; + padding-right: 24px; + } cursor: pointer; user-select: none; margin-right: 5px; diff --git a/web/packages/teleport/src/TopBar/TopBar.test.tsx b/web/packages/teleport/src/TopBar/TopBar.test.tsx index a253fcc26e9f7..e2dec8c9510c9 100644 --- a/web/packages/teleport/src/TopBar/TopBar.test.tsx +++ b/web/packages/teleport/src/TopBar/TopBar.test.tsx @@ -61,7 +61,7 @@ test('notification bell without notification', async () => { setup(); render(getTopBar()); - await screen.findByTestId('cluster-selector'); + await screen.findByTestId('tb-note'); expect(screen.getByTestId('tb-note')).toBeInTheDocument(); expect(screen.queryByTestId('tb-note-attention')).not.toBeInTheDocument(); @@ -84,7 +84,7 @@ test('notification bell with notification', async () => { }; render(getTopBar()); - await screen.findByTestId('cluster-selector'); + await screen.findByTestId('tb-note'); expect(screen.getByTestId('tb-note')).toBeInTheDocument(); expect(screen.getByTestId('tb-note-attention')).toBeInTheDocument(); diff --git a/web/packages/teleport/src/TopBar/TopBar.tsx b/web/packages/teleport/src/TopBar/TopBar.tsx index 079f2d3116c30..b818749ad1ea0 100644 --- a/web/packages/teleport/src/TopBar/TopBar.tsx +++ b/web/packages/teleport/src/TopBar/TopBar.tsx @@ -17,92 +17,96 @@ */ import React, { lazy, Suspense, useState } from 'react'; -import styled from 'styled-components'; -import { Flex, Text, TopNav } from 'design'; +import styled, { useTheme } from 'styled-components'; +import { Link } from 'react-router-dom'; +import { Flex, Image, Text, TopNav } from 'design'; import { matchPath, useHistory } from 'react-router'; import { BrainIcon } from 'design/SVGIcon'; -import { ArrowLeft } from 'design/Icon'; +import { ArrowLeft, ChevronRight, SlidersVertical } from 'design/Icon'; +import { HoverTooltip } from 'shared/components/ToolTip'; import useTeleport from 'teleport/useTeleport'; -import useStickyClusterId from 'teleport/useStickyClusterId'; import { UserMenuNav } from 'teleport/components/UserMenuNav'; import { useFeatures } from 'teleport/FeaturesContext'; - +import { NavigationCategory } from 'teleport/Navigation/categories'; +import useStickyClusterId from 'teleport/useStickyClusterId'; import cfg from 'teleport/config'; import { useLayout } from 'teleport/Main/LayoutContext'; -import { KeysEnum } from 'teleport/services/storageService'; import { getFirstRouteForCategory } from 'teleport/Navigation/Navigation'; -import ClusterSelector from './ClusterSelector'; import { Notifications } from './Notifications'; import { ButtonIconContainer } from './Shared'; +import logoLight from './logoLight.svg'; +import logoDark from './logoDark.svg'; const Assist = lazy(() => import('teleport/Assist')); -export function TopBar() { +const AccessManagementButton = ({ + iconOnly = false, + to, + selected, + ...props +}: { + iconOnly?: boolean; + to: string; + selected: boolean; +}) => { + return ( + + {iconOnly ? ( + + ) : ( + <> + + Access Management + + {!selected && ( + + p.theme.breakpoints.medium}px) { + display: none; + } + `} + color="text.muted" + /> + )} + + )} + + ); +}; + +export function TopBar({ CustomLogo }: TopBarProps) { const ctx = useTeleport(); + const { clusterId } = useStickyClusterId(); const history = useHistory(); const features = useFeatures(); - const assistEnabled = ctx.getFeatureFlags().assist && ctx.assistEnabled; + const topBarLinks = features.filter( + feature => + feature.category === NavigationCategory.Resources && feature.topMenuItem + ); const [showAssist, setShowAssist] = useState(false); - const { clusterId, hasClusterUrl } = useStickyClusterId(); - const { hasDockedElement } = useLayout(); - function loadClusters() { - return ctx.clusterService.fetchClusters(); - } - - function changeCluster(value: string) { - const newPrefix = cfg.getClusterRoute(value); - - const oldPrefix = cfg.getClusterRoute(clusterId); - - const newPath = history.location.pathname.replace(oldPrefix, newPrefix); - - // TODO (avatus) DELETE IN 15 (LEGACY RESOURCES SUPPORT) - // this is a temporary hack to support leaf clusters _maybe_ not having access - // to unified resources yet. When unified resources are loaded in fetchUnifiedResources, - // if the response is a 404 (the endpoint doesnt exist), we: - // 1. push them to the servers page (old default) - // 2. set this variable conditionally render the "legacy" navigation - // When we switch clusters (to leaf or root), we remove the item and perform the check again by pushing - // to the resource (new default view). - window.localStorage.removeItem(KeysEnum.UNIFIED_RESOURCES_NOT_SUPPORTED); - // we also need to reset the pinned resources flag when we switch clusters to try again - window.localStorage.removeItem(KeysEnum.PINNED_RESOURCES_NOT_SUPPORTED); - const legacyResourceRoutes = [ - cfg.getNodesRoute(clusterId), - cfg.getAppsRoute(clusterId), - cfg.getKubernetesRoute(clusterId), - cfg.getDatabasesRoute(clusterId), - cfg.getDesktopsRoute(clusterId), - ]; - - if ( - legacyResourceRoutes.some(route => - history.location.pathname.includes(route) - ) - ) { - const unifiedPath = cfg - .getUnifiedResourcesRoute(clusterId) - .replace(oldPrefix, newPrefix); - - history.replace(unifiedPath); - return; - } - - // keep current view just change the clusterId - history.push(newPath); - } - // find active feature const feature = features .filter(feature => Boolean(feature.route)) @@ -122,37 +126,73 @@ export function TopBar() { history.push(firstRouteForCategory); } - const title = feature?.route?.title || ''; - - // instead of re-creating an expensive react-select component, - // hide/show it instead - const styles = { - display: !hasClusterUrl ? 'none' : 'block', - }; - return ( - {feature?.hideNavigation && ( + {feature?.hideNavigation ? ( + ) : ( + <> + + + p.theme.breakpoints.medium}px) { + display: block; + } + `} + selected={feature?.category === NavigationCategory.Management} + to={getFirstRouteForCategory( + features, + NavigationCategory.Management + )} + /> + )} - {!hasClusterUrl && ( - - {title} - - )} - - + + {!feature?.hideNavigation && ( + <> + + p.theme.breakpoints.medium}px) { + display: none; + } + `} + iconOnly={true} + selected={feature?.category === NavigationCategory.Management} + to={getFirstRouteForCategory( + features, + NavigationCategory.Management + )} + /> + {topBarLinks.map(({ topMenuItem, navigationItem }) => { + const selected = history.location.pathname.includes( + navigationItem.getLink(clusterId) + ); + return ( + + + + ); + })} + + )} {!hasDockedElement && assistEnabled && ( setShowAssist(true)}> @@ -172,10 +212,151 @@ export function TopBar() { } export const TopBarContainer = styled(TopNav)` - height: 72px; - background-color: inherit; - padding-left: ${p => `${p.theme.space[p.navigationHidden ? 2 : 6]}px`}; + position: absolute; + width: 100%; + background: ${p => p.theme.colors.levels.surface}; overflow-y: initial; flex-shrink: 0; + z-index: 10; border-bottom: 1px solid ${({ theme }) => theme.colors.spotBackground[0]}; + + height: ${p => p.theme.topBarHeight[0]}px; + @media screen and (min-width: ${p => p.theme.breakpoints.small}px) { + height: ${p => p.theme.topBarHeight[1]}px; + } + @media screen and (min-width: ${p => p.theme.breakpoints.large}px) { + height: ${p => p.theme.topBarHeight[2]}px; + } + + box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.12), + 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.2); `; + +const TeleportLogo = ({ CustomLogo }: TopBarProps) => { + const theme = useTheme(); + + return ( + + p.theme.breakpoints.medium}px) { + width: 256px; + } + + transition: background-color 0.1s linear; + &:hover { + background-color: ${p => + p.theme.colors.interactive.tonal.primary[0]}; + } + align-items: center; + `} + to={cfg.routes.root} + > + {CustomLogo ? ( + + ) : ( + teleport logo props.theme.space[4]}px; + height: 26px; + @media screen and (min-width: ${p => + p.theme.breakpoints.small}px) { + height: 28px; + } + @media screen and (min-width: ${p => + p.theme.breakpoints.large}px) { + height: 30px; + } + `} + /> + )} + + + ); +}; + +const NavigationButton = ({ + to, + selected, + children, + title, + ...props +}: { + to: string; + selected: boolean; + children: React.ReactNode; + title: string; +}) => { + const theme = useTheme(); + const selectedBorder = `2px solid ${theme.colors.brand}`; + const selectedBackground = theme.colors.interactive.tonal.primary[0]; + + return ( + + p.theme.breakpoints.large}px) { + padding-left: 24px; + padding-right: 24px; + } + border-bottom: ${selected ? selectedBorder : 'none'}; + background-color: ${selected ? selectedBackground : 'inherit'}; + &:hover { + background-color: ${selected + ? selectedBackground + : theme.colors.buttons.secondary.default}; + } + `} + {...props} + > + + {children} + + + + ); +}; + +export type NavigationItem = { + title: string; + path: string; + Icon: JSX.Element; +}; + +export type TopBarProps = { + CustomLogo?: () => React.ReactElement; + showPoweredByLogo?: boolean; +}; diff --git a/web/packages/teleport/src/TopBar/logoDark.svg b/web/packages/teleport/src/TopBar/logoDark.svg new file mode 100644 index 0000000000000..1ed6d944a3875 --- /dev/null +++ b/web/packages/teleport/src/TopBar/logoDark.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/packages/teleport/src/TopBar/logoLight.svg b/web/packages/teleport/src/TopBar/logoLight.svg new file mode 100644 index 0000000000000..70dac68db813b --- /dev/null +++ b/web/packages/teleport/src/TopBar/logoLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx index 2e0a95139cc1a..e7d5b0e0e9876 100644 --- a/web/packages/teleport/src/components/Dropdown/Dropdown.tsx +++ b/web/packages/teleport/src/components/Dropdown/Dropdown.tsx @@ -35,7 +35,7 @@ export const Dropdown = styled.div` box-shadow: ${({ theme }) => theme.boxShadow[1]}; border-radius: ${p => p.theme.radii[2]}px; width: 265px; - right: 0; + right: 20px; top: 43px; z-index: 999; opacity: ${p => (p.open ? 1 : 0)}; diff --git a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx index 75c309e9a6785..b680bd27f53ac 100644 --- a/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx +++ b/web/packages/teleport/src/components/UserMenuNav/UserMenuNav.tsx @@ -46,21 +46,22 @@ interface UserMenuNavProps { const Container = styled.div` position: relative; align-self: center; - margin-right: 30px; + padding-left: ${props => props.theme.space[4]}px; + padding-right: ${props => props.theme.space[4]}px; + &:hover { + background: ${props => props.theme.colors.spotBackground[0]}; + } + height: 100%; `; const UserInfo = styled.div` + height: 100%; display: flex; align-items: center; - padding: 8px; border-radius: 5px; cursor: pointer; user-select: none; position: relative; - - &:hover { - background: ${props => props.theme.colors.spotBackground[0]}; - } `; const Username = styled(Text)` @@ -68,6 +69,11 @@ const Username = styled(Text)` font-size: 14px; font-weight: 400; padding-right: 40px; + margin-left: 16px; + display: none; + @media screen and (min-width: ${p => p.theme.breakpoints.medium}px) { + display: inline-flex; + } `; const StyledAvatar = styled.div` @@ -80,7 +86,6 @@ const StyledAvatar = styled.div` font-weight: bold; justify-content: center; height: 32px; - margin-right: 16px; width: 100%; max-width: 32px; min-width: 32px; @@ -97,6 +102,11 @@ const Arrow = styled.div` transform: ${p => (p.open ? 'rotate(-180deg)' : 'none')}; transition: 0.1s linear transform; } + + display: none; + @media screen and (min-width: ${p => p.theme.breakpoints.medium}px) { + display: inline-flex; + } `; export function UserMenuNav({ username }: UserMenuNavProps) { @@ -136,7 +146,7 @@ export function UserMenuNav({ username }: UserMenuNavProps) { to={item.topMenuItem.getLink(clusterId)} onClick={() => setOpen(false)} > - {item.topMenuItem.icon} + {} {item.topMenuItem.title} diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 8bb3ba849331e..94e9bafb7bd13 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -100,12 +100,14 @@ class AccessRequests implements TeleportFeature { navigationItem = { title: NavTitle.AccessRequests, - icon: , + icon: EqualizersVertical, exact: true, getLink() { return cfg.routes.accessRequest; }, }; + + topMenuItem = this.navigationItem; } export class FeatureNodes implements TeleportFeature { @@ -118,7 +120,7 @@ export class FeatureNodes implements TeleportFeature { navigationItem = { title: NavTitle.Servers, - icon: , + icon: Server, exact: true, getLink(clusterId: string) { return cfg.getNodesRoute(clusterId); @@ -144,7 +146,7 @@ export class FeatureUnifiedResources implements TeleportFeature { navigationItem = { title: NavTitle.Resources, - icon: , + icon: Server, exact: true, getLink(clusterId: string) { return cfg.getUnifiedResourcesRoute(clusterId); @@ -178,7 +180,7 @@ export class FeatureApps implements TeleportFeature { navigationItem = { title: NavTitle.Applications, - icon: , + icon: Application, exact: true, getLink(clusterId: string) { return cfg.getAppsRoute(clusterId); @@ -204,7 +206,7 @@ export class FeatureKubes implements TeleportFeature { navigationItem = { title: NavTitle.Kubernetes, - icon: , + icon: Kubernetes, exact: true, getLink(clusterId: string) { return cfg.getKubernetesRoute(clusterId); @@ -230,7 +232,7 @@ export class FeatureDatabases implements TeleportFeature { navigationItem = { title: NavTitle.Databases, - icon: , + icon: Database, exact: true, getLink(clusterId: string) { return cfg.getDatabasesRoute(clusterId); @@ -256,7 +258,7 @@ export class FeatureDesktops implements TeleportFeature { navigationItem = { title: NavTitle.Desktops, - icon: , + icon: Desktop, exact: true, getLink(clusterId: string) { return cfg.getDesktopsRoute(clusterId); @@ -280,12 +282,13 @@ export class FeatureSessions implements TeleportFeature { navigationItem = { title: NavTitle.ActiveSessions, - icon: , + icon: Terminal, exact: true, getLink(clusterId: string) { return cfg.getSessionsRoute(clusterId); }, }; + topMenuItem = this.navigationItem; } // **************************** @@ -311,7 +314,7 @@ export class FeatureUsers implements TeleportFeature { navigationItem = { title: NavTitle.Users, - icon: , + icon: UsersIcon, exact: true, getLink() { return cfg.getUsersRoute(); @@ -340,7 +343,7 @@ export class FeatureRoles implements TeleportFeature { navigationItem = { title: NavTitle.Roles, - icon: , + icon: ClipboardUser, exact: true, getLink() { return cfg.routes.roles; @@ -365,7 +368,7 @@ export class FeatureAuthConnectors implements TeleportFeature { navigationItem = { title: NavTitle.AuthConnectors, - icon: , + icon: ShieldCheck, exact: false, getLink() { return cfg.routes.sso; @@ -390,7 +393,7 @@ export class FeatureLocks implements TeleportFeature { navigationItem = { title: NavTitle.SessionAndIdentityLocks, - icon: , + icon: Lock, exact: false, getLink() { return cfg.getLocksRoute(); @@ -427,7 +430,7 @@ export class FeatureDiscover implements TeleportFeature { navigationItem = { title: NavTitle.EnrollNewResource, - icon: , + icon: AddCircle, exact: true, getLink() { return cfg.routes.discover; @@ -463,7 +466,7 @@ export class FeatureIntegrations implements TeleportFeature { navigationItem = { title: NavTitle.Integrations, - icon: , + icon: IntegrationsIcon, exact: true, getLink() { return cfg.routes.integrations; @@ -492,7 +495,7 @@ export class FeatureIntegrationEnroll implements TeleportFeature { navigationItem = { title: NavTitle.EnrollNewIntegration, - icon: , + icon: AddCircle, getLink() { return cfg.getIntegrationEnrollRoute(null); }, @@ -524,7 +527,7 @@ export class FeatureRecordings implements TeleportFeature { navigationItem = { title: NavTitle.SessionRecordings, - icon: , + icon: CirclePlay, exact: true, getLink(clusterId: string) { return cfg.getRecordingsRoute(clusterId); @@ -548,7 +551,7 @@ export class FeatureAudit implements TeleportFeature { navigationItem = { title: NavTitle.AuditLog, - icon: , + icon: ListThin, getLink(clusterId: string) { return cfg.getAuditRoute(clusterId); }, @@ -574,7 +577,7 @@ export class FeatureClusters implements TeleportFeature { navigationItem = { title: NavTitle.ManageClusters, - icon: , + icon: SlidersVertical, exact: false, getLink() { return cfg.routes.clusters; @@ -598,7 +601,7 @@ export class FeatureTrust implements TeleportFeature { navigationItem = { title: NavTitle.TrustedClusters, - icon: , + icon: Cluster, getLink() { return cfg.routes.trustedClusters; }, @@ -621,7 +624,7 @@ class FeatureDeviceTrust implements TeleportFeature { navigationItem = { title: NavTitle.TrustedDevices, - icon: , + icon: Laptop, exact: true, getLink() { return cfg.routes.deviceTrust; @@ -646,7 +649,7 @@ export class FeatureAccount implements TeleportFeature { topMenuItem = { title: NavTitle.AccountSettings, - icon: , + icon: UserCircleGear, getLink() { return cfg.routes.account; }, @@ -667,7 +670,7 @@ export class FeatureHelpAndSupport implements TeleportFeature { topMenuItem = { title: NavTitle.HelpAndSupport, - icon: , + icon: Question, exact: true, getLink() { return cfg.routes.support; diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 134332891bf21..3e367091d9d00 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -32,7 +32,7 @@ export interface Context { export interface TeleportFeatureNavigationItem { title: NavTitle; - icon: React.ReactNode; + icon: (props) => JSX.Element; exact?: boolean; getLink?(clusterId: string): string; isExternalLink?: boolean;