mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-29 17:58:15 +08:00
Web: make SelectResource.tsx component leaner (#51698)
* Web: Move resource specs into resource directory * Web: move code out into separate files
This commit is contained in:
@@ -25,12 +25,10 @@ import cfg from 'teleport/config';
|
||||
import { Discover, DiscoverComponent } from 'teleport/Discover/Discover';
|
||||
import { ResourceViewConfig } from 'teleport/Discover/flow';
|
||||
import {
|
||||
APPLICATIONS,
|
||||
DATABASES,
|
||||
DATABASES_UNGUIDED,
|
||||
DATABASES_UNGUIDED_DOC,
|
||||
} from 'teleport/Discover/SelectResource/databases';
|
||||
import {
|
||||
APPLICATIONS,
|
||||
KUBERNETES,
|
||||
SERVERS,
|
||||
} from 'teleport/Discover/SelectResource/resources';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
IntegrationStatusCode,
|
||||
} from 'teleport/services/integrations';
|
||||
|
||||
import { DATABASES } from '../SelectResource/databases';
|
||||
import { DATABASES } from '../SelectResource/resources';
|
||||
import { ResourceKind } from '../Shared';
|
||||
import { TeleportProvider } from './fixtures';
|
||||
|
||||
|
||||
@@ -37,12 +37,10 @@ import * as userUserContext from 'teleport/User/UserContext';
|
||||
|
||||
import { ResourceKind } from '../Shared';
|
||||
import { resourceKindToPreferredResource } from '../Shared/ResourceKind';
|
||||
import {
|
||||
filterResources,
|
||||
SelectResource,
|
||||
sortResources,
|
||||
} from './SelectResource';
|
||||
import { SelectResource } from './SelectResource';
|
||||
import { ResourceSpec } from './types';
|
||||
import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters';
|
||||
import { sortResourcesByPreferences } from './utils/sort';
|
||||
|
||||
const setUp = () => {
|
||||
jest
|
||||
@@ -85,7 +83,7 @@ const onboardDiscoverNoResources: OnboardDiscover = {
|
||||
hasVisited: false,
|
||||
};
|
||||
|
||||
test('sortResources without preferred resources, sorts resources alphabetically with guided resources first', () => {
|
||||
test('sortResourcesByPreferences without preferred resources, sorts resources alphabetically with guided resources first', () => {
|
||||
setUp();
|
||||
const mockIn: ResourceSpec[] = [
|
||||
// unguided
|
||||
@@ -99,7 +97,7 @@ test('sortResources without preferred resources, sorts resources alphabetically
|
||||
makeResourceSpec({ name: 'costco' }),
|
||||
];
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
mockIn,
|
||||
makeDefaultUserPreferences(),
|
||||
onboardDiscoverWithResources
|
||||
@@ -358,7 +356,7 @@ describe('preferred resources', () => {
|
||||
test.each(testCases)('$name', testCase => {
|
||||
const preferences = makeDefaultUserPreferences();
|
||||
preferences.onboard.preferredResources = testCase.preferred;
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
kindBasedList,
|
||||
preferences,
|
||||
onboardDiscoverWithResources
|
||||
@@ -563,7 +561,7 @@ describe('marketing params', () => {
|
||||
test.each(testCases)('$name', testCase => {
|
||||
const preferences = makeDefaultUserPreferences();
|
||||
preferences.onboard = testCase.preferred;
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
kindBasedList,
|
||||
preferences,
|
||||
onboardDiscoverWithResources
|
||||
@@ -707,7 +705,7 @@ describe('os sorted resources', () => {
|
||||
test.each(testCases)('$name', testCase => {
|
||||
OS.mockReturnValue(testCase.userAgent);
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
osBasedList,
|
||||
makeDefaultUserPreferences(),
|
||||
onboardDiscoverWithResources
|
||||
@@ -726,7 +724,7 @@ describe('os sorted resources', () => {
|
||||
];
|
||||
OS.mockReturnValue(UserAgent.macOS);
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
mockIn,
|
||||
makeDefaultUserPreferences(),
|
||||
onboardDiscoverWithResources
|
||||
@@ -773,7 +771,7 @@ describe('os sorted resources', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
oneOfEachList,
|
||||
preferences,
|
||||
onboardDiscoverWithResources
|
||||
@@ -853,7 +851,7 @@ describe('sorting 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(
|
||||
const actual = sortResourcesByPreferences(
|
||||
oneOfEachList,
|
||||
makeDefaultUserPreferences(),
|
||||
onboardDiscoverNoResources
|
||||
@@ -892,7 +890,7 @@ describe('sorting Connect My Computer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
oneOfEachList,
|
||||
preferences,
|
||||
onboardDiscoverNoResources
|
||||
@@ -935,7 +933,7 @@ describe('sorting Connect My Computer', () => {
|
||||
platform: Platform.Linux,
|
||||
});
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
[
|
||||
unguidedA,
|
||||
guidedServerForMatchingPlatformB,
|
||||
@@ -988,7 +986,7 @@ describe('sorting Connect My Computer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
[
|
||||
unguidedA,
|
||||
guidedServerForMatchingPlatformB,
|
||||
@@ -1014,7 +1012,7 @@ describe('sorting 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(
|
||||
const actual = sortResourcesByPreferences(
|
||||
oneOfEachList,
|
||||
makeDefaultUserPreferences(),
|
||||
onboardDiscoverWithResources
|
||||
@@ -1053,7 +1051,7 @@ describe('sorting Connect My Computer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
oneOfEachList,
|
||||
preferences,
|
||||
onboardDiscoverWithResources
|
||||
@@ -1099,7 +1097,7 @@ describe('sorting Connect My Computer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const actual = sortResources(
|
||||
const actual = sortResourcesByPreferences(
|
||||
[...oneOfEachList, databaseForAnotherPlatform],
|
||||
preferences,
|
||||
onboardDiscoverNoResources
|
||||
@@ -1195,12 +1193,11 @@ describe('filterResources', () => {
|
||||
supportedPlatforms: [Platform.macOS],
|
||||
});
|
||||
|
||||
const result = filterResources(Platform.macOS, 'local', [
|
||||
winAndLinux,
|
||||
win,
|
||||
macosAndLinux,
|
||||
macos,
|
||||
]);
|
||||
const result = filterBySupportedPlatformsAndAuthTypes(
|
||||
Platform.macOS,
|
||||
'local',
|
||||
[winAndLinux, win, macosAndLinux, macos]
|
||||
);
|
||||
|
||||
expect(result).toContain(macosAndLinux);
|
||||
expect(result).toContain(macos);
|
||||
@@ -1209,24 +1206,28 @@ describe('filterResources', () => {
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
]);
|
||||
const result = filterBySupportedPlatformsAndAuthTypes(
|
||||
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);
|
||||
});
|
||||
@@ -1249,12 +1250,11 @@ describe('filterResources', () => {
|
||||
supportedAuthTypes: ['local'],
|
||||
});
|
||||
|
||||
const result = filterResources(Platform.macOS, 'local', [
|
||||
ssoAndPasswordless,
|
||||
sso,
|
||||
localAndPasswordless,
|
||||
local,
|
||||
]);
|
||||
const result = filterBySupportedPlatformsAndAuthTypes(
|
||||
Platform.macOS,
|
||||
'local',
|
||||
[ssoAndPasswordless, sso, localAndPasswordless, local]
|
||||
);
|
||||
|
||||
expect(result).toContain(localAndPasswordless);
|
||||
expect(result).toContain(local);
|
||||
|
||||
@@ -16,49 +16,29 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Alert, Box, Flex, Link, P3, Text } from 'design';
|
||||
import * as Icons from 'design/Icon';
|
||||
import { NewTab } from 'design/Icon';
|
||||
import { getPlatform, Platform } from 'design/platform';
|
||||
import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb';
|
||||
import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb';
|
||||
import { getPlatform } from 'design/platform';
|
||||
|
||||
import AddApp from 'teleport/Apps/AddApp';
|
||||
import { FeatureHeader, FeatureHeaderTitle } from 'teleport/components/Layout';
|
||||
import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge';
|
||||
import cfg from 'teleport/config';
|
||||
import {
|
||||
BASE_RESOURCES,
|
||||
getResourcePretitle,
|
||||
} from 'teleport/Discover/SelectResource/resources';
|
||||
import {
|
||||
HeaderSubtitle,
|
||||
PermissionsErrorMessage,
|
||||
ResourceKind,
|
||||
} from 'teleport/Discover/Shared';
|
||||
import { resourceKindToPreferredResource } from 'teleport/Discover/Shared/ResourceKind';
|
||||
import { BASE_RESOURCES } from 'teleport/Discover/SelectResource/resources';
|
||||
import { HeaderSubtitle } from 'teleport/Discover/Shared';
|
||||
import { storageService } from 'teleport/services/storageService';
|
||||
import { Acl, AuthType, OnboardDiscover } from 'teleport/services/user';
|
||||
import { useUser } from 'teleport/User/UserContext';
|
||||
import useTeleport from 'teleport/useTeleport';
|
||||
|
||||
import { getMarketingTermMatches } from './getMarketingTermMatches';
|
||||
import { DiscoverIcon } from './icons';
|
||||
import { SAML_APPLICATIONS } from './resourcesE';
|
||||
import {
|
||||
PrioritizedResources,
|
||||
SearchResource,
|
||||
type ResourceSpec,
|
||||
} from './types';
|
||||
import { SAML_APPLICATIONS } from './resources';
|
||||
import { Tile } from './Tile';
|
||||
import { SearchResource, type ResourceSpec } from './types';
|
||||
import { addHasAccessField } from './utils/checkAccess';
|
||||
import { filterBySupportedPlatformsAndAuthTypes } from './utils/filters';
|
||||
import { sortResourcesByKind, sortResourcesByPreferences } from './utils/sort';
|
||||
|
||||
interface SelectResourceProps {
|
||||
onSelect: (resource: ResourceSpec) => void;
|
||||
@@ -89,11 +69,11 @@ export function SelectResource({ onSelect }: SelectResourceProps) {
|
||||
const platform = getPlatform();
|
||||
const defaultResources: ResourceSpec[] = useMemo(
|
||||
() =>
|
||||
sortResources(
|
||||
sortResourcesByPreferences(
|
||||
// Apply access check to each resource.
|
||||
addHasAccessField(
|
||||
acl,
|
||||
filterResources(
|
||||
filterBySupportedPlatformsAndAuthTypes(
|
||||
platform,
|
||||
authType,
|
||||
getDefaultResources(cfg.isEnterprise)
|
||||
@@ -193,98 +173,15 @@ export function SelectResource({ onSelect }: SelectResourceProps) {
|
||||
{resources && resources.length > 0 && (
|
||||
<>
|
||||
<Grid role="grid">
|
||||
{resources.map((r, index) => {
|
||||
const title = r.name;
|
||||
const pretitle = getResourcePretitle(r);
|
||||
const select = () => {
|
||||
if (!r.hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowApp(true);
|
||||
onSelect(r);
|
||||
};
|
||||
|
||||
let resourceCardProps: ComponentPropsWithoutRef<
|
||||
'button' | typeof Link
|
||||
>;
|
||||
|
||||
if (r.kind === ResourceKind.Application && r.isDialog) {
|
||||
resourceCardProps = {
|
||||
onClick: select,
|
||||
onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(),
|
||||
role: 'button',
|
||||
};
|
||||
} else if (r.unguidedLink) {
|
||||
resourceCardProps = {
|
||||
as: Link,
|
||||
href: r.hasAccess ? r.unguidedLink : null,
|
||||
target: '_blank',
|
||||
style: { textDecoration: 'none' },
|
||||
role: 'link',
|
||||
};
|
||||
} else {
|
||||
resourceCardProps = {
|
||||
onClick: () => r.hasAccess && onSelect(r),
|
||||
onKeyUp: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && r.hasAccess) {
|
||||
onSelect(r);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
};
|
||||
}
|
||||
|
||||
// There can be three types of click behavior with the resource cards:
|
||||
// 1) If the resource has no interactive UI flow ("unguided"),
|
||||
// clicking on the card will take a user to our docs page
|
||||
// on a new tab.
|
||||
// 2) If the resource is guided, we start the "flow" by
|
||||
// taking user to the next step.
|
||||
// 3) If the resource is kind 'Application', it will render the legacy
|
||||
// popup modal where it shows user to add app manually or automatically.
|
||||
return (
|
||||
<ResourceCard
|
||||
data-testid={r.kind}
|
||||
key={`${index}${pretitle}${title}`}
|
||||
hasAccess={r.hasAccess}
|
||||
aria-label={`${pretitle} ${title}`}
|
||||
{...resourceCardProps}
|
||||
>
|
||||
{!r.unguidedLink && r.hasAccess && (
|
||||
<BadgeGuided>Guided</BadgeGuided>
|
||||
)}
|
||||
{!r.hasAccess && (
|
||||
<ToolTipNoPermBadge
|
||||
children={<PermissionsErrorMessage resource={r} />}
|
||||
/>
|
||||
)}
|
||||
<Flex px={2} alignItems="center" height="48px">
|
||||
<Flex mr={3} justifyContent="center" width="24px">
|
||||
<DiscoverIcon name={r.icon} />
|
||||
</Flex>
|
||||
<Box>
|
||||
{pretitle && (
|
||||
<Text typography="body3" color="text.slightlyMuted">
|
||||
{pretitle}
|
||||
</Text>
|
||||
)}
|
||||
{r.unguidedLink ? (
|
||||
<Text bold color="text.main">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
<Text bold>{title}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{r.unguidedLink && r.hasAccess ? (
|
||||
<NewTabInCorner color="text.muted" size={18} />
|
||||
) : null}
|
||||
</ResourceCard>
|
||||
);
|
||||
})}
|
||||
{resources.map((r, index) => (
|
||||
<Tile
|
||||
// TODO(kimlisa): replace with r.id in upcoming PR
|
||||
key={`${index}${r.name}${r.kind}`}
|
||||
resourceSpec={r}
|
||||
onChangeShowApp={setShowApp}
|
||||
onSelectResource={onSelect}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
<P3 mt={6}>
|
||||
Looking for something else?{' '}
|
||||
@@ -338,301 +235,6 @@ const ClearSearch = ({ onClick }: { onClick(): void }) => {
|
||||
);
|
||||
};
|
||||
|
||||
function checkHasAccess(acl: Acl, resourceKind: ResourceKind) {
|
||||
const basePerm = acl.tokens.create;
|
||||
if (!basePerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (resourceKind) {
|
||||
case ResourceKind.Application:
|
||||
return acl.appServers.read && acl.appServers.list;
|
||||
case ResourceKind.Database:
|
||||
return acl.dbServers.read && acl.dbServers.list;
|
||||
case ResourceKind.Desktop:
|
||||
return acl.desktops.read && acl.desktops.list;
|
||||
case ResourceKind.Kubernetes:
|
||||
return acl.kubeServers.read && acl.kubeServers.list;
|
||||
case ResourceKind.Server:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function sortResourcesByKind(
|
||||
resourceKind: SearchResource,
|
||||
resources: ResourceSpec[]
|
||||
) {
|
||||
let sorted: ResourceSpec[] = [];
|
||||
switch (resourceKind) {
|
||||
case SearchResource.SERVER:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Server),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Server),
|
||||
];
|
||||
break;
|
||||
case SearchResource.APPLICATION:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Application),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Application),
|
||||
];
|
||||
break;
|
||||
case SearchResource.DATABASE:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Database),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Database),
|
||||
];
|
||||
break;
|
||||
case SearchResource.DESKTOP:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Desktop),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Desktop),
|
||||
];
|
||||
break;
|
||||
case SearchResource.KUBERNETES:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Kubernetes),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Kubernetes),
|
||||
];
|
||||
break;
|
||||
}
|
||||
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<ElementType>(
|
||||
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,
|
||||
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);
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
const prioritizeUserPlatform = compareAB(r => r.platform === platform);
|
||||
if (prioritizeUserPlatform) {
|
||||
return prioritizeUserPlatform;
|
||||
}
|
||||
|
||||
// Display preferred resources second
|
||||
if (hasPreferredResources) {
|
||||
const prioritizePreferredResource = compareAB(r =>
|
||||
preferredResources.includes(resourceKindToPreferredResource(r.kind))
|
||||
);
|
||||
if (prioritizePreferredResource) {
|
||||
return prioritizePreferredResource;
|
||||
}
|
||||
}
|
||||
|
||||
// Display guided resources third
|
||||
const prioritizeGuided = compareAB(r => !r.unguidedLink);
|
||||
if (prioritizeGuided) {
|
||||
return prioritizeGuided;
|
||||
}
|
||||
|
||||
// Alpha
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort restricted resources alphabetically
|
||||
restricted.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort resources that user has access to the
|
||||
// top of the list, so it is more visible to
|
||||
// the user.
|
||||
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
|
||||
*
|
||||
* @remarks
|
||||
* A user can have preferredResources set via onboarding either from the survey (preferredResources)
|
||||
* or various query parameters (marketingParams). We sort the list by the marketingParams if available.
|
||||
* If not, we sort by preferred resource type if available.
|
||||
* We do not search.
|
||||
*
|
||||
* @param preferences - Cluster state user preferences
|
||||
* @returns PrioritizedResources which is both the resource to prioritize and a boolean value of the value
|
||||
*
|
||||
*/
|
||||
function getPrioritizedResources(
|
||||
preferences: UserPreferences
|
||||
): PrioritizedResources {
|
||||
const marketingParams = preferences.onboard.marketingParams;
|
||||
|
||||
if (marketingParams) {
|
||||
const marketingPriorities = getMarketingTermMatches(marketingParams);
|
||||
if (marketingPriorities.length > 0) {
|
||||
return {
|
||||
hasPreferredResources: true,
|
||||
preferredResources: marketingPriorities,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const preferredResources = preferences.onboard.preferredResources || [];
|
||||
|
||||
// hasPreferredResources will be false if all resources are selected
|
||||
const maxResources = Object.keys(Resource).length / 2 - 1;
|
||||
const selectedAll = preferredResources.length === maxResources;
|
||||
|
||||
return {
|
||||
preferredResources: preferredResources,
|
||||
hasPreferredResources: preferredResources.length > 0 && !selectedAll,
|
||||
};
|
||||
}
|
||||
|
||||
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:
|
||||
return { ...r, dbMeta: { ...r.dbMeta }, hasAccess };
|
||||
default:
|
||||
return { ...r, hasAccess };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const Grid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 320px);
|
||||
@@ -640,58 +242,6 @@ const Grid = styled.div`
|
||||
row-gap: 15px;
|
||||
`;
|
||||
|
||||
const NewTabInCorner = styled(NewTab)`
|
||||
position: absolute;
|
||||
top: ${props => props.theme.space[3]}px;
|
||||
right: ${props => props.theme.space[3]}px;
|
||||
transition: color 0.3s;
|
||||
`;
|
||||
|
||||
const ResourceCard = styled.button<{ hasAccess?: boolean }>`
|
||||
position: relative;
|
||||
text-align: left;
|
||||
background: ${props => props.theme.colors.spotBackground[0]};
|
||||
transition: all 0.3s;
|
||||
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: ${props => props.theme.colors.text.main};
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
opacity: ${props => (props.hasAccess ? '1' : '0.45')};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px ${props => props.theme.colors.brand};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${props => props.theme.colors.spotBackground[1]};
|
||||
|
||||
${NewTabInCorner} {
|
||||
color: ${props => props.theme.colors.text.slightlyMuted};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const BadgeGuided = styled.div`
|
||||
position: absolute;
|
||||
background: ${props => props.theme.colors.brand};
|
||||
color: ${props => props.theme.colors.text.primaryInverse};
|
||||
padding: 0px 6px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
font-size: 10px;
|
||||
line-height: 24px;
|
||||
`;
|
||||
|
||||
const InputWrapper = styled.div`
|
||||
border-radius: 200px;
|
||||
height: 40px;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { type ComponentPropsWithoutRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box, Flex, Link, Text } from 'design';
|
||||
import { NewTab } from 'design/Icon';
|
||||
|
||||
import { ToolTipNoPermBadge } from 'teleport/components/ToolTipNoPermBadge';
|
||||
import {
|
||||
PermissionsErrorMessage,
|
||||
ResourceKind,
|
||||
} from 'teleport/Discover/Shared';
|
||||
|
||||
import { getResourcePretitle } from '.';
|
||||
import { DiscoverIcon } from './icons';
|
||||
import { type ResourceSpec } from './types';
|
||||
|
||||
export function Tile({
|
||||
resourceSpec,
|
||||
onChangeShowApp,
|
||||
onSelectResource,
|
||||
}: {
|
||||
resourceSpec: ResourceSpec;
|
||||
onChangeShowApp(b: boolean): void;
|
||||
onSelectResource(r: ResourceSpec): void;
|
||||
}) {
|
||||
const title = resourceSpec.name;
|
||||
const pretitle = getResourcePretitle(resourceSpec);
|
||||
const select = () => {
|
||||
if (!resourceSpec.hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeShowApp(true);
|
||||
onSelectResource(resourceSpec);
|
||||
};
|
||||
|
||||
let resourceCardProps: ComponentPropsWithoutRef<'button' | typeof Link>;
|
||||
|
||||
if (resourceSpec.kind === ResourceKind.Application && resourceSpec.isDialog) {
|
||||
resourceCardProps = {
|
||||
onClick: select,
|
||||
onKeyUp: (e: KeyboardEvent) => e.key === 'Enter' && select(),
|
||||
role: 'button',
|
||||
};
|
||||
} else if (resourceSpec.unguidedLink) {
|
||||
resourceCardProps = {
|
||||
as: Link,
|
||||
href: resourceSpec.hasAccess ? resourceSpec.unguidedLink : null,
|
||||
target: '_blank',
|
||||
style: { textDecoration: 'none' },
|
||||
role: 'link',
|
||||
};
|
||||
} else {
|
||||
resourceCardProps = {
|
||||
onClick: () => resourceSpec.hasAccess && onSelectResource(resourceSpec),
|
||||
onKeyUp: (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && resourceSpec.hasAccess) {
|
||||
onSelectResource(resourceSpec);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
};
|
||||
}
|
||||
|
||||
// There can be three types of click behavior with the resource cards:
|
||||
// 1) If the resource has no interactive UI flow ("unguided"),
|
||||
// clicking on the card will take a user to our docs page
|
||||
// on a new tab.
|
||||
// 2) If the resource is guided, we start the "flow" by
|
||||
// taking user to the next step.
|
||||
// 3) If the resource is kind 'Application', it will render the legacy
|
||||
// popup modal where it shows user to add app manually or automatically.
|
||||
return (
|
||||
<ResourceCard
|
||||
data-testid={resourceSpec.kind}
|
||||
hasAccess={resourceSpec.hasAccess}
|
||||
aria-label={`${pretitle} ${title}`}
|
||||
{...resourceCardProps}
|
||||
>
|
||||
{!resourceSpec.unguidedLink && resourceSpec.hasAccess && (
|
||||
<BadgeGuided>Guided</BadgeGuided>
|
||||
)}
|
||||
{!resourceSpec.hasAccess && (
|
||||
<ToolTipNoPermBadge>
|
||||
<PermissionsErrorMessage resource={resourceSpec} />
|
||||
</ToolTipNoPermBadge>
|
||||
)}
|
||||
<Flex px={2} alignItems="center" height="48px">
|
||||
<Flex mr={3} justifyContent="center" width="24px">
|
||||
<DiscoverIcon name={resourceSpec.icon} />
|
||||
</Flex>
|
||||
<Box>
|
||||
{pretitle && (
|
||||
<Text typography="body3" color="text.slightlyMuted">
|
||||
{pretitle}
|
||||
</Text>
|
||||
)}
|
||||
{resourceSpec.unguidedLink ? (
|
||||
<Text bold color="text.main">
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
<Text bold>{title}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{resourceSpec.unguidedLink && resourceSpec.hasAccess ? (
|
||||
<NewTabInCorner color="text.muted" size={18} />
|
||||
) : null}
|
||||
</ResourceCard>
|
||||
);
|
||||
}
|
||||
|
||||
const NewTabInCorner = styled(NewTab)`
|
||||
position: absolute;
|
||||
top: ${props => props.theme.space[3]}px;
|
||||
right: ${props => props.theme.space[3]}px;
|
||||
transition: color 0.3s;
|
||||
`;
|
||||
|
||||
const ResourceCard = styled.button<{ hasAccess?: boolean }>`
|
||||
position: relative;
|
||||
text-align: left;
|
||||
background: ${props => props.theme.colors.spotBackground[0]};
|
||||
transition: all 0.3s;
|
||||
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
color: ${props => props.theme.colors.text.main};
|
||||
line-height: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
opacity: ${props => (props.hasAccess ? '1' : '0.45')};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px ${props => props.theme.colors.brand};
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${props => props.theme.colors.spotBackground[1]};
|
||||
|
||||
${NewTabInCorner} {
|
||||
color: ${props => props.theme.colors.text.slightlyMuted};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const BadgeGuided = styled.div`
|
||||
position: absolute;
|
||||
background: ${props => props.theme.colors.brand};
|
||||
color: ${props => props.theme.colors.text.primaryInverse};
|
||||
padding: 0px 6px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
font-size: 10px;
|
||||
line-height: 24px;
|
||||
`;
|
||||
@@ -17,6 +17,9 @@
|
||||
*/
|
||||
|
||||
export { SelectResource } from './SelectResource';
|
||||
export { getResourcePretitle } from './resources';
|
||||
export { getDatabaseProtocol, getDefaultDatabasePort } from './databases';
|
||||
export {
|
||||
getResourcePretitle,
|
||||
getDatabaseProtocol,
|
||||
getDefaultDatabasePort,
|
||||
} from './resources';
|
||||
export * from './types';
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ import { DbProtocol } from 'shared/services/databases';
|
||||
|
||||
import { DiscoverEventResource } from 'teleport/services/userEvent';
|
||||
|
||||
import { ResourceKind } from '../Shared/ResourceKind';
|
||||
import { DatabaseEngine, DatabaseLocation, ResourceSpec } from './types';
|
||||
import { ResourceKind } from '../../Shared/ResourceKind';
|
||||
import { DatabaseEngine, DatabaseLocation, ResourceSpec } from '../types';
|
||||
|
||||
const baseDatabaseKeywords = ['db', 'database', 'databases'];
|
||||
const awsKeywords = [...baseDatabaseKeywords, 'aws', 'amazon web services'];
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export * from './databases';
|
||||
export * from './resources';
|
||||
export * from './resourcesE';
|
||||
+7
-7
@@ -24,19 +24,19 @@ import {
|
||||
DiscoverEventResource,
|
||||
} from 'teleport/services/userEvent';
|
||||
|
||||
import { ResourceKind } from '../Shared/ResourceKind';
|
||||
import {
|
||||
DATABASES,
|
||||
DATABASES_UNGUIDED,
|
||||
DATABASES_UNGUIDED_DOC,
|
||||
} from './databases';
|
||||
import { ResourceKind } from '../../Shared/ResourceKind';
|
||||
import {
|
||||
DatabaseEngine,
|
||||
DatabaseLocation,
|
||||
KubeLocation,
|
||||
ResourceSpec,
|
||||
ServerLocation,
|
||||
} from './types';
|
||||
} from '../types';
|
||||
import {
|
||||
DATABASES,
|
||||
DATABASES_UNGUIDED,
|
||||
DATABASES_UNGUIDED_DOC,
|
||||
} from './databases';
|
||||
|
||||
const baseServerKeywords = ['server', 'node', 'ssh'];
|
||||
const awsKeywords = ['aws', 'amazon', 'amazon web services'];
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
import { SamlServiceProviderPreset } from 'teleport/services/samlidp/types';
|
||||
import { DiscoverEventResource } from 'teleport/services/userEvent';
|
||||
|
||||
import { ResourceKind } from '../Shared';
|
||||
import { ResourceSpec } from './types';
|
||||
import { ResourceKind } from '../../Shared';
|
||||
import { ResourceSpec } from '../types';
|
||||
|
||||
export const SAML_APPLICATIONS: ResourceSpec[] = [
|
||||
{
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Acl } from 'teleport/services/user';
|
||||
|
||||
import { ResourceKind } from '../../Shared';
|
||||
import { ResourceSpec } from '../types';
|
||||
|
||||
function checkHasAccess(acl: Acl, resourceKind: ResourceKind) {
|
||||
const basePerm = acl.tokens.create;
|
||||
if (!basePerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (resourceKind) {
|
||||
case ResourceKind.Application:
|
||||
return acl.appServers.read && acl.appServers.list;
|
||||
case ResourceKind.Database:
|
||||
return acl.dbServers.read && acl.dbServers.list;
|
||||
case ResourceKind.Desktop:
|
||||
return acl.desktops.read && acl.desktops.list;
|
||||
case ResourceKind.Kubernetes:
|
||||
return acl.kubeServers.read && acl.kubeServers.list;
|
||||
case ResourceKind.Server:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function addHasAccessField(
|
||||
acl: Acl,
|
||||
resources: ResourceSpec[]
|
||||
): ResourceSpec[] {
|
||||
return resources.map(r => {
|
||||
const hasAccess = checkHasAccess(acl, r.kind);
|
||||
switch (r.kind) {
|
||||
case ResourceKind.Database:
|
||||
return { ...r, dbMeta: { ...r.dbMeta }, hasAccess };
|
||||
default:
|
||||
return { ...r, hasAccess };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Platform } from 'design/platform';
|
||||
|
||||
import { AuthType } from 'teleport/services/user';
|
||||
|
||||
import { type ResourceSpec } from '../types';
|
||||
|
||||
export function filterBySupportedPlatformsAndAuthTypes(
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getPlatform } from 'design/platform';
|
||||
import { Resource } from 'gen-proto-ts/teleport/userpreferences/v1/onboard_pb';
|
||||
import { UserPreferences } from 'gen-proto-ts/teleport/userpreferences/v1/userpreferences_pb';
|
||||
|
||||
import { OnboardDiscover } from 'teleport/services/user';
|
||||
|
||||
import { ResourceKind } from '../../Shared';
|
||||
import { resourceKindToPreferredResource } from '../../Shared/ResourceKind';
|
||||
import { getMarketingTermMatches } from '../getMarketingTermMatches';
|
||||
import { PrioritizedResources, ResourceSpec, SearchResource } from '../types';
|
||||
|
||||
function isConnectMyComputerAvailable(
|
||||
accessibleResources: ResourceSpec[]
|
||||
): boolean {
|
||||
return !!accessibleResources.find(
|
||||
resource => resource.kind === ResourceKind.ConnectMyComputer
|
||||
);
|
||||
}
|
||||
|
||||
export function sortResourcesByPreferences(
|
||||
resources: ResourceSpec[],
|
||||
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);
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
const prioritizeUserPlatform = compareAB(r => r.platform === platform);
|
||||
if (prioritizeUserPlatform) {
|
||||
return prioritizeUserPlatform;
|
||||
}
|
||||
|
||||
// Display preferred resources second
|
||||
if (hasPreferredResources) {
|
||||
const prioritizePreferredResource = compareAB(r =>
|
||||
preferredResources.includes(resourceKindToPreferredResource(r.kind))
|
||||
);
|
||||
if (prioritizePreferredResource) {
|
||||
return prioritizePreferredResource;
|
||||
}
|
||||
}
|
||||
|
||||
// Display guided resources third
|
||||
const prioritizeGuided = compareAB(r => !r.unguidedLink);
|
||||
if (prioritizeGuided) {
|
||||
return prioritizeGuided;
|
||||
}
|
||||
|
||||
// Alpha
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort restricted resources alphabetically
|
||||
restricted.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Sort resources that user has access to the
|
||||
// top of the list, so it is more visible to
|
||||
// the user.
|
||||
return [...accessible, ...restricted];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns prioritized resources based on user preferences cluster state
|
||||
*
|
||||
* @remarks
|
||||
* A user can have preferredResources set via onboarding either from the survey (preferredResources)
|
||||
* or various query parameters (marketingParams). We sort the list by the marketingParams if available.
|
||||
* If not, we sort by preferred resource type if available.
|
||||
* We do not search.
|
||||
*
|
||||
* @param preferences - Cluster state user preferences
|
||||
* @returns PrioritizedResources which is both the resource to prioritize and a boolean value of the value
|
||||
*
|
||||
*/
|
||||
function getPrioritizedResources(
|
||||
preferences: UserPreferences
|
||||
): PrioritizedResources {
|
||||
const marketingParams = preferences.onboard.marketingParams;
|
||||
|
||||
if (marketingParams) {
|
||||
const marketingPriorities = getMarketingTermMatches(marketingParams);
|
||||
if (marketingPriorities.length > 0) {
|
||||
return {
|
||||
hasPreferredResources: true,
|
||||
preferredResources: marketingPriorities,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const preferredResources = preferences.onboard.preferredResources || [];
|
||||
|
||||
// hasPreferredResources will be false if all resources are selected
|
||||
const maxResources = Object.keys(Resource).length / 2 - 1;
|
||||
const selectedAll = preferredResources.length === maxResources;
|
||||
|
||||
return {
|
||||
preferredResources: preferredResources,
|
||||
hasPreferredResources: preferredResources.length > 0 && !selectedAll,
|
||||
};
|
||||
}
|
||||
|
||||
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<ElementType>(
|
||||
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 sortResourcesByKind(
|
||||
resourceKind: SearchResource,
|
||||
resources: ResourceSpec[]
|
||||
) {
|
||||
let sorted: ResourceSpec[] = [];
|
||||
switch (resourceKind) {
|
||||
case SearchResource.SERVER:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Server),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Server),
|
||||
];
|
||||
break;
|
||||
case SearchResource.APPLICATION:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Application),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Application),
|
||||
];
|
||||
break;
|
||||
case SearchResource.DATABASE:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Database),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Database),
|
||||
];
|
||||
break;
|
||||
case SearchResource.DESKTOP:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Desktop),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Desktop),
|
||||
];
|
||||
break;
|
||||
case SearchResource.KUBERNETES:
|
||||
sorted = [
|
||||
...resources.filter(r => r.kind === ResourceKind.Kubernetes),
|
||||
...resources.filter(r => r.kind !== ResourceKind.Kubernetes),
|
||||
];
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
Reference in New Issue
Block a user