diff --git a/web/packages/design/src/ResourceIcon/assets/laptop.svg b/web/packages/design/src/ResourceIcon/assets/laptop.svg new file mode 100644 index 0000000000000..ac0d8fe9b88a1 --- /dev/null +++ b/web/packages/design/src/ResourceIcon/assets/laptop.svg @@ -0,0 +1,310 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/packages/design/src/ResourceIcon/index.tsx b/web/packages/design/src/ResourceIcon/index.tsx index c0a9c54d89cb0..9c6eb43b48e0d 100644 --- a/web/packages/design/src/ResourceIcon/index.tsx +++ b/web/packages/design/src/ResourceIcon/index.tsx @@ -36,6 +36,7 @@ import gcp from './assets/gcp.svg'; import grafana from './assets/grafana.svg'; import jenkins from './assets/jenkins.svg'; import kube from './assets/kube.svg'; +import laptop from './assets/laptop.svg'; import linuxDark from './assets/linux-dark.svg'; import linuxLight from './assets/linux-light.svg'; import mongoDark from './assets/mongo-dark.svg'; @@ -91,6 +92,7 @@ const iconSpecs = { Grafana: forAllThemes(grafana), Jenkins: forAllThemes(jenkins), Kube: forAllThemes(kube), + Laptop: forAllThemes(laptop), Linux: { dark: linuxDark, light: linuxLight }, Mongo: { dark: mongoDark, light: mongoLight }, MysqlLarge: { dark: mysqlLargeDark, light: mysqlLargeLight }, diff --git a/web/packages/teleport/src/Discover/Discover.test.tsx b/web/packages/teleport/src/Discover/Discover.test.tsx index ec46c1dac965e..d8e40b0860591 100644 --- a/web/packages/teleport/src/Discover/Discover.test.tsx +++ b/web/packages/teleport/src/Discover/Discover.test.tsx @@ -47,12 +47,18 @@ import { makeDefaultUserPreferences } from 'teleport/services/userPreferences/us import { ResourceKind } from './Shared'; +beforeEach(() => { + jest.restoreAllMocks(); +}); + type createProps = { initialEntry?: string; preferredResource?: ClusterResource; }; const create = ({ initialEntry = '', preferredResource }: createProps) => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Macintosh'); + const defaultPref = makeDefaultUserPreferences(); defaultPref.onboard.preferredResources = preferredResource ? [preferredResource] @@ -83,9 +89,11 @@ const create = ({ initialEntry = '', preferredResource }: createProps) => { test('displays all resources by default', () => { create({}); - expect(screen.getAllByTestId(ResourceKind.Server)).toHaveLength( - SERVERS.length - ); + expect( + screen.getAllByTestId(ResourceKind.Server) + // TODO(ravicious): Uncomment for v14.2. + // .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + ).toHaveLength(SERVERS.length); expect(screen.getAllByTestId(ResourceKind.Desktop)).toHaveLength( WINDOWS_DESKTOPS.length ); @@ -122,9 +130,11 @@ describe('location state', () => { test('displays servers when the location state is server', () => { create({ initialEntry: 'server' }); - expect(screen.getAllByTestId(ResourceKind.Server)).toHaveLength( - SERVERS.length - ); + expect( + screen.getAllByTestId(ResourceKind.Server) + // TODO(ravicious): Uncomment for v14.2. + // .concat(screen.getAllByTestId(ResourceKind.ConnectMyComputer)) + ).toHaveLength(SERVERS.length); // we assert three databases for servers because the naming convention includes "server" expect(screen.queryAllByTestId(ResourceKind.Database)).toHaveLength(3); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx index 7af80e6654063..e9843681cf04d 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.test.tsx @@ -22,10 +22,12 @@ import { ClusterResource, OnboardUserPreferences, } from 'teleport/services/userPreferences/types'; +import { OnboardDiscover } from 'teleport/services/user'; import { ResourceKind } from '../Shared'; +import { resourceKindToPreferredResource } from '../Shared/ResourceKind'; -import { sortResources } from './SelectResource'; +import { filterResources, sortResources } from './SelectResource'; import { ResourceSpec } from './types'; const setUp = () => { @@ -50,6 +52,25 @@ const makeResourceSpec = ( ); }; +/** + * If the user has resources, Connect My Computer is not prioritized when sorting resources. + */ +const onboardDiscoverWithResources: OnboardDiscover = { + hasResource: true, + notified: true, + hasVisited: true, +}; +/** + * If the user does not have resources, Connect My Computer is prioritized as long as it was not + * filtered out based on supported platforms and auth types and the user either has no preferences + * or prefers servers. + */ +const onboardDiscoverNoResources: OnboardDiscover = { + hasResource: false, + notified: true, + hasVisited: false, +}; + test('sortResources without preferred resources, sorts resources alphabetically with guided resources first', () => { setUp(); const mockIn: ResourceSpec[] = [ @@ -64,7 +85,11 @@ test('sortResources without preferred resources, sorts resources alphabetically makeResourceSpec({ name: 'costco' }), ]; - const actual = sortResources(mockIn, makeDefaultUserPreferences()); + const actual = sortResources( + mockIn, + makeDefaultUserPreferences(), + onboardDiscoverWithResources + ); expect(actual).toMatchObject([ // guided and alpha @@ -319,7 +344,11 @@ describe('preferred resources', () => { test.each(testCases)('$name', testCase => { const preferences = makeDefaultUserPreferences(); preferences.onboard.preferredResources = testCase.preferred; - const actual = sortResources(kindBasedList, preferences); + const actual = sortResources( + kindBasedList, + preferences, + onboardDiscoverWithResources + ); expect(actual).toMatchObject(testCase.expected); }); @@ -520,7 +549,11 @@ describe('marketing params', () => { test.each(testCases)('$name', testCase => { const preferences = makeDefaultUserPreferences(); preferences.onboard = testCase.preferred; - const actual = sortResources(kindBasedList, preferences); + const actual = sortResources( + kindBasedList, + preferences, + onboardDiscoverWithResources + ); expect(actual).toMatchObject(testCase.expected); }); @@ -660,7 +693,11 @@ describe('os sorted resources', () => { test.each(testCases)('$name', testCase => { OS.mockReturnValue(testCase.userAgent); - const actual = sortResources(osBasedList, makeDefaultUserPreferences()); + const actual = sortResources( + osBasedList, + makeDefaultUserPreferences(), + onboardDiscoverWithResources + ); expect(actual).toMatchObject(testCase.expected); }); @@ -675,7 +712,11 @@ describe('os sorted resources', () => { ]; OS.mockReturnValue(UserAgent.macOS); - const actual = sortResources(mockIn, makeDefaultUserPreferences()); + const actual = sortResources( + mockIn, + makeDefaultUserPreferences(), + onboardDiscoverWithResources + ); expect(actual).toMatchObject([ makeResourceSpec({ name: 'Aaaa' }), makeResourceSpec({ @@ -707,7 +748,9 @@ describe('os sorted resources', () => { OS.mockReturnValue(UserAgent.macOS); const preferences = makeDefaultUserPreferences(); preferences.onboard = { - preferredResources: [2], + preferredResources: [ + resourceKindToPreferredResource(ResourceKind.Server), + ], marketingParams: { campaign: '', source: '', @@ -716,7 +759,11 @@ describe('os sorted resources', () => { }, }; - const actual = sortResources(oneOfEachList, preferences); + const actual = sortResources( + oneOfEachList, + preferences, + onboardDiscoverWithResources + ); expect(actual).toMatchObject([ // 1. OS makeResourceSpec({ @@ -740,3 +787,414 @@ describe('os sorted resources', () => { ]); }); }); + +describe('sorting Connect My Computer', () => { + let OS: jest.SpyInstance; + + beforeEach(() => { + OS = jest.spyOn(window.navigator, 'userAgent', 'get'); + }); + + const connectMyComputer = makeResourceSpec({ + kind: ResourceKind.ConnectMyComputer, + name: 'Connect My Computer', + }); + const noAccessServerForMatchingPlatform = makeResourceSpec({ + name: 'no access but platform matches', + hasAccess: false, + platform: Platform.macOS, + kind: ResourceKind.Server, + }); + const guidedA = makeResourceSpec({ name: 'guided' }); + const guidedB = makeResourceSpec({ name: 'guidedB' }); + const unguidedA = makeResourceSpec({ + name: 'unguidedA', + unguidedLink: 'test.com', + }); + const unguidedB = makeResourceSpec({ + name: 'unguidedB', + unguidedLink: 'test.com', + }); + const platformMatch = makeResourceSpec({ + name: 'platform match', + platform: Platform.macOS, + }); + const server = makeResourceSpec({ + name: 'server', + kind: ResourceKind.Server, + }); + + const oneOfEachList = [ + noAccessServerForMatchingPlatform, + guidedB, + unguidedB, + guidedA, + unguidedA, + platformMatch, + server, + connectMyComputer, + ]; + + describe('prioritizing Connect My Computer', () => { + it('puts the Connect My Computer resource as the first resource if the user has no preferences', () => { + OS.mockReturnValue(UserAgent.macOS); + + const actual = sortResources( + oneOfEachList, + makeDefaultUserPreferences(), + onboardDiscoverNoResources + ); + + expect(actual).toMatchObject([ + // 1. Connect My Computer + connectMyComputer, + // 2. OS + platformMatch, + // 3. guided + guidedA, + guidedB, + server, + // 4. alpha + unguidedA, + unguidedB, + // 5. no access + noAccessServerForMatchingPlatform, + ]); + }); + + it('puts the Connect My Computer resource as the first resource if the user prefers servers', () => { + OS.mockReturnValue(UserAgent.macOS); + + const preferences = makeDefaultUserPreferences(); + preferences.onboard = { + preferredResources: [ + resourceKindToPreferredResource(ResourceKind.Server), + ], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; + + const actual = sortResources( + oneOfEachList, + preferences, + onboardDiscoverNoResources + ); + + expect(actual).toMatchObject([ + // 1. Connect My Computer + connectMyComputer, + // 2. OS + platformMatch, + // 3. preferred + server, + // 4. guided + guidedA, + guidedB, + // 5. alpha + unguidedA, + unguidedB, + // 6. no access is last + noAccessServerForMatchingPlatform, + ]); + }); + + it('deprioritizes other server tiles of the matching platform within the guided resources if the user does not prefer servers', () => { + OS.mockReturnValue(UserAgent.macOS); + + const guidedServerForMatchingPlatformA = makeResourceSpec({ + name: 'guided server for matching platform A', + kind: ResourceKind.Server, + platform: Platform.macOS, + }); + const guidedServerForMatchingPlatformB = makeResourceSpec({ + name: 'guided server for matching platform B', + kind: ResourceKind.Server, + platform: Platform.macOS, + }); + const guidedServerForAnotherPlatform = makeResourceSpec({ + name: 'guided server for another platform', + kind: ResourceKind.Server, + platform: Platform.Linux, + }); + + const actual = sortResources( + [ + unguidedA, + guidedServerForMatchingPlatformB, + guidedServerForMatchingPlatformA, + guidedServerForAnotherPlatform, + connectMyComputer, + ], + makeDefaultUserPreferences(), + onboardDiscoverNoResources + ); + + expect(actual).toMatchObject([ + connectMyComputer, + guidedServerForAnotherPlatform, + guidedServerForMatchingPlatformA, + guidedServerForMatchingPlatformB, + unguidedA, + ]); + }); + + it('does not deprioritize server tiles of the matching platform if the user prefers servers,', () => { + OS.mockReturnValue(UserAgent.macOS); + + const guidedServerForMatchingPlatformA = makeResourceSpec({ + name: 'guided server for matching platform A', + kind: ResourceKind.Server, + platform: Platform.macOS, + }); + const guidedServerForMatchingPlatformB = makeResourceSpec({ + name: 'guided server for matching platform B', + kind: ResourceKind.Server, + platform: Platform.macOS, + }); + const guidedServerForAnotherPlatform = makeResourceSpec({ + name: 'guided server for another platform', + kind: ResourceKind.Server, + platform: Platform.Linux, + }); + + const preferences = makeDefaultUserPreferences(); + preferences.onboard = { + preferredResources: [ + resourceKindToPreferredResource(ResourceKind.Server), + ], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; + + const actual = sortResources( + [ + unguidedA, + guidedServerForMatchingPlatformB, + guidedServerForMatchingPlatformA, + guidedServerForAnotherPlatform, + connectMyComputer, + ], + preferences, + onboardDiscoverNoResources + ); + + expect(actual).toMatchObject([ + connectMyComputer, + guidedServerForMatchingPlatformA, + guidedServerForMatchingPlatformB, + guidedServerForAnotherPlatform, + unguidedA, + ]); + }); + }); + + describe('deprioritizing Connect My Computer', () => { + it('puts the Connect My Computer resource as the last guided resource if the user has resources', () => { + OS.mockReturnValue(UserAgent.macOS); + + const actual = sortResources( + oneOfEachList, + makeDefaultUserPreferences(), + onboardDiscoverWithResources + ); + + expect(actual).toMatchObject([ + // 1. OS + platformMatch, + // 2. guided + guidedA, + guidedB, + server, + // 3. Connect My Computer + connectMyComputer, + // 4. alpha + unguidedA, + unguidedB, + // 5. no access + noAccessServerForMatchingPlatform, + ]); + }); + + it('puts the Connect My Computer resource as the last guided resource if the user has resources, even if the user prefers servers', () => { + OS.mockReturnValue(UserAgent.macOS); + + const preferences = makeDefaultUserPreferences(); + preferences.onboard = { + preferredResources: [ + resourceKindToPreferredResource(ResourceKind.Server), + ], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; + + const actual = sortResources( + oneOfEachList, + preferences, + onboardDiscoverWithResources + ); + + expect(actual).toMatchObject([ + // 1. OS + platformMatch, + // 2. preferred + server, + // 2. guided + guidedA, + guidedB, + // 3. Connect My Computer, + connectMyComputer, + // 4. alpha + unguidedA, + unguidedB, + // 6. no access is last + noAccessServerForMatchingPlatform, + ]); + }); + + it('puts the Connect My Computer resource as the last guided resource if the user has no resources but they prefer other resources than servers', () => { + OS.mockReturnValue(UserAgent.macOS); + + const databaseForAnotherPlatform = makeResourceSpec({ + name: 'database for another platform', + kind: ResourceKind.Database, + platform: Platform.Linux, + }); + + const preferences = makeDefaultUserPreferences(); + preferences.onboard = { + preferredResources: [ + resourceKindToPreferredResource(ResourceKind.Database), + ], + marketingParams: { + campaign: '', + source: '', + medium: '', + intent: '', + }, + }; + + const actual = sortResources( + [...oneOfEachList, databaseForAnotherPlatform], + preferences, + onboardDiscoverNoResources + ); + + expect(actual).toMatchObject([ + // 1. OS + platformMatch, + // 2. preferred + databaseForAnotherPlatform, + // 2. guided + guidedA, + guidedB, + server, + // 3. Connect My Computer, + connectMyComputer, + // 4. alpha + unguidedA, + unguidedB, + // 6. no access is last + noAccessServerForMatchingPlatform, + ]); + }); + }); +}); + +describe('filterResources', () => { + it('filters out resources based on supportedPlatforms', () => { + const winAndLinux = makeResourceSpec({ + name: 'Filtered out with many supported platforms', + supportedPlatforms: [Platform.Windows, Platform.Linux], + }); + const win = makeResourceSpec({ + name: 'Filtered out with one supported platform', + supportedPlatforms: [Platform.Windows], + }); + const macosAndLinux = makeResourceSpec({ + name: 'Kept with many supported platforms', + supportedPlatforms: [Platform.macOS, Platform.Linux], + }); + const macos = makeResourceSpec({ + name: 'Kept with one supported platform', + supportedPlatforms: [Platform.macOS], + }); + + const result = filterResources(Platform.macOS, 'local', [ + winAndLinux, + win, + macosAndLinux, + macos, + ]); + + expect(result).toContain(macosAndLinux); + expect(result).toContain(macos); + expect(result).not.toContain(winAndLinux); + expect(result).not.toContain(win); + }); + + it('does not filter out resources with supportedPlatforms and supportedAuthTypes that are missing or empty', () => { + const result = filterResources(Platform.macOS, 'local', [ + makeResourceSpec({ + name: 'Empty supportedPlatforms', + supportedPlatforms: [], + }), + makeResourceSpec({ + name: 'Missing supportedPlatforms', + supportedPlatforms: undefined, + }), + makeResourceSpec({ + name: 'Empty supportedAuthTypes', + supportedAuthTypes: [], + }), + makeResourceSpec({ + name: 'Missing supportedAuthTypes', + supportedAuthTypes: undefined, + }), + ]); + + expect(result).toHaveLength(4); + }); + + it('filters out resources based on supportedAuthTypes', () => { + const ssoAndPasswordless = makeResourceSpec({ + name: 'Filtered out with many supported auth types', + supportedAuthTypes: ['sso', 'passwordless'], + }); + const sso = makeResourceSpec({ + name: 'Filtered out with one supported auth type', + supportedAuthTypes: ['sso'], + }); + const localAndPasswordless = makeResourceSpec({ + name: 'Kept with many supported auth types', + supportedAuthTypes: ['local', 'passwordless'], + }); + const local = makeResourceSpec({ + name: 'Kept with one supported auth type', + supportedAuthTypes: ['local'], + }); + + const result = filterResources(Platform.macOS, 'local', [ + ssoAndPasswordless, + sso, + localAndPasswordless, + local, + ]); + + expect(result).toContain(localAndPasswordless); + expect(result).toContain(local); + expect(result).not.toContain(ssoAndPasswordless); + expect(result).not.toContain(sso); + }); +}); diff --git a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx index 3698219ce7bb3..d7016c93edf33 100644 --- a/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/SelectResource.tsx @@ -20,12 +20,11 @@ import { useHistory, useLocation } from 'react-router'; import * as Icons from 'design/Icon'; import styled from 'styled-components'; import { Box, Flex, Link, Text } from 'design'; - -import { getPlatformType, Platform } from 'design/platform'; +import { getPlatform, Platform } from 'design/platform'; import useTeleport from 'teleport/useTeleport'; import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge'; -import { Acl } from 'teleport/services/user'; +import { Acl, AuthType, OnboardDiscover } from 'teleport/services/user'; import { Header, HeaderSubtitle, @@ -38,6 +37,7 @@ import { } from 'teleport/Discover/SelectResource/resources'; import AddApp from 'teleport/Apps/AddApp'; import { useUser } from 'teleport/User/UserContext'; +import localStorage from 'teleport/services/localStorage'; import { ClusterResource, @@ -94,11 +94,18 @@ export function SelectResource({ onSelect }: SelectResourceProps) { useEffect(() => { // Apply access check to each resource. const userContext = ctx.storeUser.state; - const { acl } = userContext; + const { acl, authType } = userContext; + const platform = getPlatform(); + const resources = addHasAccessField( + acl, + filterResources(platform, authType, RESOURCES) + ); + const onboardDiscover = localStorage.getOnboardDiscover(); const sortedResources = sortResources( - makeResourcesWithHasAccessField(acl), - preferences + resources, + preferences, + onboardDiscover ); setDefaultResources(sortedResources); @@ -301,6 +308,10 @@ function checkHasAccess(acl: Acl, resourceKind: ResourceKind) { return acl.nodes.list; case ResourceKind.SamlApplication: return acl.samlIdpServiceProvider.create; + case ResourceKind.ConnectMyComputer: + // This is probably already true since without this permission the user wouldn't be able to + // add any other resource, but let's just leave it for completeness sake. + return acl.tokens.create; default: return false; } @@ -346,67 +357,135 @@ function sortResourcesByKind( return sorted; } +const aBeforeB = -1; +const aAfterB = 1; +const aEqualsB = 0; + +/** + * Evaluates the predicate and prioritizes the element matching the predicate over the element that + * doesn't. + * + * @example + * comparePredicate({color: 'green'}, {color: 'red'}, (el) => el.color === 'green') // => -1 (a before b) + * comparePredicate({color: 'red'}, {color: 'green'}, (el) => el.color === 'green') // => 1 (a after b) + * comparePredicate({color: 'blue'}, {color: 'pink'}, (el) => el.color === 'green') // => 0 (both are equal) + */ +function comparePredicate( + a: ElementType, + b: ElementType, + predicate: (resource: ElementType) => boolean +): -1 | 0 | 1 { + const aMatches = predicate(a); + const bMatches = predicate(b); + + if (aMatches && !bMatches) { + return aBeforeB; + } + + if (bMatches && !aMatches) { + return aAfterB; + } + + return aEqualsB; +} + export function sortResources( resources: ResourceSpec[], - preferences: UserPreferences + preferences: UserPreferences, + onboardDiscover: OnboardDiscover | undefined ) { const { preferredResources, hasPreferredResources } = getPrioritizedResources(preferences); + const platform = getPlatform(); const sortedResources = [...resources]; const accessible = sortedResources.filter(r => r.hasAccess); const restricted = sortedResources.filter(r => !r.hasAccess); - // Sort accessible resources by 1. os 2. preferred 3. guided and 4. alphabetically + const hasNoResources = onboardDiscover && !onboardDiscover.hasResource; + const prefersServers = + hasPreferredResources && + preferredResources.includes( + resourceKindToPreferredResource(ResourceKind.Server) + ); + const prefersServersOrNoPreferences = + prefersServers || !hasPreferredResources; + const shouldShowConnectMyComputerFirst = + hasNoResources && + prefersServersOrNoPreferences && + isConnectMyComputerAvailable(accessible); + + // Sort accessible resources by: + // 1. os + // 2. preferred + // 3. guided + // 4. alphabetically + // + // When available on the given platform, Connect My Computer is put either as the first resource + // if the user has no resources, otherwise it's at the end of the guided group. accessible.sort((a, b) => { - let aPreferred, - bPreferred = false; - if (hasPreferredResources) { - aPreferred = preferredResources.includes( - resourceKindToPreferredResource(a.kind) - ); - bPreferred = preferredResources.includes( - resourceKindToPreferredResource(b.kind) + const compareAB = (predicate: (r: ResourceSpec) => boolean) => + comparePredicate(a, b, predicate); + const areBothGuided = !a.unguidedLink && !b.unguidedLink; + + // Special cases for Connect My Computer. + // Show Connect My Computer tile as the first resource. + if (shouldShowConnectMyComputerFirst) { + const prioritizeConnectMyComputer = compareAB( + r => r.kind === ResourceKind.ConnectMyComputer ); - } + if (prioritizeConnectMyComputer) { + return prioritizeConnectMyComputer; + } - let platform: string; - const platformType = getPlatformType(); - if (platformType.isMac) { - platform = Platform.macOS; - } - if (platformType.isLinux) { - platform = Platform.Linux; - } - if (platformType.isWin) { - platform = Platform.Windows; + // Within the guided group, deprioritize server tiles of the current user platform if Connect + // My Computer is available. + // + // If the user has no resources available in the cluster, we want to nudge them towards + // Connect My Computer rather than, say, standalone macOS setup. + // + // Only do this if the user doesn't explicitly prefer servers. If they prefer servers, we + // want the servers for their platform to be displayed in their usual place so that the user + // doesn't miss that Teleport supports them. + if (!prefersServers && areBothGuided) { + const deprioritizeServerForUserPlatform = compareAB( + r => !(r.kind == ResourceKind.Server && r.platform === platform) + ); + if (deprioritizeServerForUserPlatform) { + return deprioritizeServerForUserPlatform; + } + } + } else if (areBothGuided) { + // Show Connect My Computer tile as the last guided resource if the user already added some + // resources or they prefer other kinds of resources than servers. + const deprioritizeConnectMyComputer = compareAB( + r => r.kind !== ResourceKind.ConnectMyComputer + ); + if (deprioritizeConnectMyComputer) { + return deprioritizeConnectMyComputer; + } } // Display platform resources first - if (a.platform === platform && b.platform !== platform) { - return -1; - } - if (a.platform !== platform && b.platform === platform) { - return 1; + const prioritizeUserPlatform = compareAB(r => r.platform === platform); + if (prioritizeUserPlatform) { + return prioritizeUserPlatform; } // Display preferred resources second - if (aPreferred && !bPreferred) { - return -1; - } - if (!aPreferred && bPreferred) { - return 1; + if (hasPreferredResources) { + const prioritizePreferredResource = compareAB(r => + preferredResources.includes(resourceKindToPreferredResource(r.kind)) + ); + if (prioritizePreferredResource) { + return prioritizePreferredResource; + } } // Display guided resources third - if (!a.unguidedLink && !b.unguidedLink) { - return a.name.localeCompare(b.name); - } - if (!b.unguidedLink) { - return 1; - } - if (!a.unguidedLink) { - return -1; + const prioritizeGuided = compareAB(r => !r.unguidedLink); + if (prioritizeGuided) { + return prioritizeGuided; } // Alpha @@ -424,6 +503,14 @@ export function sortResources( return [...accessible, ...restricted]; } +function isConnectMyComputerAvailable( + accessibleResources: ResourceSpec[] +): boolean { + return !!accessibleResources.find( + resource => resource.kind === ResourceKind.ConnectMyComputer + ); +} + /** * Returns prioritized resources based on user preferences cluster state * @@ -464,8 +551,29 @@ function getPrioritizedResources( }; } -function makeResourcesWithHasAccessField(acl: Acl): ResourceSpec[] { - return RESOURCES.map(r => { +export function filterResources( + platform: Platform, + authType: AuthType, + resources: ResourceSpec[] +) { + return resources.filter(resource => { + const resourceSupportsPlatform = + !resource.supportedPlatforms?.length || + resource.supportedPlatforms.includes(platform); + + const resourceSupportsAuthType = + !resource.supportedAuthTypes?.length || + resource.supportedAuthTypes.includes(authType); + + return resourceSupportsPlatform && resourceSupportsAuthType; + }); +} + +function addHasAccessField( + acl: Acl, + resources: ResourceSpec[] +): ResourceSpec[] { + return resources.map(r => { const hasAccess = checkHasAccess(acl, r.kind); switch (r.kind) { case ResourceKind.Database: diff --git a/web/packages/teleport/src/Discover/SelectResource/resources.tsx b/web/packages/teleport/src/Discover/SelectResource/resources.tsx index 98fc78e6bcadc..d811ff38b906d 100644 --- a/web/packages/teleport/src/Discover/SelectResource/resources.tsx +++ b/web/packages/teleport/src/Discover/SelectResource/resources.tsx @@ -85,6 +85,16 @@ export const SERVERS: ResourceSpec[] = [ event: DiscoverEventResource.Ec2Instance, nodeMeta: { location: ServerLocation.Aws }, }, + // TODO(ravicious): Uncomment for v14.2. + // { + // name: 'Connect My Computer', + // kind: ResourceKind.ConnectMyComputer, + // keywords: baseServerKeywords + 'connect my computer', + // icon: 'Laptop', + // event: DiscoverEventResource.Server, + // supportedPlatforms: [Platform.macOS, Platform.Linux], + // supportedAuthTypes: ['local', 'passwordless'], + // }, ]; export const APPLICATIONS: ResourceSpec[] = [ diff --git a/web/packages/teleport/src/Discover/SelectResource/types.ts b/web/packages/teleport/src/Discover/SelectResource/types.ts index 10adfcd1b9af3..a46d192a5b86a 100644 --- a/web/packages/teleport/src/Discover/SelectResource/types.ts +++ b/web/packages/teleport/src/Discover/SelectResource/types.ts @@ -17,6 +17,7 @@ import { Platform } from 'design/platform'; import { ClusterResource } from 'teleport/services/userPreferences/types'; +import { AuthType } from 'teleport/services/user'; import { ResourceKind } from '../Shared/ResourceKind'; @@ -34,7 +35,7 @@ export enum DatabaseLocation { TODO, } -// DatabaseEngine represents the db "protocol". +/** DatabaseEngine represents the db "protocol". */ export enum DatabaseEngine { Postgres, AuroraPostgres, @@ -64,26 +65,52 @@ export interface ResourceSpec { popular?: boolean; kind: ResourceKind; icon: ResourceIconName; - // keywords are filter words that user may use to search for - // this resource. + /** + * keywords are filter words that user may use to search for + * this resource. + */ keywords: string; - // hasAccess is a flag to mean that user has - // the preliminary permissions to add this resource. + /** + * hasAccess is a flag to mean that user has + * the preliminary permissions to add this resource. + */ hasAccess?: boolean; - // unguidedLink is the link out to this resources documentation. - // It is used as a flag, that when defined, means that - // this resource is not "guided" (has no UI interactive flow). + /** + * unguidedLink is the link out to this resources documentation. + * It is used as a flag, that when defined, means that + * this resource is not "guided" (has no UI interactive flow). + */ unguidedLink?: string; - // isDialog indicates whether the flow for this resource is a popover dialog as opposed to a Discover flow. - // This is the case for the 'Application' resource. + /** + * isDialog indicates whether the flow for this resource is a popover dialog as opposed to a + * Discover flow. This is the case for the 'Application' resource. + */ isDialog?: boolean; - // event is the expected backend enum event name that describes - // the type of this resource (e.g. server v. kubernetes), - // used for usage reporting. + /** + * event is the expected backend enum event name that describes + * the type of this resource (e.g. server v. kubernetes), + * used for usage reporting. + */ event: DiscoverEventResource; - // platform indicates a particular platform the resource is associated with. - // Set this value if the resource should be prioritized based on the platform. + /** + * platform indicates a particular platform the resource is associated with. + * Set this value if the resource should be prioritized based on the platform. + */ platform?: Platform; + /** + * supportedPlatforms indicate particular platforms the resource is available on. The resource + * won't be displayed on unsupported platforms. + * + * An empty array or undefined means that the resource is supported on all platforms. + */ + supportedPlatforms?: Platform[]; + /** + * supportedAuthTypes indicate particular auth types that the resource is available for. The + * resource won't be displayed if the user logged in using an unsupported auth type. + * + * An empty array or undefined means that the resource supports all auth types. + */ + supportedAuthTypes?: AuthType[]; } export enum SearchResource { diff --git a/web/packages/teleport/src/Discover/Shared/ResourceKind.ts b/web/packages/teleport/src/Discover/Shared/ResourceKind.ts index 1c2cd4c77c3c4..bcd16560425fa 100644 --- a/web/packages/teleport/src/Discover/Shared/ResourceKind.ts +++ b/web/packages/teleport/src/Discover/Shared/ResourceKind.ts @@ -26,6 +26,7 @@ export enum ResourceKind { Server, SamlApplication, Discovery, + ConnectMyComputer, } export function resourceKindToJoinRole(kind: ResourceKind): JoinRole { @@ -42,6 +43,8 @@ export function resourceKindToJoinRole(kind: ResourceKind): JoinRole { return 'Node'; case ResourceKind.Discovery: return 'Discovery'; + case ResourceKind.ConnectMyComputer: + return 'Node'; } } @@ -61,5 +64,7 @@ export function resourceKindToPreferredResource( return ClusterResource.RESOURCE_SERVER_SSH; case ResourceKind.Discovery: return ClusterResource.RESOURCE_UNSPECIFIED; + case ResourceKind.ConnectMyComputer: + return ClusterResource.RESOURCE_SERVER_SSH; } }