First configure your AWS IAM permissions
-
+
The following IAM permissions will be added as an inline policy
named {IAM_POLICY_NAME} to IAM role{' '}
{iamRoleName}
@@ -94,7 +94,7 @@ export function CreateAppAccess() {
/>
-
+
Run the command below on your{' '}
diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx
index cc5ed4bb590b4..1717a082208ba 100644
--- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx
+++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx
@@ -21,7 +21,7 @@ import React, { useState, useEffect } from 'react';
import { Text, Flex, Box, Indicator, ButtonSecondary, Subtitle3 } from 'design';
import * as Icons from 'design/Icon';
import { FetchStatus } from 'design/DataTable/types';
-import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip';
+import { HoverTooltip, IconTooltip } from 'design/Tooltip';
import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrMessage } from 'shared/utils/errorType';
import { pluralize } from 'shared/utils/text';
@@ -126,7 +126,7 @@ export const SelectSecurityGroups = ({
<>
Select ECS Security Groups
-
+
Select ECS security group(s) based on the following requirements:
@@ -141,7 +141,7 @@ export const SelectSecurityGroups = ({
-
+
diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx
index 785ec15fbda9e..8a6e93a0491b1 100644
--- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx
+++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx
@@ -29,7 +29,7 @@ import {
} from 'design';
import * as Icons from 'design/Icon';
import { FetchStatus } from 'design/DataTable/types';
-import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip';
+import { HoverTooltip, IconTooltip } from 'design/Tooltip';
import { pluralize } from 'shared/utils/text';
import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrMessage } from 'shared/utils/errorType';
@@ -121,12 +121,12 @@ export function SelectSubnetIds({
<>
Select ECS Subnets
-
+
A subnet has an outbound internet route if it has a route to an
internet gateway or a NAT gateway in a public subnet.
-
+
diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx
index 204e30b3e79d1..617d10ba79790 100644
--- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx
+++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx
@@ -19,7 +19,7 @@
import React from 'react';
import { Box, Toggle } from 'design';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
export function AutoDiscoverToggle({
wantAutoDiscover,
@@ -40,11 +40,11 @@ export function AutoDiscoverToggle({
Auto-enroll all databases for the selected VPC
-
+
Auto-enroll will automatically identify all RDS databases (e.g.
PostgreSQL, MySQL, Aurora) from the selected VPC and register them as
database resources in your infrastructure.
-
+
);
diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx
index e73fc6dfc3e15..2505da7275658 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx
+++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx
@@ -31,7 +31,7 @@ import { FetchStatus } from 'design/DataTable/types';
import { Danger } from 'design/Alert';
import useAttempt from 'shared/hooks/useAttemptNext';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import { getErrMessage } from 'shared/utils/errorType';
import { EksMeta, useDiscover } from 'teleport/Discover/useDiscover';
@@ -435,11 +435,11 @@ export function EnrollEksCluster(props: AgentStepProps) {
Enable Kubernetes App Discovery
-
+
Teleport's Kubernetes App Discovery will automatically identify
and enroll to Teleport HTTP applications running inside a
Kubernetes cluster.
-
+
Auto-enroll all EKS clusters for selected region
-
+
Auto-enroll will automatically identify all EKS clusters from
the selected region and register them as Kubernetes resources in
your infrastructure.
-
+
{showTable && (
diff --git a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx
index d325d99b3b123..98adbba084d88 100644
--- a/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx
+++ b/web/packages/teleport/src/Discover/Server/CreateEc2Ice/CreateEc2IceDialog.tsx
@@ -263,8 +263,9 @@ export function CreateEc2IceDialog({
style={{ display: 'flex', textAlign: 'left', width: '100%' }}
>
- The EC2 instance [{typedAgentMeta?.node.awsMetadata.instanceId}] has
- been added to Teleport.
+ The EC2 instance [{
+ typedAgentMeta?.node.awsMetadata.instanceId
+ }] has been added to Teleport.
nextStep()}>
Next
diff --git a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx
index 6a7245bacf798..6846a09779e53 100644
--- a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx
+++ b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx
@@ -30,7 +30,7 @@ import {
import styled from 'styled-components';
import { Danger, Info } from 'design/Alert';
import TextEditor from 'shared/components/TextEditor';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import FieldInput from 'shared/components/FieldInput';
import { Rule } from 'shared/components/Validation/rules';
import Validation, { Validator } from 'shared/components/Validation';
@@ -317,7 +317,7 @@ export function DiscoveryConfigSsm() {
{' '}
to configure your IAM permissions.
-
+
The following IAM permissions will be added as an inline
policy named {IAM_POLICY_NAME} to IAM role{' '}
{arnResourceName}
@@ -330,7 +330,7 @@ export function DiscoveryConfigSsm() {
/>
-
+
Auto-enroll all EC2 instances for selected region
-
+
Auto-enroll will automatically identify all EC2 instances from
the selected region and register them as node resources in
your infrastructure.
-
+
{wantAutoDiscover && (
diff --git a/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx b/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx
index 4c491191152b8..0c244462b7507 100644
--- a/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx
+++ b/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx
@@ -21,7 +21,7 @@ import styled from 'styled-components';
import { Flex, Link, Box, H3 } from 'design';
import { assertUnreachable } from 'shared/utils/assertUnreachable';
import TextEditor from 'shared/components/TextEditor';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import { P } from 'design/Text/Text';
@@ -179,11 +179,11 @@ export function ConfigureIamPerms({
<>
Configure your AWS IAM permissions
-
+
The following IAM permissions will be added as an inline policy
named {iamPolicyName} to IAM role {iamRoleName}
{editor}
-
+
{msg} Run the command below on your{' '}
diff --git a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx
index 9017c990205f4..1b56b2d69e270 100644
--- a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx
+++ b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx
@@ -18,7 +18,7 @@
import { Box, Flex, Input, Text, Mark, H3, Subtitle3 } from 'design';
import styled from 'styled-components';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import React from 'react';
@@ -71,7 +71,7 @@ discovery_service:
Auto-enrolling requires you to configure a{' '}
Discovery Service
-
+
>
@@ -100,7 +100,7 @@ discovery_service:
Step 2
Define a Discovery Group name{' '}
-
+
diff --git a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/CreatedDiscoveryConfigDialog.tsx b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/CreatedDiscoveryConfigDialog.tsx
index ee7bf1db8e2d0..ecf88ddcecfb2 100644
--- a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/CreatedDiscoveryConfigDialog.tsx
+++ b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/CreatedDiscoveryConfigDialog.tsx
@@ -90,7 +90,9 @@ export function CreatedDiscoveryConfigDialog({
<>
{' '}
The discovery service can take a few minutes to finish
- auto-enrolling resources found in region {region}.
+ auto-enrolling resources found in region
+ {region}
+ .
>
)}
diff --git a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx
index 7b66604e54a51..890eeee6cc60a 100644
--- a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx
+++ b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx
@@ -23,7 +23,7 @@ import Table, { Cell } from 'design/DataTable';
import { Danger } from 'design/Alert';
import { CheckboxInput } from 'design/Checkbox';
import { FetchStatus } from 'design/DataTable/types';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import { Attempt } from 'shared/hooks/useAttemptNext';
@@ -163,13 +163,13 @@ export const SecurityGroupPicker = ({
if (sg.recommended && sg.tips?.length) {
return (
-
+
{sg.tips.map((tip, index) => (
- {tip}
))}
-
+
|
);
}
diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx
index aecd67c00d114..b99521e8719ae 100644
--- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx
+++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx
@@ -20,7 +20,7 @@ import React from 'react';
import styled from 'styled-components';
import { Flex, Box, H3, Text } from 'design';
import TextEditor from 'shared/components/TextEditor';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import useStickyClusterId from 'teleport/useStickyClusterId';
@@ -61,7 +61,7 @@ export function ConfigureAwsOidcSummary({
}`;
return (
-
+
Running the command in AWS CloudShell does the following:
1. Configures an AWS IAM OIDC Identity Provider (IdP)
@@ -76,7 +76,7 @@ export function ConfigureAwsOidcSummary({
/>
-
+
);
}
diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx
index a225196d65dfc..47452f3aa720e 100644
--- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx
+++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx
@@ -19,7 +19,7 @@
import React from 'react';
import { Text, Flex } from 'design';
import FieldInput from 'shared/components/FieldInput';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
export function S3BucketConfiguration({
s3Bucket,
@@ -32,11 +32,11 @@ export function S3BucketConfiguration({
<>
Amazon S3 Location
-
+
Deprecated. Amazon is now validating the IdP certificate against a
list of root CAs. Storing the OpenID Configuration in S3 is no longer
required, and should be removed to improve security.
-
+
-
+
-
+
AWS OIDC Identity Provider
{!hasIntegrationAccess && (
@@ -84,9 +84,9 @@ export function IntegrationTiles({
data-testid="tile-external-audit-storage"
>
-
+
-
+
AWS External Audit Storage
{renderExternalAuditStorageBadge(
diff --git a/web/packages/teleport/src/Integrations/Enroll/common.tsx b/web/packages/teleport/src/Integrations/Enroll/common.tsx
index ed61e93631fde..e94641b245bf2 100644
--- a/web/packages/teleport/src/Integrations/Enroll/common.tsx
+++ b/web/packages/teleport/src/Integrations/Enroll/common.tsx
@@ -31,14 +31,17 @@ export const IntegrationTile = styled(Flex)<{
align-items: center;
justify-content: center;
position: relative;
- border-radius: 4px;
- padding: 15px 10px 6px 10px;
+ border-radius: ${({ theme }) => theme.radii[2]}px;
+ padding: ${({ theme }) => theme.space[3]}px;
+ gap: ${({ theme }) => theme.space[3]}px;
height: 170px;
width: 170px;
background-color: ${({ theme }) => theme.colors.buttons.secondary.default};
text-align: center;
cursor: ${({ disabled, $exists }) =>
- disabled || $exists ? 'default' : 'pointer'};
+ disabled || $exists ? 'not-allowed' : 'pointer'};
+ transition: background-color 200ms ease;
+
${props => {
if (props.$exists) {
return;
@@ -46,7 +49,8 @@ export const IntegrationTile = styled(Flex)<{
return `
opacity: ${props.disabled ? '0.45' : '1'};
- &:hover {
+ &:hover,
+ &:focus-visible {
background-color: ${props.theme.colors.buttons.secondary.hover};
}
`;
@@ -69,10 +73,8 @@ export const NoCodeIntegrationDescription = () => (
*/
export const IntegrationIcon = styled(ResourceIcon)<{ size?: number }>`
display: inline-block;
+ margin: 0 auto;
height: 100%;
- ${({ size }) =>
- size &&
- `
- max-height: ${size}px;
- `}
+ min-width: 0;
+ ${({ size }) => size && `max-width: ${size}px;`}
`;
diff --git a/web/packages/teleport/src/Integrations/IntegrationList.tsx b/web/packages/teleport/src/Integrations/IntegrationList.tsx
index a226cea01523f..8e3e41526ac77 100644
--- a/web/packages/teleport/src/Integrations/IntegrationList.tsx
+++ b/web/packages/teleport/src/Integrations/IntegrationList.tsx
@@ -24,7 +24,7 @@ import { Link as InternalRouteLink } from 'react-router-dom';
import { Box, Flex } from 'design';
import Table, { Cell } from 'design/DataTable';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import { useAsync } from 'shared/hooks/useAsync';
import { ResourceIcon } from 'design/ResourceIcon';
import { saveOnDisk } from 'shared/utils/saveOnDisk';
@@ -251,7 +251,7 @@ const StatusCell = ({ item }: { item: IntegrationLike }) => {
{getStatusCodeTitle(item.statusCode)}
{statusDescription && (
- {statusDescription}
+ {statusDescription}
)}
diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx
index 8d95d16a691c4..773efe7beb610 100644
--- a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx
+++ b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx
@@ -21,7 +21,7 @@ import { Link as InternalLink } from 'react-router-dom';
import { ButtonIcon, Flex, Label, Text } from 'design';
import { ArrowLeft } from 'design/Icon';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import cfg from 'teleport/config';
import { getStatusAndLabel } from 'teleport/Integrations/helpers';
diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
index 46e9100feab8b..862085cfd0157 100644
--- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
+++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx
@@ -42,7 +42,7 @@ import Dialog, {
} from 'design/Dialog';
import { MenuButton } from 'shared/components/MenuAction';
import { Attempt, useAsync } from 'shared/hooks/useAsync';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton';
import { useTeleport } from 'teleport';
diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx
index 6daef672649c6..357c6a3d59471 100644
--- a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx
+++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx
@@ -29,7 +29,7 @@ import {
Alert,
} from 'design';
import styled from 'styled-components';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import { Cross } from 'design/Icon';
import Validation from 'shared/components/Validation';
import FieldInput from 'shared/components/FieldInput';
diff --git a/web/packages/teleport/src/Navigation/Navigation.tsx b/web/packages/teleport/src/Navigation/Navigation.tsx
index 6450c575114bf..e50295ea5a1f9 100644
--- a/web/packages/teleport/src/Navigation/Navigation.tsx
+++ b/web/packages/teleport/src/Navigation/Navigation.tsx
@@ -21,7 +21,7 @@ import styled, { useTheme } from 'styled-components';
import { matchPath, useLocation, useHistory } from 'react-router';
import { Box, Text, Flex } from 'design';
-import { ToolTipInfo } from 'shared/components/ToolTip';
+import { IconTooltip } from 'design/Tooltip';
import cfg from 'teleport/config';
import {
@@ -195,9 +195,9 @@ function LicenseFooter({
{title}
-
+
{infoContent}
-
+
{subText}
diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx
index 8217106d5fd20..eb9d10c111b82 100644
--- a/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx
+++ b/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx
@@ -23,7 +23,7 @@ import styled, { css, useTheme } from 'styled-components';
import { Box, ButtonIcon, Flex, P2, Text } from 'design';
import { Theme } from 'design/theme';
import { ArrowLineLeft } from 'design/Icon';
-import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip';
+import { HoverTooltip, IconTooltip } from 'design/Tooltip';
import cfg from 'teleport/config';
@@ -470,9 +470,9 @@ function LicenseFooter({
{title}
-
+
{infoContent}
-
+
{subText}
diff --git a/web/packages/teleport/src/Notifications/Notifications.tsx b/web/packages/teleport/src/Notifications/Notifications.tsx
index ada3dd3761af1..b64d1460e8041 100644
--- a/web/packages/teleport/src/Notifications/Notifications.tsx
+++ b/web/packages/teleport/src/Notifications/Notifications.tsx
@@ -24,7 +24,7 @@ import { Alert, Box, Flex, Indicator, Text } from 'design';
import { Notification as NotificationIcon, BellRinging } from 'design/Icon';
import Logger from 'shared/libs/logger';
import { useRefClickOutside } from 'shared/hooks/useRefClickOutside';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import {
useInfiniteScroll,
diff --git a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx
index 37059aee38594..2ee25880cedad 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx
@@ -17,20 +17,32 @@
*/
import React from 'react';
-import { Flex, ButtonText, H2 } from 'design';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { Flex, ButtonText, H2, Indicator, Box } from 'design';
+import { HoverTooltip } from 'design/Tooltip';
import { Trash } from 'design/Icon';
import useTeleport from 'teleport/useTeleport';
import { Role } from 'teleport/services/resources';
+import { EditorTab, EditorTabs } from './EditorTabs';
+
/** Renders a header button with role name and delete button. */
export const EditorHeader = ({
role = null,
onDelete,
+ selectedEditorTab,
+ onEditorTabChange,
+ isProcessing,
+ standardEditorId,
+ yamlEditorId,
}: {
- onDelete?(): void;
role?: Role;
+ onDelete(): void;
+ selectedEditorTab: EditorTab;
+ onEditorTabChange(t: EditorTab): void;
+ isProcessing: boolean;
+ standardEditorId: string;
+ yamlEditorId: string;
}) => {
const ctx = useTeleport();
const isCreating = !role;
@@ -38,8 +50,24 @@ export const EditorHeader = ({
const hasDeleteAccess = ctx.storeUser.getRoleAccess().remove;
return (
-
- {isCreating ? 'Create a New Role' : role?.metadata.name}
+
+
+
+ {isCreating
+ ? 'Create a New Role'
+ : `Edit Role ${role?.metadata.name}`}
+
+
+
+ {isProcessing && }
+
+
{!isCreating && (
{
+ const standardLabel = 'Switch to standard editor';
+ const yamlLabel = 'Switch to YAML editor';
return (
);
};
diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx
index 235a0d83782a6..1376c078e80ff 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.test.tsx
@@ -75,10 +75,7 @@ afterEach(() => {
test('rendering and switching tabs for new role', async () => {
render();
- expect(screen.getByRole('tab', { name: 'Standard' })).toHaveAttribute(
- 'aria-selected',
- 'true'
- );
+ expect(getStandardEditorTab()).toHaveAttribute('aria-selected', 'true');
expect(
screen.queryByRole('button', { name: /Reset to Standard Settings/i })
).not.toBeInTheDocument();
@@ -86,7 +83,7 @@ test('rendering and switching tabs for new role', async () => {
expect(screen.getByLabelText('Description')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
expect(fromFauxYaml(await getTextEditorContents())).toEqual(
withDefaults({
kind: 'role',
@@ -103,7 +100,7 @@ test('rendering and switching tabs for new role', async () => {
);
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();
- await user.click(screen.getByRole('tab', { name: 'Standard' }));
+ await user.click(getStandardEditorTab());
await screen.findByLabelText('Role Name');
expect(
screen.queryByRole('button', { name: /Reset to Standard Settings/i })
@@ -127,14 +124,11 @@ test('rendering and switching tabs for a non-standard role', async () => {
originalRole={{ object: originalRole, yaml: originalYaml }}
/>
);
- expect(screen.getByRole('tab', { name: 'YAML' })).toHaveAttribute(
- 'aria-selected',
- 'true'
- );
+ expect(getYamlEditorTab()).toHaveAttribute('aria-selected', 'true');
expect(fromFauxYaml(await getTextEditorContents())).toEqual(originalRole);
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();
- await user.click(screen.getByRole('tab', { name: 'Standard' }));
+ await user.click(getStandardEditorTab());
expect(
screen.getByRole('button', { name: 'Reset to Standard Settings' })
).toBeVisible();
@@ -142,12 +136,12 @@ test('rendering and switching tabs for a non-standard role', async () => {
expect(screen.getByLabelText('Description')).toHaveValue('');
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
expect(fromFauxYaml(await getTextEditorContents())).toEqual(originalRole);
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();
// Switch once again, reset to standard
- await user.click(screen.getByRole('tab', { name: 'Standard' }));
+ await user.click(getStandardEditorTab());
expect(screen.getByRole('button', { name: 'Update Role' })).toBeDisabled();
await user.click(
screen.getByRole('button', { name: 'Reset to Standard Settings' })
@@ -155,13 +149,35 @@ test('rendering and switching tabs for a non-standard role', async () => {
expect(screen.getByRole('button', { name: 'Update Role' })).toBeEnabled();
await user.type(screen.getByLabelText('Description'), 'some description');
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
const editorContents = fromFauxYaml(await getTextEditorContents());
expect(editorContents.metadata.description).toBe('some description');
expect(editorContents.spec.deny).toEqual({});
expect(screen.getByRole('button', { name: 'Update Role' })).toBeEnabled();
});
+test('no double conversions when clicking already active tabs', async () => {
+ render();
+ await user.click(getYamlEditorTab());
+ await user.click(getStandardEditorTab());
+ await user.type(screen.getByLabelText('Role Name'), '_2');
+ await user.click(getStandardEditorTab());
+ expect(screen.getByLabelText('Role Name')).toHaveValue('new_role_name_2');
+
+ await user.click(getYamlEditorTab());
+ await user.clear(await findTextEditor());
+ await user.type(
+ await findTextEditor(),
+ // Note: this is actually correct JSON syntax; the testing library uses
+ // braces for special keys, so we need to use double opening braces.
+ '{{"kind":"role", metadata:{{"name":"new_role_name_3"}}'
+ );
+ await user.click(getYamlEditorTab());
+ expect(await getTextEditorContents()).toBe(
+ '{"kind":"role", metadata:{"name":"new_role_name_3"}}'
+ );
+});
+
test('canceling standard editor', async () => {
const onCancel = jest.fn();
render();
@@ -175,7 +191,7 @@ test('canceling standard editor', async () => {
test('canceling yaml editor', async () => {
const onCancel = jest.fn();
render();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalled();
expect(userEventService.captureUserEvent).toHaveBeenCalledWith({
@@ -222,7 +238,7 @@ test('saving a new role after editing as YAML', async () => {
render();
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
await user.clear(await findTextEditor());
await user.type(await findTextEditor(), '{{"foo":"bar"}');
await user.click(screen.getByRole('button', { name: 'Create Role' }));
@@ -247,7 +263,7 @@ test('error while yamlifying', async () => {
.spyOn(yamlService, 'stringify')
.mockRejectedValue(new Error('me no speak yaml'));
render();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
+ await user.click(getYamlEditorTab());
expect(screen.getByText('me no speak yaml')).toBeVisible();
});
@@ -256,8 +272,8 @@ test('error while parsing', async () => {
.spyOn(yamlService, 'parse')
.mockRejectedValue(new Error('me no speak yaml'));
render();
- await user.click(screen.getByRole('tab', { name: 'YAML' }));
- await user.click(screen.getByRole('tab', { name: 'Standard' }));
+ await user.click(getYamlEditorTab());
+ await user.click(getStandardEditorTab());
expect(screen.getByText('me no speak yaml')).toBeVisible();
});
@@ -280,6 +296,12 @@ const TestRoleEditor = (props: RoleEditorProps) => {
);
};
+const getStandardEditorTab = () =>
+ screen.getByRole('tab', { name: 'Switch to standard editor' });
+
+const getYamlEditorTab = () =>
+ screen.getByRole('tab', { name: 'Switch to YAML editor' });
+
const findTextEditor = async () =>
within(await screen.findByTestId('text-editor-container')).getByRole(
'textbox'
diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx
index cadef43e0ce26..38e2902ac2878 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx
@@ -16,8 +16,8 @@
* along with this program. If not, see .
*/
-import { Alert, Box, Flex } from 'design';
-import React, { useState } from 'react';
+import { Alert, Flex } from 'design';
+import React, { useId, useState } from 'react';
import { useAsync } from 'shared/hooks/useAsync';
import { Role, RoleWithYaml } from 'teleport/services/resources';
@@ -32,7 +32,7 @@ import {
roleToRoleEditorModel as roleToRoleEditorModel,
} from './standardmodel';
import { YamlEditorModel } from './yamlmodel';
-import { EditorTab, EditorTabs } from './EditorTabs';
+import { EditorTab } from './EditorTabs';
import { EditorHeader } from './EditorHeader';
import { StandardEditor } from './StandardEditor';
import { YamlEditor } from './YamlEditor';
@@ -59,6 +59,12 @@ export const RoleEditor = ({
onSave,
onDelete,
}: RoleEditorProps) => {
+ const idPrefix = useId();
+ // These IDs are needed to connect accessibility attributes between the
+ // standard/YAML tab switcher and the switched panels.
+ const standardEditorId = `${idPrefix}-standard`;
+ const yamlEditorId = `${idPrefix}-yaml`;
+
const [standardModel, setStandardModel] = useState(
() => {
const role = originalRole?.object ?? newRole();
@@ -114,6 +120,10 @@ export const RoleEditor = ({
saveAttempt.status === 'processing';
async function onTabChange(activeIndex: EditorTab) {
+ // The code below is not idempotent, so we need to protect ourselves from
+ // an accidental model replacement.
+ if (activeIndex === selectedEditorTab) return;
+
switch (activeIndex) {
case EditorTab.Standard: {
if (!yamlModel.content) {
@@ -160,7 +170,15 @@ export const RoleEditor = ({
return (
-
+
{saveAttempt.status === 'error' && (
{saveAttempt.statusText}
@@ -176,32 +194,29 @@ export const RoleEditor = ({
{yamlifyAttempt.statusText}
)}
-
-
-
{selectedEditorTab === EditorTab.Standard && (
- handleSave({ object })}
- onCancel={handleCancel}
- standardEditorModel={standardModel}
- isProcessing={isProcessing}
- onChange={setStandardModel}
- />
+
+ handleSave({ object })}
+ onCancel={handleCancel}
+ standardEditorModel={standardModel}
+ isProcessing={isProcessing}
+ onChange={setStandardModel}
+ />
+
)}
{selectedEditorTab === EditorTab.Yaml && (
- void (await handleSave({ yaml }))}
- isProcessing={isProcessing}
- onCancel={handleCancel}
- originalRole={originalRole}
- />
+
+ void (await handleSave({ yaml }))}
+ isProcessing={isProcessing}
+ onCancel={handleCancel}
+ originalRole={originalRole}
+ />
+
)}
);
diff --git a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx
index e6cece6752920..3652e87a537ca 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx
@@ -17,7 +17,7 @@
*/
import { Box, ButtonPrimary, ButtonSecondary, Flex } from 'design';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import useTeleport from 'teleport/useTeleport';
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
index 4497cbea441d7..59a7af9928081 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
@@ -19,8 +19,8 @@
import { render, screen, userEvent } from 'design/utils/testing';
import React, { useState } from 'react';
-import { within } from '@testing-library/react';
-import Validation from 'shared/components/Validation';
+import { act, within } from '@testing-library/react';
+import Validation, { Validator } from 'shared/components/Validation';
import selectEvent from 'react-select-event';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
@@ -48,6 +48,7 @@ import {
StandardEditorProps,
WindowsDesktopAccessSpecSection,
} from './StandardEditor';
+import { validateAccessSpec } from './validation';
const TestStandardEditor = (props: Partial) => {
const ctx = createTeleportContext();
@@ -72,6 +73,8 @@ test('adding and removing sections', async () => {
const user = userEvent.setup();
render();
expect(getAllSectionNames()).toEqual(['Role Metadata']);
+ await user.click(screen.getByRole('tab', { name: 'Resources' }));
+ expect(getAllSectionNames()).toEqual([]);
await user.click(
screen.getByRole('button', { name: 'Add New Specifications' })
@@ -85,7 +88,7 @@ test('adding and removing sections', async () => {
]);
await user.click(screen.getByRole('menuitem', { name: 'Servers' }));
- expect(getAllSectionNames()).toEqual(['Role Metadata', 'Servers']);
+ expect(getAllSectionNames()).toEqual(['Servers']);
await user.click(
screen.getByRole('button', { name: 'Add New Specifications' })
@@ -98,25 +101,21 @@ test('adding and removing sections', async () => {
]);
await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' }));
- expect(getAllSectionNames()).toEqual([
- 'Role Metadata',
- 'Servers',
- 'Kubernetes',
- ]);
+ expect(getAllSectionNames()).toEqual(['Servers', 'Kubernetes']);
await user.click(
within(getSectionByName('Servers')).getByRole('button', {
name: 'Remove section',
})
);
- expect(getAllSectionNames()).toEqual(['Role Metadata', 'Kubernetes']);
+ expect(getAllSectionNames()).toEqual(['Kubernetes']);
await user.click(
within(getSectionByName('Kubernetes')).getByRole('button', {
name: 'Remove section',
})
);
- expect(getAllSectionNames()).toEqual(['Role Metadata']);
+ expect(getAllSectionNames()).toEqual([]);
});
test('collapsed sections still apply validation', async () => {
@@ -137,6 +136,24 @@ test('collapsed sections still apply validation', async () => {
expect(onSave).toHaveBeenCalled();
});
+test('invisible tabs still apply validation', async () => {
+ const user = userEvent.setup();
+ const onSave = jest.fn();
+ render();
+ // Intentionally cause a validation error.
+ await user.clear(screen.getByLabelText('Role Name'));
+ // Switch to a different tab.
+ await user.click(screen.getByRole('tab', { name: 'Resources' }));
+ await user.click(screen.getByRole('button', { name: 'Create Role' }));
+ expect(onSave).not.toHaveBeenCalled();
+
+ // Switch back, make it valid.
+ await user.click(screen.getByRole('tab', { name: 'Invalid data Overview' }));
+ await user.type(screen.getByLabelText('Role Name'), 'foo');
+ await user.click(screen.getByRole('button', { name: 'Create Role' }));
+ expect(onSave).toHaveBeenCalled();
+});
+
const getAllMenuItemNames = () =>
screen.queryAllByRole('menuitem').map(m => m.textContent);
@@ -152,67 +169,105 @@ const StatefulSection = ({
defaultValue,
component: Component,
onChange,
+ validatorRef,
}: {
defaultValue: S;
- component: React.ComponentType>;
+ component: React.ComponentType>;
onChange(spec: S): void;
+ validatorRef?(v: Validator): void;
}) => {
const [model, setModel] = useState(defaultValue);
+ const validation = validateAccessSpec(model);
return (
- {
- setModel(spec);
- onChange(spec);
- }}
- />
+ {({ validator }) => {
+ validatorRef?.(validator);
+ return (
+ {
+ setModel(spec);
+ onChange(spec);
+ }}
+ />
+ );
+ }}
);
};
-test('ServerAccessSpecSection', async () => {
- const user = userEvent.setup();
- const onChange = jest.fn();
- render(
-
- component={ServerAccessSpecSection}
- defaultValue={newAccessSpec('node')}
- onChange={onChange}
- />
- );
- await user.click(screen.getByRole('button', { name: 'Add a Label' }));
- await user.type(screen.getByPlaceholderText('label key'), 'some-key');
- await user.type(screen.getByPlaceholderText('label value'), 'some-value');
- await selectEvent.create(screen.getByLabelText('Logins'), 'root', {
- createOptionText: 'Login: root',
- });
- await selectEvent.create(screen.getByLabelText('Logins'), 'some-user', {
- createOptionText: 'Login: some-user',
+describe('ServerAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={ServerAccessSpecSection}
+ defaultValue={newAccessSpec('node')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
+
+ test('editing', async () => {
+ const { user, onChange } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.type(screen.getByPlaceholderText('label key'), 'some-key');
+ await user.type(screen.getByPlaceholderText('label value'), 'some-value');
+ await selectEvent.create(screen.getByLabelText('Logins'), 'root', {
+ createOptionText: 'Login: root',
+ });
+ await selectEvent.create(screen.getByLabelText('Logins'), 'some-user', {
+ createOptionText: 'Login: some-user',
+ });
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'node',
+ labels: [{ name: 'some-key', value: 'some-value' }],
+ logins: [
+ expect.objectContaining({ label: 'root', value: 'root' }),
+ expect.objectContaining({ label: 'some-user', value: 'some-user' }),
+ ],
+ } as ServerAccessSpec);
});
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'node',
- labels: [{ name: 'some-key', value: 'some-value' }],
- logins: [
- expect.objectContaining({ label: 'root', value: 'root' }),
- expect.objectContaining({ label: 'some-user', value: 'some-user' }),
- ],
- } as ServerAccessSpec);
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await selectEvent.create(screen.getByLabelText('Logins'), '*', {
+ createOptionText: 'Login: *',
+ });
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
+ expect(
+ screen.getByText('Wildcard is not allowed in logins')
+ ).toBeInTheDocument();
+ });
});
describe('KubernetesAccessSpecSection', () => {
const setup = () => {
const onChange = jest.fn();
+ let validator: Validator;
render(
component={KubernetesAccessSpecSection}
defaultValue={newAccessSpec('kube_cluster')}
onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
/>
);
- return { user: userEvent.setup(), onChange };
+ return { user: userEvent.setup(), onChange, validator };
};
test('editing the spec', async () => {
@@ -319,105 +374,199 @@ describe('KubernetesAccessSpecSection', () => {
expect.objectContaining({ resources: [] })
);
});
+
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.click(screen.getByRole('button', { name: 'Add a Resource' }));
+ await user.clear(screen.getByLabelText('Name'));
+ await user.clear(screen.getByLabelText('Namespace'));
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
+ expect(screen.getByLabelText('Name')).toHaveAccessibleDescription(
+ 'Resource name is required, use "*" for any resource'
+ );
+ expect(screen.getByLabelText('Namespace')).toHaveAccessibleDescription(
+ 'Namespace is required for resources of this kind'
+ );
+ });
});
-test('AppAccessSpecSection', async () => {
- const user = userEvent.setup();
- const onChange = jest.fn();
- render(
-
- component={AppAccessSpecSection}
- defaultValue={newAccessSpec('app')}
- onChange={onChange}
- />
- );
+describe('AppAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={AppAccessSpecSection}
+ defaultValue={newAccessSpec('app')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
- await user.click(screen.getByRole('button', { name: 'Add a Label' }));
- await user.type(screen.getByPlaceholderText('label key'), 'env');
- await user.type(screen.getByPlaceholderText('label value'), 'prod');
- await user.type(
+ const awsRoleArn = () =>
within(screen.getByRole('group', { name: 'AWS Role ARNs' })).getByRole(
'textbox'
- ),
- 'arn:aws:iam::123456789012:role/admin'
- );
- await user.type(
+ );
+ const azureIdentity = () =>
within(screen.getByRole('group', { name: 'Azure Identities' })).getByRole(
'textbox'
- ),
- '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin'
- );
- await user.type(
+ );
+ const gcpServiceAccount = () =>
within(
screen.getByRole('group', { name: 'GCP Service Accounts' })
- ).getByRole('textbox'),
- 'admin@some-project.iam.gserviceaccount.com'
- );
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'app',
- labels: [{ name: 'env', value: 'prod' }],
- awsRoleARNs: ['arn:aws:iam::123456789012:role/admin'],
- azureIdentities: [
- '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin',
- ],
- gcpServiceAccounts: ['admin@some-project.iam.gserviceaccount.com'],
- } as AppAccessSpec);
-});
+ ).getByRole('textbox');
-test('DatabaseAccessSpecSection', async () => {
- const user = userEvent.setup();
- const onChange = jest.fn();
- render(
-
- component={DatabaseAccessSpecSection}
- defaultValue={newAccessSpec('db')}
- onChange={onChange}
- />
- );
+ test('editing', async () => {
+ const { user, onChange } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.type(screen.getByPlaceholderText('label key'), 'env');
+ await user.type(screen.getByPlaceholderText('label value'), 'prod');
+ await user.type(awsRoleArn(), 'arn:aws:iam::123456789012:role/admin');
+ await user.type(
+ azureIdentity(),
+ '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin'
+ );
+ await user.type(
+ gcpServiceAccount(),
+ 'admin@some-project.iam.gserviceaccount.com'
+ );
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'app',
+ labels: [{ name: 'env', value: 'prod' }],
+ awsRoleARNs: ['arn:aws:iam::123456789012:role/admin'],
+ azureIdentities: [
+ '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin',
+ ],
+ gcpServiceAccounts: ['admin@some-project.iam.gserviceaccount.com'],
+ } as AppAccessSpec);
+ });
- await user.click(screen.getByRole('button', { name: 'Add a Label' }));
- await user.type(screen.getByPlaceholderText('label key'), 'env');
- await user.type(screen.getByPlaceholderText('label value'), 'prod');
- await selectEvent.create(screen.getByLabelText('Database Names'), 'stuff', {
- createOptionText: 'Database Name: stuff',
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.type(awsRoleArn(), '*');
+ await user.type(azureIdentity(), '*');
+ await user.type(gcpServiceAccount(), '*');
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
+ expect(awsRoleArn()).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in AWS role ARNs'
+ );
+ expect(azureIdentity()).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in Azure identities'
+ );
+ expect(gcpServiceAccount()).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in GCP service accounts'
+ );
});
- await selectEvent.create(screen.getByLabelText('Database Users'), 'mary', {
- createOptionText: 'Database User: mary',
+});
+
+describe('DatabaseAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={DatabaseAccessSpecSection}
+ defaultValue={newAccessSpec('db')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
+
+ test('editing', async () => {
+ const { user, onChange } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.type(screen.getByPlaceholderText('label key'), 'env');
+ await user.type(screen.getByPlaceholderText('label value'), 'prod');
+ await selectEvent.create(screen.getByLabelText('Database Names'), 'stuff', {
+ createOptionText: 'Database Name: stuff',
+ });
+ await selectEvent.create(screen.getByLabelText('Database Users'), 'mary', {
+ createOptionText: 'Database User: mary',
+ });
+ await selectEvent.create(screen.getByLabelText('Database Roles'), 'admin', {
+ createOptionText: 'Database Role: admin',
+ });
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'db',
+ labels: [{ name: 'env', value: 'prod' }],
+ names: [expect.objectContaining({ label: 'stuff', value: 'stuff' })],
+ roles: [expect.objectContaining({ label: 'admin', value: 'admin' })],
+ users: [expect.objectContaining({ label: 'mary', value: 'mary' })],
+ } as DatabaseAccessSpec);
});
- await selectEvent.create(screen.getByLabelText('Database Roles'), 'admin', {
- createOptionText: 'Database Role: admin',
+
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await selectEvent.create(screen.getByLabelText('Database Roles'), '*', {
+ createOptionText: 'Database Role: *',
+ });
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
+ expect(
+ screen.getByText('Wildcard is not allowed in database roles')
+ ).toBeInTheDocument();
});
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'db',
- labels: [{ name: 'env', value: 'prod' }],
- names: [expect.objectContaining({ label: 'stuff', value: 'stuff' })],
- roles: [expect.objectContaining({ label: 'admin', value: 'admin' })],
- users: [expect.objectContaining({ label: 'mary', value: 'mary' })],
- } as DatabaseAccessSpec);
});
-test('WindowsDesktopAccessSpecSection', async () => {
- const user = userEvent.setup();
- const onChange = jest.fn();
- render(
-
- component={WindowsDesktopAccessSpecSection}
- defaultValue={newAccessSpec('windows_desktop')}
- onChange={onChange}
- />
- );
+describe('WindowsDesktopAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={WindowsDesktopAccessSpecSection}
+ defaultValue={newAccessSpec('windows_desktop')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
- await user.click(screen.getByRole('button', { name: 'Add a Label' }));
- await user.type(screen.getByPlaceholderText('label key'), 'os');
- await user.type(screen.getByPlaceholderText('label value'), 'win-xp');
- await selectEvent.create(screen.getByLabelText('Logins'), 'julio', {
- createOptionText: 'Login: julio',
+ test('editing', async () => {
+ const { user, onChange } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.type(screen.getByPlaceholderText('label key'), 'os');
+ await user.type(screen.getByPlaceholderText('label value'), 'win-xp');
+ await selectEvent.create(screen.getByLabelText('Logins'), 'julio', {
+ createOptionText: 'Login: julio',
+ });
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'windows_desktop',
+ labels: [{ name: 'os', value: 'win-xp' }],
+ logins: [expect.objectContaining({ label: 'julio', value: 'julio' })],
+ } as WindowsDesktopAccessSpec);
+ });
+
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
});
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'windows_desktop',
- labels: [{ name: 'os', value: 'win-xp' }],
- logins: [expect.objectContaining({ label: 'julio', value: 'julio' })],
- } as WindowsDesktopAccessSpec);
});
const reactSelectValueContainer = (input: HTMLInputElement) =>
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
index 01789e1f2f837..bf1567ee235cd 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-import React, { useState } from 'react';
+import React, { useId, useState } from 'react';
import {
Box,
ButtonIcon,
@@ -28,21 +28,25 @@ import {
Text,
} from 'design';
import FieldInput from 'shared/components/FieldInput';
-import Validation, { Validator } from 'shared/components/Validation';
-import { requiredField } from 'shared/components/Validation/rules';
+import Validation, {
+ useValidation,
+ Validator,
+} from 'shared/components/Validation';
+import {
+ precomputed,
+ ValidationResult,
+} from 'shared/components/Validation/rules';
import * as Icon from 'design/Icon';
-import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip';
+import { HoverTooltip, IconTooltip } from 'design/Tooltip';
import styled, { useTheme } from 'styled-components';
-
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
-
import {
FieldSelect,
FieldSelectCreatable,
} from 'shared/components/FieldSelect';
+import { SlideTabs } from 'design/SlideTabs';
import { Role, RoleWithYaml } from 'teleport/services/resources';
-
import { LabelsInput } from 'teleport/components/LabelsInput';
import { FieldMultiInput } from '../../../../shared/components/FieldMultiInput/FieldMultiInput';
@@ -66,6 +70,17 @@ import {
DatabaseAccessSpec,
WindowsDesktopAccessSpec,
} from './standardmodel';
+import {
+ validateRoleEditorModel,
+ MetadataValidationResult,
+ AccessSpecValidationResult,
+ ServerSpecValidationResult,
+ KubernetesSpecValidationResult,
+ KubernetesResourceValidationResult,
+ AppSpecValidationResult,
+ DatabaseSpecValidationResult,
+ WindowsDesktopSpecValidationResult,
+} from './validation';
import { EditorSaveCancelButton } from './Shared';
import { RequiresResetToStandard } from './RequiresResetToStandard';
@@ -92,12 +107,27 @@ export const StandardEditor = ({
}: StandardEditorProps) => {
const isEditing = !!originalRole;
const { roleModel } = standardEditorModel;
+ const validation = validateRoleEditorModel(roleModel);
/** All spec kinds except those that are already in the role. */
const allowedSpecKinds = allAccessSpecKinds.filter(k =>
roleModel.accessSpecs.every(as => as.kind !== k)
);
+ enum StandardEditorTab {
+ Overview,
+ Resources,
+ AdminRules,
+ Options,
+ }
+
+ const [currentTab, setCurrentTab] = useState(StandardEditorTab.Overview);
+ const idPrefix = useId();
+ const overviewTabId = `${idPrefix}-overview`;
+ const resourcesTabId = `${idPrefix}-resources`;
+ const adminRulesTabId = `${idPrefix}-admin-rules`;
+ const optionsTabId = `${idPrefix}-options`;
+
function handleSave(validator: Validator) {
if (!validator.validate()) {
return;
@@ -169,53 +199,113 @@ export const StandardEditor = ({
mute={standardEditorModel.roleModel.requiresReset}
data-testid="standard-editor"
>
-
+
+ !s.valid)
+ ? validationErrorTabStatus
+ : undefined,
+ },
+ {
+ key: StandardEditorTab.AdminRules,
+ title: 'Admin Rules',
+ controls: adminRulesTabId,
+ },
+ {
+ key: StandardEditorTab.Options,
+ title: 'Options',
+ controls: optionsTabId,
+ },
+ ]}
+ activeIndex={currentTab}
+ onChange={setCurrentTab}
+ />
+
+
handleChange({ ...roleModel, metadata })}
/>
- {roleModel.accessSpecs.map(spec => (
- setAccessSpec(value)}
- onRemove={() => removeAccessSpec(spec.kind)}
- />
- ))}
-
-
-
- Add New Specifications
- >
- }
- buttonProps={{
- size: 'medium',
- fill: 'filled',
- disabled: isProcessing || allowedSpecKinds.length === 0,
- }}
- >
- {allowedSpecKinds.map(kind => (
-
- ))}
-
-
-
+
+
+
+ {roleModel.accessSpecs.map((spec, i) => {
+ const validationResult = validation.accessSpecs[i];
+ return (
+ setAccessSpec(value)}
+ onRemove={() => removeAccessSpec(spec.kind)}
+ />
+ );
+ })}
+
+
+
+ Add New Specifications
+ >
+ }
+ buttonProps={{
+ size: 'medium',
+ fill: 'filled',
+ disabled: isProcessing || allowedSpecKinds.length === 0,
+ }}
+ >
+ {allowedSpecKinds.map(kind => (
+
+ ))}
+
+
+
+
handleSave(validator)}
@@ -233,28 +323,36 @@ export const StandardEditor = ({
);
};
-export type SectionProps = {
+export type SectionProps = {
value: T;
isProcessing: boolean;
+ validation?: V;
onChange?(value: T): void;
};
+const validationErrorTabStatus = {
+ kind: 'danger',
+ ariaLabel: 'Invalid data',
+} as const;
+
const MetadataSection = ({
value,
isProcessing,
+ validation,
onChange,
-}: SectionProps) => (
+}: SectionProps) => (
onChange({ ...value, name: e.target.value })}
/>
) => {
const theme = useTheme();
const [expanded, setExpanded] = useState(true);
const ExpandIcon = expanded ? Icon.Minus : Icon.Plus;
const expandTooltip = expanded ? 'Collapse' : 'Expand';
+ const validator = useValidation();
const handleExpand = (e: React.MouseEvent) => {
// Don't let handle the event, we'll do it ourselves to keep
@@ -311,7 +412,11 @@ const Section = ({
as="details"
open={expanded}
border={1}
- borderColor={theme.colors.interactive.tonal.neutral[0]}
+ borderColor={
+ validator.state.validating && !validation.valid
+ ? theme.colors.interactive.solid.danger.default
+ : theme.colors.interactive.tonal.neutral[0]
+ }
borderRadius={3}
>
{title}
- {tooltip && {tooltip}}
+ {tooltip && {tooltip}}
{removable && (
>;
+ component: React.ComponentType>;
}
> = {
kube_cluster: {
@@ -409,12 +514,16 @@ const specSections: Record<
* A generic access spec section. Details are rendered by components from the
* `specSections` map.
*/
-const AccessSpecSection = ({
+const AccessSpecSection = <
+ T extends AccessSpec,
+ V extends AccessSpecValidationResult,
+>({
value,
isProcessing,
+ validation,
onChange,
onRemove,
-}: SectionProps & {
+}: SectionProps & {
onRemove?(): void;
}) => {
const { component: Body, title, tooltip } = specSections[value.kind];
@@ -425,8 +534,14 @@ const AccessSpecSection = ({
onRemove={onRemove}
tooltip={tooltip}
isProcessing={isProcessing}
+ validation={validation}
>
-
+
);
};
@@ -434,8 +549,9 @@ const AccessSpecSection = ({
export function ServerAccessSpecSection({
value,
isProcessing,
+ validation,
onChange,
-}: SectionProps) {
+}: SectionProps) {
return (
<>
@@ -445,6 +561,7 @@ export function ServerAccessSpecSection({
disableBtns={isProcessing}
labels={value.labels}
setLabels={labels => onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
/>
onChange?.({ ...value, logins })}
+ rule={precomputed(validation.fields.logins)}
mt={3}
mb={0}
/>
@@ -467,8 +585,9 @@ export function ServerAccessSpecSection({
export function KubernetesAccessSpecSection({
value,
isProcessing,
+ validation,
onChange,
-}: SectionProps) {
+}: SectionProps) {
return (
<>
onChange?.({ ...value, labels })}
/>
@@ -498,6 +618,7 @@ export function KubernetesAccessSpecSection({
onChange?.({
@@ -540,11 +661,13 @@ export function KubernetesAccessSpecSection({
function KubernetesResourceView({
value,
+ validation,
isProcessing,
onChange,
onRemove,
}: {
value: KubernetesResourceModel;
+ validation: KubernetesResourceValidationResult;
isProcessing: boolean;
onChange(m: KubernetesResourceModel): void;
onRemove(): void;
@@ -590,6 +713,7 @@ function KubernetesResourceView({
}
disabled={isProcessing}
value={name}
+ rule={precomputed(validation.name)}
onChange={e => onChange?.({ ...value, name: e.target.value })}
/>
onChange?.({ ...value, namespace: e.target.value })}
/>
) {
+}: SectionProps) {
return (
@@ -632,6 +758,7 @@ export function AppAccessSpecSection({
disableBtns={isProcessing}
labels={value.labels}
setLabels={labels => onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
/>
onChange?.({ ...value, awsRoleARNs: arns })}
+ rule={precomputed(validation.fields.awsRoleARNs)}
/>
onChange?.({ ...value, azureIdentities: ids })}
+ rule={precomputed(validation.fields.azureIdentities)}
/>
onChange?.({ ...value, gcpServiceAccounts: accts })}
+ rule={precomputed(validation.fields.gcpServiceAccounts)}
/>
);
@@ -659,8 +789,9 @@ export function AppAccessSpecSection({
export function DatabaseAccessSpecSection({
value,
isProcessing,
+ validation,
onChange,
-}: SectionProps) {
+}: SectionProps) {
return (
<>
@@ -671,6 +802,7 @@ export function DatabaseAccessSpecSection({
disableBtns={isProcessing}
labels={value.labels}
setLabels={labels => onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
/>
onChange?.({ ...value, roles })}
+ rule={precomputed(validation.fields.roles)}
mb={0}
/>
>
@@ -730,8 +863,9 @@ export function DatabaseAccessSpecSection({
export function WindowsDesktopAccessSpecSection({
value,
isProcessing,
+ validation,
onChange,
-}: SectionProps) {
+}: SectionProps) {
return (
<>
@@ -742,6 +876,7 @@ export function WindowsDesktopAccessSpecSection({
disableBtns={isProcessing}
labels={value.labels}
setLabels={labels => onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
/>
;
-type KubernetesResourceKind =
- | '*'
- | 'pod'
- | 'secret'
- | 'configmap'
- | 'namespace'
- | 'service'
- | 'serviceaccount'
- | 'kube_node'
- | 'persistentvolume'
- | 'persistentvolumeclaim'
- | 'deployment'
- | 'replicaset'
- | 'statefulset'
- | 'daemonset'
- | 'clusterrole'
- | 'kube_role'
- | 'clusterrolebinding'
- | 'rolebinding'
- | 'cronjob'
- | 'job'
- | 'certificatesigningrequest'
- | 'ingress';
/**
* All possible resource kind drop-down options. This array needs to be kept in
@@ -172,19 +151,6 @@ export const kubernetesResourceKindOptions: KubernetesResourceKindOption[] = [
];
type KubernetesVerbOption = Option;
-type KubernetesVerb =
- | '*'
- | 'get'
- | 'create'
- | 'update'
- | 'patch'
- | 'delete'
- | 'list'
- | 'watch'
- | 'deletecollection'
- | 'exec'
- | 'portforward';
-
/**
* All possible Kubernetes verb drop-down options. This array needs to be kept
* in sync with `KubernetesVerbs` in `api/types/constants.go.
@@ -590,7 +556,7 @@ export function labelsModelToLabels(uiLabels: UILabel[]): Labels {
return labels;
}
-function optionsToStrings(opts: readonly Option[]): string[] {
+function optionsToStrings(opts: readonly Option[]): T[] {
return opts.map(opt => opt.value);
}
diff --git a/web/packages/teleport/src/Roles/RoleEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/validation.ts
new file mode 100644
index 0000000000000..95cde89ed036c
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/validation.ts
@@ -0,0 +1,168 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 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 {
+ arrayOf,
+ requiredField,
+ RuleSetValidationResult,
+ runRules,
+ ValidationResult,
+} from 'shared/components/Validation/rules';
+
+import { Option } from 'shared/components/Select';
+
+import { KubernetesResourceKind } from 'teleport/services/resources';
+
+import { nonEmptyLabels } from 'teleport/components/LabelsInput/LabelsInput';
+
+import {
+ AccessSpec,
+ KubernetesResourceModel,
+ MetadataModel,
+ RoleEditorModel,
+} from './standardmodel';
+
+const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [
+ 'namespace',
+ 'kube_node',
+ 'persistentvolume',
+ 'clusterrole',
+ 'clusterrolebinding',
+ 'certificatesigningrequest',
+];
+
+export function validateRoleEditorModel({
+ metadata,
+ accessSpecs,
+}: RoleEditorModel) {
+ return {
+ metadata: validateMetadata(metadata),
+ accessSpecs: accessSpecs.map(validateAccessSpec),
+ };
+}
+
+function validateMetadata(model: MetadataModel): MetadataValidationResult {
+ return runRules(model, metadataRules);
+}
+
+const metadataRules = { name: requiredField('Role name is required') };
+export type MetadataValidationResult = RuleSetValidationResult<
+ typeof metadataRules
+>;
+
+export function validateAccessSpec(
+ spec: AccessSpec
+): AccessSpecValidationResult {
+ const { kind } = spec;
+ switch (kind) {
+ case 'kube_cluster':
+ return runRules(spec, kubernetesValidationRules);
+ case 'node':
+ return runRules(spec, serverValidationRules);
+ case 'app':
+ return runRules(spec, appSpecValidationRules);
+ case 'db':
+ return runRules(spec, databaseSpecValidationRules);
+ case 'windows_desktop':
+ return runRules(spec, windowsDesktopSpecValidationRules);
+ default:
+ kind satisfies never;
+ }
+}
+
+export type AccessSpecValidationResult =
+ | ServerSpecValidationResult
+ | KubernetesSpecValidationResult
+ | AppSpecValidationResult
+ | DatabaseSpecValidationResult
+ | WindowsDesktopSpecValidationResult;
+
+const validKubernetesResource = (res: KubernetesResourceModel) => () => {
+ const name = requiredField(
+ 'Resource name is required, use "*" for any resource'
+ )(res.name)();
+ const namespace = kubernetesClusterWideResourceKinds.includes(res.kind.value)
+ ? { valid: true }
+ : requiredField('Namespace is required for resources of this kind')(
+ res.namespace
+ )();
+ return {
+ valid: name.valid && namespace.valid,
+ name,
+ namespace,
+ };
+};
+export type KubernetesResourceValidationResult = {
+ name: ValidationResult;
+ namespace: ValidationResult;
+};
+
+const kubernetesValidationRules = {
+ labels: nonEmptyLabels,
+ resources: arrayOf(validKubernetesResource),
+};
+export type KubernetesSpecValidationResult = RuleSetValidationResult<
+ typeof kubernetesValidationRules
+>;
+
+const noWildcard = (message: string) => (value: string) => () => {
+ const valid = value !== '*';
+ return { valid, message: valid ? '' : message };
+};
+
+const noWildcardOptions = (message: string) => (options: Option[]) => () => {
+ const valid = options.every(o => o.value !== '*');
+ return { valid, message: valid ? '' : message };
+};
+
+const serverValidationRules = {
+ labels: nonEmptyLabels,
+ logins: noWildcardOptions('Wildcard is not allowed in logins'),
+};
+export type ServerSpecValidationResult = RuleSetValidationResult<
+ typeof serverValidationRules
+>;
+
+const appSpecValidationRules = {
+ labels: nonEmptyLabels,
+ awsRoleARNs: arrayOf(noWildcard('Wildcard is not allowed in AWS role ARNs')),
+ azureIdentities: arrayOf(
+ noWildcard('Wildcard is not allowed in Azure identities')
+ ),
+ gcpServiceAccounts: arrayOf(
+ noWildcard('Wildcard is not allowed in GCP service accounts')
+ ),
+};
+export type AppSpecValidationResult = RuleSetValidationResult<
+ typeof appSpecValidationRules
+>;
+
+const databaseSpecValidationRules = {
+ labels: nonEmptyLabels,
+ roles: noWildcardOptions('Wildcard is not allowed in database roles'),
+};
+export type DatabaseSpecValidationResult = RuleSetValidationResult<
+ typeof databaseSpecValidationRules
+>;
+
+const windowsDesktopSpecValidationRules = {
+ labels: nonEmptyLabels,
+};
+export type WindowsDesktopSpecValidationResult = RuleSetValidationResult<
+ typeof windowsDesktopSpecValidationRules
+>;
diff --git a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx
index d537eba3a43ad..5641442e43d78 100644
--- a/web/packages/teleport/src/Roles/RoleList/RoleList.tsx
+++ b/web/packages/teleport/src/Roles/RoleList/RoleList.tsx
@@ -24,6 +24,7 @@ import { SearchPanel } from 'shared/components/Search';
import { SeversidePagination } from 'teleport/components/hooks/useServersidePagination';
import { RoleResource } from 'teleport/services/resources';
+import { Access } from 'teleport/services/user';
export function RoleList({
onEdit,
@@ -31,13 +32,18 @@ export function RoleList({
onSearchChange,
search,
serversidePagination,
+ rolesAcl,
}: {
onEdit(id: string): void;
onDelete(id: string): void;
onSearchChange(search: string): void;
search: string;
serversidePagination: SeversidePagination;
+ rolesAcl: Access;
}) {
+ const canEdit = rolesAcl.edit;
+ const canDelete = rolesAcl.remove;
+
return (
(
onEdit(role.id)}
onDelete={() => onDelete(role.id)}
/>
@@ -80,12 +88,22 @@ export function RoleList({
);
}
-const ActionCell = (props: { onEdit(): void; onDelete(): void }) => {
+const ActionCell = (props: {
+ canEdit: boolean;
+ canDelete: boolean;
+ onEdit(): void;
+ onDelete(): void;
+}) => {
+ if (!(props.canEdit || props.canDelete)) {
+ return | ;
+ }
return (
-
-
+ {props.canEdit && }
+ {props.canDelete && (
+
+ )}
|
);
diff --git a/web/packages/teleport/src/Roles/Roles.story.tsx b/web/packages/teleport/src/Roles/Roles.story.tsx
index c0d197a8f0196..f5be3186c0eaf 100644
--- a/web/packages/teleport/src/Roles/Roles.story.tsx
+++ b/web/packages/teleport/src/Roles/Roles.story.tsx
@@ -81,4 +81,11 @@ const sample = {
remove: () => null,
create: () => null,
update: () => null,
+ rolesAcl: {
+ list: true,
+ create: true,
+ remove: true,
+ edit: true,
+ read: true,
+ },
};
diff --git a/web/packages/teleport/src/Roles/Roles.test.tsx b/web/packages/teleport/src/Roles/Roles.test.tsx
new file mode 100644
index 0000000000000..1edae5ee235e6
--- /dev/null
+++ b/web/packages/teleport/src/Roles/Roles.test.tsx
@@ -0,0 +1,211 @@
+/**
+ * Teleport
+ * Copyright (C) 2024 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 { MemoryRouter } from 'react-router';
+import { render, screen, fireEvent, waitFor } from 'design/utils/testing';
+
+import { ContextProvider } from 'teleport';
+import { createTeleportContext } from 'teleport/mocks/contexts';
+
+import { Roles } from './Roles';
+import { State } from './useRoles';
+
+describe('Roles list', () => {
+ const defaultState: State = {
+ create: jest.fn(),
+ fetch: jest.fn(),
+ remove: jest.fn(),
+ update: jest.fn(),
+ rolesAcl: {
+ read: true,
+ remove: true,
+ create: true,
+ edit: true,
+ list: true,
+ },
+ };
+
+ beforeEach(() => {
+ jest.spyOn(defaultState, 'fetch').mockResolvedValue({
+ startKey: '',
+ items: [
+ {
+ content: '',
+ id: '1',
+ kind: 'role',
+ name: 'cool-role',
+ description: 'coolest-role',
+ },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('button is enabled if user has create perms', async () => {
+ const ctx = createTeleportContext();
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create_new_role_button')).toBeEnabled();
+ });
+ });
+
+ test('displays disabled create button', async () => {
+ const ctx = createTeleportContext();
+ const testState = {
+ ...defaultState,
+ rolesAcl: {
+ ...defaultState.rolesAcl,
+ create: false,
+ },
+ };
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('create_new_role_button')).toBeDisabled();
+ });
+ });
+
+ test('all options available', async () => {
+ const ctx = createTeleportContext();
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /options/i })
+ ).toBeInTheDocument();
+ });
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(2);
+ });
+
+ test('hides edit button if no access', async () => {
+ const ctx = createTeleportContext();
+ const testState = {
+ ...defaultState,
+ rolesAcl: {
+ ...defaultState.rolesAcl,
+ edit: false,
+ },
+ };
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /options/i })
+ ).toBeInTheDocument();
+ });
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(1);
+ expect(menuItems.every(item => item.textContent.includes('Edit'))).not.toBe(
+ true
+ );
+ });
+
+ test('hides delete button if no access', async () => {
+ const ctx = createTeleportContext();
+ const testState = {
+ ...defaultState,
+ rolesAcl: {
+ ...defaultState.rolesAcl,
+ remove: false,
+ },
+ };
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: /options/i })
+ ).toBeInTheDocument();
+ });
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(1);
+ expect(
+ menuItems.every(item => item.textContent.includes('Delete'))
+ ).not.toBe(true);
+ });
+
+ test('hides Options button if no permissions to edit or delete', async () => {
+ const ctx = createTeleportContext();
+ const testState = {
+ ...defaultState,
+ rolesAcl: {
+ ...defaultState.rolesAcl,
+ remove: false,
+ edit: false,
+ },
+ };
+
+ render(
+
+
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('cool-role')).toBeInTheDocument();
+ });
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(0);
+ });
+});
diff --git a/web/packages/teleport/src/Roles/Roles.tsx b/web/packages/teleport/src/Roles/Roles.tsx
index d04034b475609..2419052bf8db1 100644
--- a/web/packages/teleport/src/Roles/Roles.tsx
+++ b/web/packages/teleport/src/Roles/Roles.tsx
@@ -22,6 +22,8 @@ import { P } from 'design/Text/Text';
import { useAsync } from 'shared/hooks/useAsync';
import { Danger } from 'design/Alert';
import { useTheme } from 'styled-components';
+import { MissingPermissionsTooltip } from 'shared/components/MissingPermissionsTooltip';
+import { HoverTooltip } from 'shared/components/ToolTip';
import {
FeatureBox,
@@ -55,7 +57,7 @@ export function RolesContainer() {
const useNewRoleEditor = storageService.getUseNewRoleEditor();
export function Roles(props: State) {
- const { remove, create, update, fetch } = props;
+ const { remove, create, update, fetch, rolesAcl } = props;
const [search, setSearch] = useState('');
const serverSidePagination = useServerSidePagination({
@@ -142,36 +144,54 @@ export function Roles(props: State) {
}
}
+ const canCreate = rolesAcl.create;
+
return (
-
+
Roles
-
+ ) : (
+ ''
+ )
}
- ml="auto"
- width="240px"
- onClick={handleCreate}
>
- Create New Role
-
+
+
{serverSidePagination.attempt.status === 'failed' && (
)}
-
+
@@ -279,7 +299,7 @@ function RoleEditorAdapter({
p={4}
borderLeft={1}
borderColor={theme.colors.interactive.tonal.neutral[0]}
- width="900px"
+ width="700px"
>
{convertAttempt.status === 'processing' && (
) {
return ctx.resourceService.createRole(await toYaml(role));
}
@@ -45,6 +47,7 @@ export function useRoles(ctx: TeleportContext) {
create,
update,
remove,
+ rolesAcl,
};
}
diff --git a/web/packages/teleport/src/Support/Support.tsx b/web/packages/teleport/src/Support/Support.tsx
index 59012984cb941..7bff9ae9e7ec9 100644
--- a/web/packages/teleport/src/Support/Support.tsx
+++ b/web/packages/teleport/src/Support/Support.tsx
@@ -96,6 +96,27 @@ export const Support = ({
{isEnterprise && !cfg.isCloud && licenseExpiryDateText && (
)}
+ {isCloud && (
+
+
+
+ Looking for{' '}
+
+ Scheduled Upgrades?
+ {' '}
+ It is now in{' '}
+
+ Cluster Management
+ {' '}
+ page.
+
+
+ )}
diff --git a/web/packages/teleport/src/TopBar/TopBar.tsx b/web/packages/teleport/src/TopBar/TopBar.tsx
index 20c828f7c5510..e19e71ca780cd 100644
--- a/web/packages/teleport/src/TopBar/TopBar.tsx
+++ b/web/packages/teleport/src/TopBar/TopBar.tsx
@@ -23,7 +23,7 @@ import { Flex, Image, Text, TopNav } from 'design';
import { matchPath, useHistory } from 'react-router';
import { Theme } from 'design/theme/themes/types';
import { ArrowLeft, Download, Server, SlidersVertical } from 'design/Icon';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import useTeleport from 'teleport/useTeleport';
import { UserMenuNav } from 'teleport/components/UserMenuNav';
diff --git a/web/packages/teleport/src/TopBar/TopBarSideNav.tsx b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx
index c787f984fa763..54b8b95bcd8e2 100644
--- a/web/packages/teleport/src/TopBar/TopBarSideNav.tsx
+++ b/web/packages/teleport/src/TopBar/TopBarSideNav.tsx
@@ -22,7 +22,7 @@ import { Link } from 'react-router-dom';
import { Flex, Image, TopNav } from 'design';
import { matchPath, useHistory } from 'react-router';
import { Theme } from 'design/theme/themes/types';
-import { HoverTooltip } from 'shared/components/ToolTip';
+import { HoverTooltip } from 'design/Tooltip';
import useTeleport from 'teleport/useTeleport';
import { UserMenuNav } from 'teleport/components/UserMenuNav';
diff --git a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
index 8ca13cd5ce0a4..0e2b314736e12 100644
--- a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
+++ b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
@@ -46,7 +46,6 @@ import {
import { useNoMinWidth } from 'teleport/Main';
import AgentButtonAdd from 'teleport/components/AgentButtonAdd';
import { SearchResource } from 'teleport/Discover/SelectResource';
-import { encodeUrlQueryParams } from 'teleport/components/hooks/useUrlFiltering';
import Empty, { EmptyStateInfo } from 'teleport/components/Empty';
import { FeatureFlags } from 'teleport/types';
import { UnifiedResource } from 'teleport/services/agents';
@@ -132,7 +131,7 @@ export function ClusterResources({
const canCreate = teleCtx.storeUser.getTokenAccess().create;
const [loadClusterError, setLoadClusterError] = useState('');
- const { params, setParams, replaceHistory, pathname } = useUrlFiltering(
+ const { params, setParams } = useUrlFiltering(
{
sort: {
fieldName: 'name',
@@ -274,22 +273,7 @@ export function ClusterResources({
) || ,
},
}))}
- setParams={newParams => {
- setParams(newParams);
- const isAdvancedSearch = !!newParams.query;
- replaceHistory(
- encodeUrlQueryParams({
- pathname,
- searchString: isAdvancedSearch
- ? newParams.query
- : newParams.search,
- sort: newParams.sort,
- kinds: newParams.kinds,
- isAdvancedSearch,
- pinnedOnly: newParams.pinnedOnly,
- })
- );
- }}
+ setParams={setParams}
Header={
<>
-
+
>
}
diff --git a/web/packages/teleport/src/Users/UserList/UserList.tsx b/web/packages/teleport/src/Users/UserList/UserList.tsx
index 34fb89d08e426..00ad7f5de34cf 100644
--- a/web/packages/teleport/src/Users/UserList/UserList.tsx
+++ b/web/packages/teleport/src/Users/UserList/UserList.tsx
@@ -20,7 +20,7 @@ import React from 'react';
import { Cell, LabelCell } from 'design/DataTable';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
-import { User, UserOrigin } from 'teleport/services/user';
+import { Access, User, UserOrigin } from 'teleport/services/user';
import { ClientSearcheableTableWithQueryParamSupport } from 'teleport/components/ClientSearcheableTableWithQueryParamSupport';
export default function UserList({
@@ -29,6 +29,7 @@ export default function UserList({
onEdit,
onDelete,
onReset,
+ usersAcl,
}: Props) {
return (
(
void;
onReset: (user: User) => void;
onDelete: (user: User) => void;
+ acl: Access;
}) => {
+ const canEdit = acl.edit;
+ const canDelete = acl.remove;
+
+ if (!(canEdit || canDelete)) {
+ return | ;
+ }
+
if (user.isBot || !user.isLocal) {
return | ;
}
@@ -131,11 +142,15 @@ const ActionCell = ({
return (
-
-
-
+ {canEdit && }
+ {canEdit && (
+
+ )}
+ {canDelete && (
+
+ )}
|
);
@@ -147,4 +162,7 @@ type Props = {
onEdit(user: User): void;
onDelete(user: User): void;
onReset(user: User): void;
+ // determines if the viewer is able to edit/delete users. This is used
+ // to conditionally render the edit/delete buttons in the ActionCell
+ usersAcl: Access;
};
diff --git a/web/packages/teleport/src/Users/Users.story.tsx b/web/packages/teleport/src/Users/Users.story.tsx
index fc905715582c2..eaccc82097e9a 100644
--- a/web/packages/teleport/src/Users/Users.story.tsx
+++ b/web/packages/teleport/src/Users/Users.story.tsx
@@ -149,4 +149,12 @@ const sample = {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
+ canEditUsers: true,
+ usersAcl: {
+ read: true,
+ edit: false,
+ remove: true,
+ list: true,
+ create: true,
+ },
};
diff --git a/web/packages/teleport/src/Users/Users.test.tsx b/web/packages/teleport/src/Users/Users.test.tsx
index 154047002d29e..0d5d02ee7e710 100644
--- a/web/packages/teleport/src/Users/Users.test.tsx
+++ b/web/packages/teleport/src/Users/Users.test.tsx
@@ -18,14 +18,23 @@
import React from 'react';
import { MemoryRouter } from 'react-router';
-import { render, screen, userEvent } from 'design/utils/testing';
+import { render, screen, userEvent, fireEvent } from 'design/utils/testing';
import { ContextProvider } from 'teleport';
import { createTeleportContext } from 'teleport/mocks/contexts';
+import { Access } from 'teleport/services/user';
import { Users } from './Users';
import { State } from './useUsers';
+const defaultAcl: Access = {
+ read: true,
+ edit: true,
+ remove: true,
+ list: true,
+ create: true,
+};
+
describe('invite collaborators integration', () => {
const ctx = createTeleportContext();
@@ -59,6 +68,7 @@ describe('invite collaborators integration', () => {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
+ usersAcl: defaultAcl,
};
});
@@ -138,6 +148,7 @@ test('Users not equal to MAU Notice', async () => {
EmailPasswordReset: null,
showMauInfo: true,
onDismissUsersMauNotice: jest.fn(),
+ usersAcl: defaultAcl,
};
const user = userEvent.setup();
@@ -192,6 +203,7 @@ describe('email password reset integration', () => {
EmailPasswordReset: null,
showMauInfo: false,
onDismissUsersMauNotice: () => null,
+ usersAcl: defaultAcl,
};
});
@@ -232,3 +244,158 @@ describe('email password reset integration', () => {
expect(screen.getByTestId('new-reset-ui')).toBeInTheDocument();
});
});
+
+describe('permission handling', () => {
+ const ctx = createTeleportContext();
+
+ let props: State;
+ beforeEach(() => {
+ props = {
+ attempt: {
+ message: 'success',
+ isSuccess: true,
+ isProcessing: false,
+ isFailed: false,
+ },
+ users: [
+ {
+ name: 'tester',
+ roles: [],
+ isLocal: true,
+ },
+ ],
+ fetchRoles: () => Promise.resolve([]),
+ operation: {
+ type: 'reset',
+ user: { name: 'alice@example.com', roles: ['foo'] },
+ },
+
+ onStartCreate: () => undefined,
+ onStartDelete: () => undefined,
+ onStartEdit: () => undefined,
+ onStartReset: () => undefined,
+ onStartInviteCollaborators: () => undefined,
+ onClose: () => undefined,
+ onDelete: () => undefined,
+ onCreate: () => undefined,
+ onUpdate: () => undefined,
+ onReset: () => undefined,
+ onInviteCollaboratorsClose: () => undefined,
+ InviteCollaborators: null,
+ inviteCollaboratorsOpen: false,
+ onEmailPasswordResetClose: () => undefined,
+ EmailPasswordReset: null,
+ showMauInfo: false,
+ onDismissUsersMauNotice: () => null,
+ usersAcl: defaultAcl,
+ };
+ });
+
+ test('displays a disabled Create Users button if lacking permissions', async () => {
+ const testProps = {
+ ...props,
+ usersAcl: {
+ ...defaultAcl,
+ edit: false,
+ },
+ };
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByTestId('create_new_users_button')).toBeDisabled();
+ });
+
+ test('edit and reset options not available in the menu', async () => {
+ const testProps = {
+ ...props,
+ usersAcl: {
+ ...defaultAcl,
+ edit: false,
+ },
+ };
+ render(
+
+
+
+
+
+ );
+
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(1);
+ expect(menuItems.some(item => item.textContent.includes('Delete'))).toBe(
+ true
+ );
+ });
+
+ test('all options are available in the menu', async () => {
+ const testProps = {
+ ...props,
+ usersAcl: {
+ read: true,
+ list: true,
+ edit: true,
+ create: true,
+ remove: true,
+ },
+ };
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByText('tester')).toBeInTheDocument();
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(3);
+ expect(menuItems.some(item => item.textContent.includes('Delete'))).toBe(
+ true
+ );
+ expect(
+ menuItems.some(item => item.textContent.includes('Reset Auth'))
+ ).toBe(true);
+ expect(menuItems.some(item => item.textContent.includes('Edit'))).toBe(
+ true
+ );
+ });
+
+ test('delete is not available in menu', async () => {
+ const testProps = {
+ ...props,
+ usersAcl: {
+ read: true,
+ list: true,
+ edit: true,
+ create: true,
+ remove: false,
+ },
+ };
+ render(
+
+
+
+
+
+ );
+
+ expect(screen.getByText('tester')).toBeInTheDocument();
+ const optionsButton = screen.getByRole('button', { name: /options/i });
+ fireEvent.click(optionsButton);
+ const menuItems = screen.queryAllByRole('menuitem');
+ expect(menuItems).toHaveLength(2);
+ expect(
+ menuItems.every(item => item.textContent.includes('Delete'))
+ ).not.toBe(true);
+ });
+});
diff --git a/web/packages/teleport/src/Users/Users.tsx b/web/packages/teleport/src/Users/Users.tsx
index f36b8d70bf6d0..16c007045752c 100644
--- a/web/packages/teleport/src/Users/Users.tsx
+++ b/web/packages/teleport/src/Users/Users.tsx
@@ -17,7 +17,8 @@
*/
import React from 'react';
-import { Indicator, Box, Alert, Button, Link } from 'design';
+import { Indicator, Text, Flex, Box, Alert, Button, Link } from 'design';
+import { HoverTooltip } from 'shared/components/ToolTip';
import {
FeatureBox,
@@ -46,6 +47,7 @@ export function Users(props: State) {
onStartDelete,
onStartEdit,
onStartReset,
+ usersAcl,
showMauInfo,
onDismissUsersMauNotice,
onClose,
@@ -60,22 +62,68 @@ export function Users(props: State) {
EmailPasswordReset,
onEmailPasswordResetClose,
} = props;
+
+ const requiredPermissions = Object.entries(usersAcl)
+ .map(([key, value]) => {
+ if (key === 'edit') {
+ return { value, label: 'update' };
+ }
+ if (key === 'create') {
+ return { value, label: 'create' };
+ }
+ })
+ .filter(Boolean);
+
+ const isMissingPermissions = requiredPermissions.some(v => !v.value);
+
return (
-
+
Users
{attempt.isSuccess && (
<>
{!InviteCollaborators && (
-
+ )
+ }
>
- Create New User
-
+
+
)}
{InviteCollaborators && (
}
{attempt.isSuccess && (
expect(screen.getByPlaceholderText('label key')).toBeInTheDocument();
expect(screen.getByPlaceholderText('label value')).toBeInTheDocument();
});
+
+describe('validation rules', () => {
+ function setup(labels: Label[], rule: LabelsRule) {
+ let validator: Validator;
+ render(
+
+ {({ validator: v }) => {
+ validator = v;
+ return (
+ {}} rule={rule} />
+ );
+ }}
+
+ );
+ return { validator };
+ }
+
+ describe.each([
+ { name: 'explicitly enforced standard rule', rule: nonEmptyLabels },
+ { name: 'implicit standard rule', rule: undefined },
+ ])('$name', ({ rule }) => {
+ test('invalid', () => {
+ const { validator } = setup(
+ [
+ { name: '', value: 'foo' },
+ { name: 'bar', value: '' },
+ { name: 'asdf', value: 'qwer' },
+ ],
+ rule
+ );
+ act(() => validator.validate());
+ expect(validator.state.valid).toBe(false);
+ expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription(
+ 'required'
+ ); // ''
+ expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); // 'foo'
+ expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); // 'bar'
+ expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription(
+ 'required'
+ ); // ''
+ expect(screen.getAllByRole('textbox')[4]).toHaveAccessibleDescription(''); // 'asdf'
+ expect(screen.getAllByRole('textbox')[5]).toHaveAccessibleDescription(''); // 'qwer'
+ });
+
+ test('valid', () => {
+ const { validator } = setup(
+ [
+ { name: '', value: 'foo' },
+ { name: 'bar', value: '' },
+ { name: 'asdf', value: 'qwer' },
+ ],
+ rule
+ );
+ act(() => validator.validate());
+ expect(validator.state.valid).toBe(false);
+ expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription(
+ 'required'
+ ); // ''
+ expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); // 'foo'
+ expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); // 'bar'
+ expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription(
+ 'required'
+ ); // ''
+ expect(screen.getAllByRole('textbox')[4]).toHaveAccessibleDescription(''); // 'asdf'
+ expect(screen.getAllByRole('textbox')[5]).toHaveAccessibleDescription(''); // 'qwer'
+ });
+ });
+
+ const nameNotFoo: LabelsRule = (labels: Label[]) => () => {
+ const results = labels.map(label => ({
+ name:
+ label.name === 'foo'
+ ? { valid: false, message: 'no foo please' }
+ : { valid: true },
+ value: { valid: true },
+ }));
+ return {
+ valid: results.every(r => r.name.valid && r.value.valid),
+ results: results,
+ };
+ };
+
+ test('custom rule, invalid', async () => {
+ const { validator } = setup(
+ [
+ { name: 'foo', value: 'bar' },
+ { name: 'bar', value: 'foo' },
+ ],
+ nameNotFoo
+ );
+ act(() => validator.validate());
+ expect(validator.state.valid).toBe(false);
+ expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription(
+ 'no foo please'
+ ); // 'foo' key
+ expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription('');
+ expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription('');
+ expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription('');
+ });
+
+ test('custom rule, valid', async () => {
+ const { validator } = setup(
+ [
+ { name: 'xyz', value: 'bar' },
+ { name: 'bar', value: 'foo' },
+ ],
+ nameNotFoo
+ );
+ act(() => validator.validate());
+ expect(validator.state.valid).toBe(true);
+ expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription('');
+ expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription('');
+ expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription('');
+ expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription('');
+ });
+});
diff --git a/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx b/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx
index f163d7df0e0de..eee6025249817 100644
--- a/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx
+++ b/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx
@@ -19,8 +19,17 @@
import styled from 'styled-components';
import { Flex, Box, ButtonSecondary, ButtonIcon } from 'design';
import FieldInput from 'shared/components/FieldInput';
-import { Validator, useValidation } from 'shared/components/Validation';
-import { requiredField } from 'shared/components/Validation/rules';
+import {
+ Validator,
+ useRule,
+ useValidation,
+} from 'shared/components/Validation';
+import {
+ precomputed,
+ requiredField,
+ Rule,
+ ValidationResult,
+} from 'shared/components/Validation/rules';
import * as Icons from 'design/Icon';
import { inputGeometry } from 'design/Input/Input';
@@ -34,6 +43,24 @@ export type LabelInputTexts = {
placeholder: string;
};
+type LabelListValidationResult = ValidationResult & {
+ /**
+ * A list of validation results, one per label. Note: items are optional just
+ * because `useRule` by default returns only `ValidationResult`. For the
+ * actual validation, it's not optional; if it's undefined, or there are
+ * fewer items in this list than the labels, a default validation rule will
+ * be used instead.
+ */
+ results?: LabelValidationResult[];
+};
+
+type LabelValidationResult = {
+ name: ValidationResult;
+ value: ValidationResult;
+};
+
+export type LabelsRule = Rule
);
}
-
-const Layout = styled(Box)`
- flex-direction: column;
- display: flex;
- flex: 1;
- max-width: 1248px;
-
- &::after {
- content: ' ';
- padding-bottom: 24px;
- }
-`;
-
-const StyledMain = styled.div`
- display: flex;
- flex-direction: column;
- flex: 1;
-`;
-
-function toResourceMap(request: PendingAccessRequest): ResourceMap {
- const resourceMap: ResourceMap = {
- user_group: {},
- windows_desktop: {},
- role: {},
- kube_cluster: {},
- node: {},
- db: {},
- app: {},
- saml_idp_service_provider: {},
- namespace: {},
- };
- if (request.kind === 'role') {
- request.roles.forEach(role => {
- resourceMap.role[role] = role;
- });
- }
-
- return resourceMap;
-}