diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx
new file mode 100644
index 0000000000000..1bbd3a5db36c2
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.test.tsx
@@ -0,0 +1,85 @@
+/**
+ * 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 { render, screen, userEvent } from 'design/utils/testing';
+import { act } from '@testing-library/react';
+import { Validator } from 'shared/components/Validation';
+import selectEvent from 'react-select-event';
+import { ResourceKind } from 'teleport/services/resources';
+
+import { RuleModel } from './standardmodel';
+import { AccessRuleValidationResult, validateAccessRule } from './validation';
+import { AccessRules } from './AccessRules';
+import { StatefulSection } from './StatefulSection';
+
+describe('AccessRules', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={AccessRules}
+ defaultValue={[]}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ validate={rules => rules.map(validateAccessRule)}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
+
+ test('editing', async () => {
+ const { user, onChange } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add New' }));
+ await selectEvent.select(screen.getByLabelText('Resources'), [
+ 'db',
+ 'node',
+ ]);
+ await selectEvent.select(screen.getByLabelText('Permissions'), [
+ 'list',
+ 'read',
+ ]);
+ expect(onChange).toHaveBeenLastCalledWith([
+ {
+ id: expect.any(String),
+ resources: [
+ { label: ResourceKind.Database, value: 'db' },
+ { label: ResourceKind.Node, value: 'node' },
+ ],
+ verbs: [
+ { label: 'list', value: 'list' },
+ { label: 'read', value: 'read' },
+ ],
+ },
+ ] as RuleModel[]);
+ });
+
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add New' }));
+ act(() => validator.validate());
+ expect(
+ screen.getByText('At least one resource kind is required')
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText('At least one permission is required')
+ ).toBeInTheDocument();
+ });
+});
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx
new file mode 100644
index 0000000000000..78b680e21e8b3
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/AccessRules.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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 Flex from 'design/Flex';
+
+import { ButtonSecondary } from 'design/Button';
+import { Plus } from 'design/Icon';
+import {
+ FieldSelect,
+ FieldSelectCreatable,
+} from 'shared/components/FieldSelect';
+import { precomputed } from 'shared/components/Validation/rules';
+import { components, MultiValueProps } from 'react-select';
+import { HoverTooltip } from 'design/Tooltip';
+import styled from 'styled-components';
+
+import { AccessRuleValidationResult } from './validation';
+import {
+ newRuleModel,
+ ResourceKindOption,
+ resourceKindOptions,
+ resourceKindOptionsMap,
+ RuleModel,
+ verbOptions,
+} from './standardmodel';
+import { Section, SectionProps } from './sections';
+
+export function AccessRules({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ function addRule() {
+ onChange?.([...value, newRuleModel()]);
+ }
+ function setRule(rule: RuleModel) {
+ onChange?.(value.map(r => (r.id === rule.id ? rule : r)));
+ }
+ function removeRule(id: string) {
+ onChange?.(value.filter(r => r.id !== id));
+ }
+ return (
+
+ {value.map((rule, i) => (
+ removeRule(rule.id)}
+ />
+ ))}
+
+
+ Add New
+
+
+ );
+}
+
+function AccessRule({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+ onRemove,
+}: SectionProps & {
+ onRemove?(): void;
+}) {
+ const { resources, verbs } = value;
+ return (
+
+ onChange?.({ ...value, resources: r })}
+ rule={precomputed(validation.fields.resources)}
+ />
+ onChange?.({ ...value, verbs: v })}
+ rule={precomputed(validation.fields.verbs)}
+ mb={0}
+ />
+
+ );
+}
+
+const ResourceKindSelect = styled(
+ FieldSelectCreatable
+)`
+ .teleport-resourcekind__value--unknown {
+ background: ${props => props.theme.colors.interactive.solid.alert.default};
+ .react-select__multi-value__label,
+ .react-select__multi-value__remove {
+ color: ${props => props.theme.colors.text.primaryInverse};
+ }
+ }
+`;
+
+function ResourceKindMultiValue(props: MultiValueProps) {
+ if (resourceKindOptionsMap.has(props.data.value)) {
+ return ;
+ }
+ return (
+
+
+
+ );
+}
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx
new file mode 100644
index 0000000000000..605a101964e5e
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/MetadataSection.tsx
@@ -0,0 +1,70 @@
+/**
+ * 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 FieldInput from 'shared/components/FieldInput';
+
+import { precomputed } from 'shared/components/Validation/rules';
+
+import { LabelsInput } from 'teleport/components/LabelsInput';
+
+import Text from 'design/Text';
+
+import { Section, SectionProps } from './sections';
+import { MetadataModel } from './standardmodel';
+import { MetadataValidationResult } from './validation';
+
+export const MetadataSection = ({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) => (
+
+ onChange({ ...value, name: e.target.value })}
+ />
+ ) =>
+ onChange({ ...value, description: e.target.value })
+ }
+ />
+
+ Labels
+
+ onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
+ />
+
+);
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx
new file mode 100644
index 0000000000000..cf510a5050aad
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Options.tsx
@@ -0,0 +1,236 @@
+/**
+ * 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 Box from 'design/Box';
+import Input from 'design/Input';
+import LabelInput from 'design/LabelInput';
+import { RadioGroup } from 'design/RadioGroup';
+import { H4 } from 'design/Text';
+import { useId } from 'react';
+import styled, { useTheme } from 'styled-components';
+
+import Select from 'shared/components/Select';
+
+import { SectionProps } from './sections';
+import {
+ OptionsModel,
+ requireMFATypeOptions,
+ sessionRecordingModeOptions,
+ createHostUserModeOptions,
+ createDBUserModeOptions,
+} from './standardmodel';
+
+export function Options({
+ value,
+ isProcessing,
+ onChange,
+}: SectionProps) {
+ const theme = useTheme();
+ const id = useId();
+ const maxSessionTTLId = `${id}-max-session-ttl`;
+ const clientIdleTimeoutId = `${id}-client-idle-timeout`;
+ const requireMFATypeId = `${id}-require-mfa-type`;
+ const createHostUserModeId = `${id}-create-host-user-mode`;
+ const createDBUserModeId = `${id}-create-db-user-mode`;
+ const defaultSessionRecordingModeId = `${id}-default-session-recording-mode`;
+ const sshSessionRecordingModeId = `${id}-ssh-session-recording-mode`;
+ return (
+
+ Global Settings
+
+ Max Session TTL
+ onChange({ ...value, maxSessionTTL: e.target.value })}
+ />
+
+
+ Client Idle Timeout
+
+
+ onChange({ ...value, clientIdleTimeout: e.target.value })
+ }
+ />
+
+ Disconnect When Certificate Expires
+ onChange({ ...value, disconnectExpiredCert: d })}
+ />
+
+ Require Session MFA
+
+ );
+}
+
+const OptionsGridContainer = styled(Box)`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ align-items: baseline;
+ row-gap: ${props => props.theme.space[3]}px;
+`;
+
+const OptionsHeader = styled(H4)<{ separator?: boolean }>`
+ grid-column: 1/3;
+ border-top: ${props =>
+ props.separator
+ ? `${props.theme.borders[1]} ${props.theme.colors.interactive.tonal.neutral[0]}`
+ : 'none'};
+ padding-top: ${props =>
+ props.separator ? `${props.theme.space[3]}px` : '0'};
+`;
+
+function BoolRadioGroup({
+ name,
+ value,
+ onChange,
+}: {
+ name: string;
+ value: boolean;
+ onChange(b: boolean): void;
+}) {
+ return (
+ onChange(d === 'true')}
+ />
+ );
+}
+
+const OptionLabel = styled(LabelInput)`
+ ${props => props.theme.typography.body2}
+`;
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx
new file mode 100644
index 0000000000000..da52de8befdce
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx
@@ -0,0 +1,465 @@
+/**
+ * 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 { render, screen, userEvent } from 'design/utils/testing';
+import { act, within } from '@testing-library/react';
+import { Validator } from 'shared/components/Validation';
+import selectEvent from 'react-select-event';
+
+import {
+ AppAccessSpec,
+ DatabaseAccessSpec,
+ KubernetesAccessSpec,
+ newAccessSpec,
+ ServerAccessSpec,
+ WindowsDesktopAccessSpec,
+} from './standardmodel';
+import { AccessSpecValidationResult, validateAccessSpec } from './validation';
+import {
+ ServerAccessSpecSection,
+ KubernetesAccessSpecSection,
+ AppAccessSpecSection,
+ DatabaseAccessSpecSection,
+ WindowsDesktopAccessSpecSection,
+} from './Resources';
+import { StatefulSection } from './StatefulSection';
+
+describe('ServerAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={ServerAccessSpecSection}
+ defaultValue={newAccessSpec('node')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ validate={validateAccessSpec}
+ />
+ );
+ 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: '{{internal.logins}}',
+ value: '{{internal.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;
+ }}
+ validate={validateAccessSpec}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
+
+ test('editing the spec', async () => {
+ const { user, onChange } = setup();
+
+ await selectEvent.create(screen.getByLabelText('Groups'), 'group1', {
+ createOptionText: 'Group: group1',
+ });
+ await selectEvent.create(screen.getByLabelText('Groups'), 'group2', {
+ createOptionText: 'Group: group2',
+ });
+
+ 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 user.click(screen.getByRole('button', { name: 'Add a Resource' }));
+ expect(
+ reactSelectValueContainer(screen.getByLabelText('Kind'))
+ ).toHaveTextContent('Any kind');
+ expect(screen.getByLabelText('Name')).toHaveValue('*');
+ expect(screen.getByLabelText('Namespace')).toHaveValue('*');
+ await selectEvent.select(screen.getByLabelText('Kind'), 'Job');
+ await user.clear(screen.getByLabelText('Name'));
+ await user.type(screen.getByLabelText('Name'), 'job-name');
+ await user.clear(screen.getByLabelText('Namespace'));
+ await user.type(screen.getByLabelText('Namespace'), 'job-namespace');
+ await selectEvent.select(screen.getByLabelText('Verbs'), [
+ 'create',
+ 'delete',
+ ]);
+
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'kube_cluster',
+ groups: [
+ expect.objectContaining({ value: '{{internal.kubernetes_groups}}' }),
+ expect.objectContaining({ value: 'group1' }),
+ expect.objectContaining({ value: 'group2' }),
+ ],
+ labels: [{ name: 'some-key', value: 'some-value' }],
+ resources: [
+ {
+ id: expect.any(String),
+ kind: expect.objectContaining({ value: 'job' }),
+ name: 'job-name',
+ namespace: 'job-namespace',
+ verbs: [
+ expect.objectContaining({ value: 'create' }),
+ expect.objectContaining({ value: 'delete' }),
+ ],
+ },
+ ],
+ } as KubernetesAccessSpec);
+ });
+
+ test('adding and removing resources', async () => {
+ const { user, onChange } = setup();
+
+ await user.click(screen.getByRole('button', { name: 'Add a Resource' }));
+ await user.clear(screen.getByLabelText('Name'));
+ await user.type(screen.getByLabelText('Name'), 'res1');
+ await user.click(
+ screen.getByRole('button', { name: 'Add Another Resource' })
+ );
+ await user.clear(screen.getAllByLabelText('Name')[1]);
+ await user.type(screen.getAllByLabelText('Name')[1], 'res2');
+ await user.click(
+ screen.getByRole('button', { name: 'Add Another Resource' })
+ );
+ await user.clear(screen.getAllByLabelText('Name')[2]);
+ await user.type(screen.getAllByLabelText('Name')[2], 'res3');
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ resources: [
+ expect.objectContaining({ name: 'res1' }),
+ expect.objectContaining({ name: 'res2' }),
+ expect.objectContaining({ name: 'res3' }),
+ ],
+ })
+ );
+
+ await user.click(
+ screen.getAllByRole('button', { name: 'Remove resource' })[1]
+ );
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ resources: [
+ expect.objectContaining({ name: 'res1' }),
+ expect.objectContaining({ name: 'res3' }),
+ ],
+ })
+ );
+ await user.click(
+ screen.getAllByRole('button', { name: 'Remove resource' })[0]
+ );
+ expect(onChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ resources: [expect.objectContaining({ name: 'res3' })],
+ })
+ );
+ await user.click(
+ screen.getAllByRole('button', { name: 'Remove resource' })[0]
+ );
+ expect(onChange).toHaveBeenLastCalledWith(
+ 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'
+ );
+ });
+});
+
+describe('AppAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={AppAccessSpecSection}
+ defaultValue={newAccessSpec('app')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ validate={validateAccessSpec}
+ />
+ );
+ return { user: userEvent.setup(), onChange, validator };
+ };
+
+ const awsRoleArns = () =>
+ screen.getByRole('group', { name: 'AWS Role ARNs' });
+ const awsRoleArnTextBoxes = () =>
+ within(awsRoleArns()).getAllByRole('textbox');
+ const azureIdentities = () =>
+ screen.getByRole('group', { name: 'Azure Identities' });
+ const azureIdentityTextBoxes = () =>
+ within(azureIdentities()).getAllByRole('textbox');
+ const gcpServiceAccounts = () =>
+ screen.getByRole('group', { name: 'GCP Service Accounts' });
+ const gcpServiceAccountTextBoxes = () =>
+ within(gcpServiceAccounts()).getAllByRole('textbox');
+
+ 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.click(
+ within(awsRoleArns()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(
+ awsRoleArnTextBoxes()[1],
+ 'arn:aws:iam::123456789012:role/admin'
+ );
+ await user.click(
+ within(azureIdentities()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(
+ azureIdentityTextBoxes()[1],
+ '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin'
+ );
+ await user.click(
+ within(gcpServiceAccounts()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(
+ gcpServiceAccountTextBoxes()[1],
+ 'admin@some-project.iam.gserviceaccount.com'
+ );
+ expect(onChange).toHaveBeenLastCalledWith({
+ kind: 'app',
+ labels: [{ name: 'env', value: 'prod' }],
+ awsRoleARNs: [
+ '{{internal.aws_role_arns}}',
+ 'arn:aws:iam::123456789012:role/admin',
+ ],
+ azureIdentities: [
+ '{{internal.azure_identities}}',
+ '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin',
+ ],
+ gcpServiceAccounts: [
+ '{{internal.gcp_service_accounts}}',
+ 'admin@some-project.iam.gserviceaccount.com',
+ ],
+ } as AppAccessSpec);
+ });
+
+ test('validation', async () => {
+ const { user, validator } = setup();
+ await user.click(screen.getByRole('button', { name: 'Add a Label' }));
+ await user.click(
+ within(awsRoleArns()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(awsRoleArnTextBoxes()[1], '*');
+ await user.click(
+ within(azureIdentities()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(azureIdentityTextBoxes()[1], '*');
+ await user.click(
+ within(gcpServiceAccounts()).getByRole('button', { name: 'Add More' })
+ );
+ await user.type(gcpServiceAccountTextBoxes()[1], '*');
+ act(() => validator.validate());
+ expect(
+ screen.getByPlaceholderText('label key')
+ ).toHaveAccessibleDescription('required');
+ expect(awsRoleArnTextBoxes()[1]).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in AWS role ARNs'
+ );
+ expect(azureIdentityTextBoxes()[1]).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in Azure identities'
+ );
+ expect(gcpServiceAccountTextBoxes()[1]).toHaveAccessibleDescription(
+ 'Wildcard is not allowed in GCP service accounts'
+ );
+ });
+});
+
+describe('DatabaseAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={DatabaseAccessSpecSection}
+ defaultValue={newAccessSpec('db')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ validate={validateAccessSpec}
+ />
+ );
+ 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({ value: '{{internal.db_names}}' }),
+ expect.objectContaining({ label: 'stuff', value: 'stuff' }),
+ ],
+ roles: [
+ expect.objectContaining({ value: '{{internal.db_roles}}' }),
+ expect.objectContaining({ label: 'admin', value: 'admin' }),
+ ],
+ users: [
+ expect.objectContaining({ value: '{{internal.db_users}}' }),
+ expect.objectContaining({ label: 'mary', value: 'mary' }),
+ ],
+ } as DatabaseAccessSpec);
+ });
+
+ 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();
+ });
+});
+
+describe('WindowsDesktopAccessSpecSection', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ component={WindowsDesktopAccessSpecSection}
+ defaultValue={newAccessSpec('windows_desktop')}
+ onChange={onChange}
+ validatorRef={v => {
+ validator = v;
+ }}
+ validate={validateAccessSpec}
+ />
+ );
+ 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'), '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({ value: '{{internal.windows_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');
+ });
+});
+
+const reactSelectValueContainer = (input: HTMLInputElement) =>
+ // eslint-disable-next-line testing-library/no-node-access
+ input.closest('.react-select__value-container');
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx
new file mode 100644
index 0000000000000..8c8136ce75de9
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.tsx
@@ -0,0 +1,486 @@
+/**
+ * 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 Box from 'design/Box';
+import { ButtonSecondary } from 'design/Button';
+import ButtonIcon from 'design/ButtonIcon';
+import Flex from 'design/Flex';
+import { Add, Trash } from 'design/Icon';
+import { Mark } from 'design/Mark';
+import Text, { H4 } from 'design/Text';
+import FieldInput from 'shared/components/FieldInput';
+import { FieldMultiInput } from 'shared/components/FieldMultiInput/FieldMultiInput';
+import FieldSelect, {
+ FieldSelectCreatable,
+} from 'shared/components/FieldSelect';
+import { precomputed } from 'shared/components/Validation/rules';
+import styled, { useTheme } from 'styled-components';
+import { LabelsInput } from 'teleport/components/LabelsInput';
+
+import { SectionProps, Section } from './sections';
+import {
+ AccessSpecKind,
+ AccessSpec,
+ ServerAccessSpec,
+ KubernetesAccessSpec,
+ newKubernetesResourceModel,
+ KubernetesResourceModel,
+ kubernetesResourceKindOptions,
+ kubernetesVerbOptions,
+ AppAccessSpec,
+ DatabaseAccessSpec,
+ WindowsDesktopAccessSpec,
+} from './standardmodel';
+import {
+ AccessSpecValidationResult,
+ ServerSpecValidationResult,
+ KubernetesSpecValidationResult,
+ KubernetesResourceValidationResult,
+ AppSpecValidationResult,
+ DatabaseSpecValidationResult,
+ WindowsDesktopSpecValidationResult,
+} from './validation';
+
+/** Maps access specification kind to UI component configuration. */
+export const specSections: Record<
+ AccessSpecKind,
+ {
+ title: string;
+ tooltip: string;
+ component: React.ComponentType>;
+ }
+> = {
+ kube_cluster: {
+ title: 'Kubernetes',
+ tooltip: 'Configures access to Kubernetes clusters',
+ component: KubernetesAccessSpecSection,
+ },
+ node: {
+ title: 'Servers',
+ tooltip: 'Configures access to SSH servers',
+ component: ServerAccessSpecSection,
+ },
+ app: {
+ title: 'Applications',
+ tooltip: 'Configures access to applications',
+ component: AppAccessSpecSection,
+ },
+ db: {
+ title: 'Databases',
+ tooltip: 'Configures access to databases',
+ component: DatabaseAccessSpecSection,
+ },
+ windows_desktop: {
+ title: 'Windows Desktops',
+ tooltip: 'Configures access to Windows desktops',
+ component: WindowsDesktopAccessSpecSection,
+ },
+};
+
+/**
+ * A generic access spec section. Details are rendered by components from the
+ * `specSections` map.
+ */
+export const AccessSpecSection = <
+ T extends AccessSpec,
+ V extends AccessSpecValidationResult,
+>({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+ onRemove,
+}: SectionProps & {
+ onRemove?(): void;
+}) => {
+ const { component: Body, title, tooltip } = specSections[value.kind];
+ return (
+
+
+ );
+};
+
+export function ServerAccessSpecSection({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ return (
+ <>
+
+ Labels
+
+ onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
+ />
+ `Login: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.logins}
+ onChange={logins => onChange?.({ ...value, logins })}
+ rule={precomputed(validation.fields.logins)}
+ mt={3}
+ mb={0}
+ />
+ >
+ );
+}
+
+export function KubernetesAccessSpecSection({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ return (
+ <>
+ `Group: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.groups}
+ onChange={groups => onChange?.({ ...value, groups })}
+ />
+
+
+ Labels
+
+ onChange?.({ ...value, labels })}
+ />
+
+
+ {value.resources.map((resource, index) => (
+
+ onChange?.({
+ ...value,
+ resources: value.resources.map((res, i) =>
+ i === index ? newRes : res
+ ),
+ })
+ }
+ onRemove={() =>
+ onChange?.({
+ ...value,
+ resources: value.resources.toSpliced(index, 1),
+ })
+ }
+ />
+ ))}
+
+
+
+ onChange?.({
+ ...value,
+ resources: [...value.resources, newKubernetesResourceModel()],
+ })
+ }
+ >
+
+ {value.resources.length > 0
+ ? 'Add Another Resource'
+ : 'Add a Resource'}
+
+
+
+ >
+ );
+}
+
+function KubernetesResourceView({
+ value,
+ validation,
+ isProcessing,
+ onChange,
+ onRemove,
+}: {
+ value: KubernetesResourceModel;
+ validation: KubernetesResourceValidationResult;
+ isProcessing: boolean;
+ onChange(m: KubernetesResourceModel): void;
+ onRemove(): void;
+}) {
+ const { kind, name, namespace, verbs } = value;
+ const theme = useTheme();
+ return (
+
+
+
+ Resource
+
+
+
+
+
+ onChange?.({ ...value, kind: k })}
+ />
+
+ Name of the resource. Special value *{' '}
+ means any name.
+ >
+ }
+ disabled={isProcessing}
+ value={name}
+ rule={precomputed(validation.name)}
+ onChange={e => onChange?.({ ...value, name: e.target.value })}
+ />
+
+ Namespace that contains the resource. Special value{' '}
+ * means any namespace.
+ >
+ }
+ disabled={isProcessing}
+ value={namespace}
+ rule={precomputed(validation.namespace)}
+ onChange={e => onChange?.({ ...value, namespace: e.target.value })}
+ />
+ onChange?.({ ...value, verbs: v })}
+ mb={0}
+ />
+
+ );
+}
+
+export function AppAccessSpecSection({
+ value,
+ validation,
+ isProcessing,
+ onChange,
+}: SectionProps) {
+ return (
+
+
+
+ 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)}
+ />
+
+ );
+}
+
+export function DatabaseAccessSpecSection({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ return (
+ <>
+
+
+ Labels
+
+ onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
+ />
+
+
+ List of database names that this role is allowed to connect to.
+ Special value * means any name.
+ >
+ }
+ isDisabled={isProcessing}
+ formatCreateLabel={label => `Database Name: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.names}
+ onChange={names => onChange?.({ ...value, names })}
+ />
+
+ List of database users that this role is allowed to connect as.
+ Special value * means any user.
+ >
+ }
+ isDisabled={isProcessing}
+ formatCreateLabel={label => `Database User: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.users}
+ onChange={users => onChange?.({ ...value, users })}
+ />
+ `Database Role: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.roles}
+ onChange={roles => onChange?.({ ...value, roles })}
+ rule={precomputed(validation.fields.roles)}
+ mb={0}
+ />
+ >
+ );
+}
+
+export function WindowsDesktopAccessSpecSection({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ return (
+ <>
+
+
+ Labels
+
+ onChange?.({ ...value, labels })}
+ rule={precomputed(validation.fields.labels)}
+ />
+
+ `Login: ${label}`}
+ components={{
+ DropdownIndicator: null,
+ }}
+ openMenuOnClick={false}
+ value={value.logins}
+ onChange={logins => onChange?.({ ...value, logins })}
+ />
+ >
+ );
+}
+
+// TODO(bl-nero): This should ideally use tonal neutral 1 from the opposite
+// theme as background.
+const MarkInverse = styled(Mark)`
+ background: ${p => p.theme.colors.text.primaryInverse};
+ color: ${p => p.theme.colors.text.main};
+`;
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx
index f1f0644e343be..ba7fe3c3d24e3 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.test.tsx
@@ -17,46 +17,18 @@
*/
import { render, screen, userEvent } from 'design/utils/testing';
-import React, { useState } from 'react';
-
-import { act, within } from '@testing-library/react';
-import Validation, { Validator } from 'shared/components/Validation';
-import selectEvent from 'react-select-event';
-
+import { useState } from 'react';
+import { within } from '@testing-library/react';
+import Validation from 'shared/components/Validation';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
import { createTeleportContext } from 'teleport/mocks/contexts';
-import { ResourceKind } from 'teleport/services/resources';
-
import {
- AppAccessSpec,
- DatabaseAccessSpec,
- KubernetesAccessSpec,
- newAccessSpec,
newRole,
roleToRoleEditorModel,
- RuleModel,
- ServerAccessSpec,
StandardEditorModel,
- WindowsDesktopAccessSpec,
} from './standardmodel';
-import {
- AccessRules,
- AppAccessSpecSection,
- DatabaseAccessSpecSection,
- KubernetesAccessSpecSection,
- SectionProps,
- ServerAccessSpecSection,
- StandardEditor,
- StandardEditorProps,
- WindowsDesktopAccessSpecSection,
-} from './StandardEditor';
-import {
- AccessSpecValidationResult,
- AccessRuleValidationResult,
- validateAccessSpec,
- validateAccessRule,
-} from './validation';
+import { StandardEditor, StandardEditorProps } from './StandardEditor';
const TestStandardEditor = (props: Partial) => {
const ctx = createTeleportContext();
@@ -174,520 +146,3 @@ const getSectionByName = (name: string) =>
// There's no better way to do it, unfortunately.
// eslint-disable-next-line testing-library/no-node-access
screen.getByRole('heading', { level: 3, name }).closest('details');
-
-function StatefulSection({
- defaultValue,
- component: Component,
- onChange,
- validatorRef,
- validate,
-}: {
- defaultValue: Spec;
- component: React.ComponentType>;
- onChange(spec: Spec): void;
- validatorRef?(v: Validator): void;
- validate(arg: Spec): ValidationResult;
-}) {
- const [model, setModel] = useState(defaultValue);
- const validation = validate(model);
- return (
-
- {({ validator }) => {
- validatorRef?.(validator);
- return (
- {
- setModel(spec);
- onChange(spec);
- }}
- />
- );
- }}
-
- );
-}
-
-describe('ServerAccessSpecSection', () => {
- const setup = () => {
- const onChange = jest.fn();
- let validator: Validator;
- render(
-
- component={ServerAccessSpecSection}
- defaultValue={newAccessSpec('node')}
- onChange={onChange}
- validatorRef={v => {
- validator = v;
- }}
- validate={validateAccessSpec}
- />
- );
- 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: '{{internal.logins}}',
- value: '{{internal.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;
- }}
- validate={validateAccessSpec}
- />
- );
- return { user: userEvent.setup(), onChange, validator };
- };
-
- test('editing the spec', async () => {
- const { user, onChange } = setup();
-
- await selectEvent.create(screen.getByLabelText('Groups'), 'group1', {
- createOptionText: 'Group: group1',
- });
- await selectEvent.create(screen.getByLabelText('Groups'), 'group2', {
- createOptionText: 'Group: group2',
- });
-
- 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 user.click(screen.getByRole('button', { name: 'Add a Resource' }));
- expect(
- reactSelectValueContainer(screen.getByLabelText('Kind'))
- ).toHaveTextContent('Any kind');
- expect(screen.getByLabelText('Name')).toHaveValue('*');
- expect(screen.getByLabelText('Namespace')).toHaveValue('*');
- await selectEvent.select(screen.getByLabelText('Kind'), 'Job');
- await user.clear(screen.getByLabelText('Name'));
- await user.type(screen.getByLabelText('Name'), 'job-name');
- await user.clear(screen.getByLabelText('Namespace'));
- await user.type(screen.getByLabelText('Namespace'), 'job-namespace');
- await selectEvent.select(screen.getByLabelText('Verbs'), [
- 'create',
- 'delete',
- ]);
-
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'kube_cluster',
- groups: [
- expect.objectContaining({ value: '{{internal.kubernetes_groups}}' }),
- expect.objectContaining({ value: 'group1' }),
- expect.objectContaining({ value: 'group2' }),
- ],
- labels: [{ name: 'some-key', value: 'some-value' }],
- resources: [
- {
- id: expect.any(String),
- kind: expect.objectContaining({ value: 'job' }),
- name: 'job-name',
- namespace: 'job-namespace',
- verbs: [
- expect.objectContaining({ value: 'create' }),
- expect.objectContaining({ value: 'delete' }),
- ],
- },
- ],
- } as KubernetesAccessSpec);
- });
-
- test('adding and removing resources', async () => {
- const { user, onChange } = setup();
-
- await user.click(screen.getByRole('button', { name: 'Add a Resource' }));
- await user.clear(screen.getByLabelText('Name'));
- await user.type(screen.getByLabelText('Name'), 'res1');
- await user.click(
- screen.getByRole('button', { name: 'Add Another Resource' })
- );
- await user.clear(screen.getAllByLabelText('Name')[1]);
- await user.type(screen.getAllByLabelText('Name')[1], 'res2');
- await user.click(
- screen.getByRole('button', { name: 'Add Another Resource' })
- );
- await user.clear(screen.getAllByLabelText('Name')[2]);
- await user.type(screen.getAllByLabelText('Name')[2], 'res3');
- expect(onChange).toHaveBeenLastCalledWith(
- expect.objectContaining({
- resources: [
- expect.objectContaining({ name: 'res1' }),
- expect.objectContaining({ name: 'res2' }),
- expect.objectContaining({ name: 'res3' }),
- ],
- })
- );
-
- await user.click(
- screen.getAllByRole('button', { name: 'Remove resource' })[1]
- );
- expect(onChange).toHaveBeenLastCalledWith(
- expect.objectContaining({
- resources: [
- expect.objectContaining({ name: 'res1' }),
- expect.objectContaining({ name: 'res3' }),
- ],
- })
- );
- await user.click(
- screen.getAllByRole('button', { name: 'Remove resource' })[0]
- );
- expect(onChange).toHaveBeenLastCalledWith(
- expect.objectContaining({
- resources: [expect.objectContaining({ name: 'res3' })],
- })
- );
- await user.click(
- screen.getAllByRole('button', { name: 'Remove resource' })[0]
- );
- expect(onChange).toHaveBeenLastCalledWith(
- 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'
- );
- });
-});
-
-describe('AppAccessSpecSection', () => {
- const setup = () => {
- const onChange = jest.fn();
- let validator: Validator;
- render(
-
- component={AppAccessSpecSection}
- defaultValue={newAccessSpec('app')}
- onChange={onChange}
- validatorRef={v => {
- validator = v;
- }}
- validate={validateAccessSpec}
- />
- );
- return { user: userEvent.setup(), onChange, validator };
- };
-
- const awsRoleArns = () =>
- screen.getByRole('group', { name: 'AWS Role ARNs' });
- const awsRoleArnTextBoxes = () =>
- within(awsRoleArns()).getAllByRole('textbox');
- const azureIdentities = () =>
- screen.getByRole('group', { name: 'Azure Identities' });
- const azureIdentityTextBoxes = () =>
- within(azureIdentities()).getAllByRole('textbox');
- const gcpServiceAccounts = () =>
- screen.getByRole('group', { name: 'GCP Service Accounts' });
- const gcpServiceAccountTextBoxes = () =>
- within(gcpServiceAccounts()).getAllByRole('textbox');
-
- 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.click(
- within(awsRoleArns()).getByRole('button', { name: 'Add More' })
- );
- await user.type(
- awsRoleArnTextBoxes()[1],
- 'arn:aws:iam::123456789012:role/admin'
- );
- await user.click(
- within(azureIdentities()).getByRole('button', { name: 'Add More' })
- );
- await user.type(
- azureIdentityTextBoxes()[1],
- '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin'
- );
- await user.click(
- within(gcpServiceAccounts()).getByRole('button', { name: 'Add More' })
- );
- await user.type(
- gcpServiceAccountTextBoxes()[1],
- 'admin@some-project.iam.gserviceaccount.com'
- );
- expect(onChange).toHaveBeenLastCalledWith({
- kind: 'app',
- labels: [{ name: 'env', value: 'prod' }],
- awsRoleARNs: [
- '{{internal.aws_role_arns}}',
- 'arn:aws:iam::123456789012:role/admin',
- ],
- azureIdentities: [
- '{{internal.azure_identities}}',
- '/subscriptions/1020304050607-cafe-8090-a0b0c0d0e0f0/resourceGroups/example-resource-group/providers/Microsoft.ManagedIdentity/userAssignedIdentities/admin',
- ],
- gcpServiceAccounts: [
- '{{internal.gcp_service_accounts}}',
- 'admin@some-project.iam.gserviceaccount.com',
- ],
- } as AppAccessSpec);
- });
-
- test('validation', async () => {
- const { user, validator } = setup();
- await user.click(screen.getByRole('button', { name: 'Add a Label' }));
- await user.click(
- within(awsRoleArns()).getByRole('button', { name: 'Add More' })
- );
- await user.type(awsRoleArnTextBoxes()[1], '*');
- await user.click(
- within(azureIdentities()).getByRole('button', { name: 'Add More' })
- );
- await user.type(azureIdentityTextBoxes()[1], '*');
- await user.click(
- within(gcpServiceAccounts()).getByRole('button', { name: 'Add More' })
- );
- await user.type(gcpServiceAccountTextBoxes()[1], '*');
- act(() => validator.validate());
- expect(
- screen.getByPlaceholderText('label key')
- ).toHaveAccessibleDescription('required');
- expect(awsRoleArnTextBoxes()[1]).toHaveAccessibleDescription(
- 'Wildcard is not allowed in AWS role ARNs'
- );
- expect(azureIdentityTextBoxes()[1]).toHaveAccessibleDescription(
- 'Wildcard is not allowed in Azure identities'
- );
- expect(gcpServiceAccountTextBoxes()[1]).toHaveAccessibleDescription(
- 'Wildcard is not allowed in GCP service accounts'
- );
- });
-});
-
-describe('DatabaseAccessSpecSection', () => {
- const setup = () => {
- const onChange = jest.fn();
- let validator: Validator;
- render(
-
- component={DatabaseAccessSpecSection}
- defaultValue={newAccessSpec('db')}
- onChange={onChange}
- validatorRef={v => {
- validator = v;
- }}
- validate={validateAccessSpec}
- />
- );
- 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({ value: '{{internal.db_names}}' }),
- expect.objectContaining({ label: 'stuff', value: 'stuff' }),
- ],
- roles: [
- expect.objectContaining({ value: '{{internal.db_roles}}' }),
- expect.objectContaining({ label: 'admin', value: 'admin' }),
- ],
- users: [
- expect.objectContaining({ value: '{{internal.db_users}}' }),
- expect.objectContaining({ label: 'mary', value: 'mary' }),
- ],
- } as DatabaseAccessSpec);
- });
-
- 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();
- });
-});
-
-describe('WindowsDesktopAccessSpecSection', () => {
- const setup = () => {
- const onChange = jest.fn();
- let validator: Validator;
- render(
-
- component={WindowsDesktopAccessSpecSection}
- defaultValue={newAccessSpec('windows_desktop')}
- onChange={onChange}
- validatorRef={v => {
- validator = v;
- }}
- validate={validateAccessSpec}
- />
- );
- 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'), '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({ value: '{{internal.windows_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');
- });
-});
-
-describe('AccessRules', () => {
- const setup = () => {
- const onChange = jest.fn();
- let validator: Validator;
- render(
-
- component={AccessRules}
- defaultValue={[]}
- onChange={onChange}
- validatorRef={v => {
- validator = v;
- }}
- validate={rules => rules.map(validateAccessRule)}
- />
- );
- return { user: userEvent.setup(), onChange, validator };
- };
-
- test('editing', async () => {
- const { user, onChange } = setup();
- await user.click(screen.getByRole('button', { name: 'Add New' }));
- await selectEvent.select(screen.getByLabelText('Resources'), [
- 'db',
- 'node',
- ]);
- await selectEvent.select(screen.getByLabelText('Permissions'), [
- 'list',
- 'read',
- ]);
- expect(onChange).toHaveBeenLastCalledWith([
- {
- id: expect.any(String),
- resources: [
- { label: ResourceKind.Database, value: 'db' },
- { label: ResourceKind.Node, value: 'node' },
- ],
- verbs: [
- { label: 'list', value: 'list' },
- { label: 'read', value: 'read' },
- ],
- },
- ] as RuleModel[]);
- });
-
- test('validation', async () => {
- const { user, validator } = setup();
- await user.click(screen.getByRole('button', { name: 'Add New' }));
- act(() => validator.validate());
- expect(
- screen.getByText('At least one resource kind is required')
- ).toBeInTheDocument();
- expect(
- screen.getByText('At least one permission is required')
- ).toBeInTheDocument();
- });
-});
-
-const reactSelectValueContainer = (input: HTMLInputElement) =>
- // eslint-disable-next-line testing-library/no-node-access
- input.closest('.react-select__value-container');
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx
index 1661eb14974f4..0efbbb1060189 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StandardEditor.tsx
@@ -16,86 +16,34 @@
* along with this program. If not, see .
*/
-import React, { useId, useState } from 'react';
-import {
- Box,
- ButtonIcon,
- ButtonSecondary,
- Flex,
- H3,
- H4,
- Input,
- LabelInput,
- Mark,
- Text,
-} from 'design';
-import FieldInput from 'shared/components/FieldInput';
+import { useId, useState } from 'react';
+import { Box, Flex } from 'design';
import { useValidation } from 'shared/components/Validation';
-import {
- precomputed,
- ValidationResult,
-} from 'shared/components/Validation/rules';
import * as Icon from 'design/Icon';
-import { HoverTooltip, IconTooltip } from 'design/Tooltip';
-import styled, { useTheme } from 'styled-components';
+import styled from 'styled-components';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
-import {
- FieldSelect,
- FieldSelectCreatable,
-} from 'shared/components/FieldSelect';
import { SlideTabs } from 'design/SlideTabs';
-import { RadioGroup } from 'design/RadioGroup';
-import Select from 'shared/components/Select';
-import { components, MultiValueProps } from 'react-select';
import { Role, RoleWithYaml } from 'teleport/services/resources';
-import { LabelsInput } from 'teleport/components/LabelsInput';
-import { FieldMultiInput } from 'shared/components/FieldMultiInput/FieldMultiInput';
import { EditorSaveCancelButton } from '../Shared';
import {
roleEditorModelToRole,
hasModifiedFields,
- MetadataModel,
RoleEditorModel,
StandardEditorModel,
AccessSpecKind,
AccessSpec,
- ServerAccessSpec,
newAccessSpec,
- KubernetesAccessSpec,
- newKubernetesResourceModel,
- kubernetesResourceKindOptions,
- kubernetesVerbOptions,
- KubernetesResourceModel,
- AppAccessSpec,
- DatabaseAccessSpec,
- WindowsDesktopAccessSpec,
RuleModel,
- resourceKindOptions,
- verbOptions,
- newRuleModel,
OptionsModel,
- requireMFATypeOptions,
- createHostUserModeOptions,
- createDBUserModeOptions,
- sessionRecordingModeOptions,
- resourceKindOptionsMap,
- ResourceKindOption,
} from './standardmodel';
-import {
- validateRoleEditorModel,
- MetadataValidationResult,
- AccessSpecValidationResult,
- ServerSpecValidationResult,
- KubernetesSpecValidationResult,
- KubernetesResourceValidationResult,
- AppSpecValidationResult,
- DatabaseSpecValidationResult,
- WindowsDesktopSpecValidationResult,
- AccessRuleValidationResult,
-} from './validation';
+import { validateRoleEditorModel } from './validation';
import { RequiresResetToStandard } from './RequiresResetToStandard';
+import { MetadataSection } from './MetadataSection';
+import { AccessSpecSection, specSections } from './Resources';
+import { AccessRules } from './AccessRules';
+import { Options } from './Options';
export type StandardEditorProps = {
originalRole: RoleWithYaml;
@@ -377,154 +325,11 @@ export const StandardEditor = ({
);
};
-export type SectionProps = {
- value: Model;
- isProcessing: boolean;
- validation?: ValidationResult;
- onChange?(value: Model): void;
-};
-
const validationErrorTabStatus = {
kind: 'danger',
ariaLabel: 'Invalid data',
} as const;
-const MetadataSection = ({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) => (
-
- onChange({ ...value, name: e.target.value })}
- />
- ) =>
- onChange({ ...value, description: e.target.value })
- }
- />
-
- Labels
-
- onChange?.({ ...value, labels })}
- rule={precomputed(validation.fields.labels)}
- />
-
-);
-
-/**
- * A wrapper for editor section. Its responsibility is rendering a header,
- * expanding, collapsing, and removing the section.
- */
-const Section = ({
- title,
- tooltip,
- children,
- removable,
- isProcessing,
- validation,
- onRemove,
-}: React.PropsWithChildren<{
- title: string;
- tooltip: string;
- removable?: boolean;
- isProcessing: boolean;
- validation?: ValidationResult;
- onRemove?(): void;
-}>) => {
- 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
- // track of the state.
- e.preventDefault();
- setExpanded(expanded => !expanded);
- };
-
- const handleRemove = (e: React.MouseEvent) => {
- // Don't let handle the event.
- e.stopPropagation();
- onRemove?.();
- };
-
- return (
-
-
- {/* TODO(bl-nero): Show validation result in the summary. */}
-
- {title}
- {tooltip && {tooltip}}
-
- {removable && (
-
-
-
-
-
-
-
- )}
-
-
-
-
-
- {children}
-
-
- );
-};
-
/**
* All access spec kinds, in order of appearance in the resource kind dropdown.
*/
@@ -536,742 +341,9 @@ const allAccessSpecKinds: AccessSpecKind[] = [
'windows_desktop',
];
-/** Maps access specification kind to UI component configuration. */
-const specSections: Record<
- AccessSpecKind,
- {
- title: string;
- tooltip: string;
- component: React.ComponentType>;
- }
-> = {
- kube_cluster: {
- title: 'Kubernetes',
- tooltip: 'Configures access to Kubernetes clusters',
- component: KubernetesAccessSpecSection,
- },
- node: {
- title: 'Servers',
- tooltip: 'Configures access to SSH servers',
- component: ServerAccessSpecSection,
- },
- app: {
- title: 'Applications',
- tooltip: 'Configures access to applications',
- component: AppAccessSpecSection,
- },
- db: {
- title: 'Databases',
- tooltip: 'Configures access to databases',
- component: DatabaseAccessSpecSection,
- },
- windows_desktop: {
- title: 'Windows Desktops',
- tooltip: 'Configures access to Windows desktops',
- component: WindowsDesktopAccessSpecSection,
- },
-};
-
-/**
- * A generic access spec section. Details are rendered by components from the
- * `specSections` map.
- */
-const AccessSpecSection = <
- T extends AccessSpec,
- V extends AccessSpecValidationResult,
->({
- value,
- isProcessing,
- validation,
- onChange,
- onRemove,
-}: SectionProps & {
- onRemove?(): void;
-}) => {
- const { component: Body, title, tooltip } = specSections[value.kind];
- return (
-
-
- );
-};
-
-export function ServerAccessSpecSection({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) {
- return (
- <>
-
- Labels
-
- onChange?.({ ...value, labels })}
- rule={precomputed(validation.fields.labels)}
- />
- `Login: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.logins}
- onChange={logins => onChange?.({ ...value, logins })}
- rule={precomputed(validation.fields.logins)}
- mt={3}
- mb={0}
- />
- >
- );
-}
-
-export function KubernetesAccessSpecSection({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) {
- return (
- <>
- `Group: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.groups}
- onChange={groups => onChange?.({ ...value, groups })}
- />
-
-
- Labels
-
- onChange?.({ ...value, labels })}
- />
-
-
- {value.resources.map((resource, index) => (
-
- onChange?.({
- ...value,
- resources: value.resources.map((res, i) =>
- i === index ? newRes : res
- ),
- })
- }
- onRemove={() =>
- onChange?.({
- ...value,
- resources: value.resources.toSpliced(index, 1),
- })
- }
- />
- ))}
-
-
-
- onChange?.({
- ...value,
- resources: [...value.resources, newKubernetesResourceModel()],
- })
- }
- >
-
- {value.resources.length > 0
- ? 'Add Another Resource'
- : 'Add a Resource'}
-
-
-
- >
- );
-}
-
-function KubernetesResourceView({
- value,
- validation,
- isProcessing,
- onChange,
- onRemove,
-}: {
- value: KubernetesResourceModel;
- validation: KubernetesResourceValidationResult;
- isProcessing: boolean;
- onChange(m: KubernetesResourceModel): void;
- onRemove(): void;
-}) {
- const { kind, name, namespace, verbs } = value;
- const theme = useTheme();
- return (
-
-
-
- Resource
-
-
-
-
-
- onChange?.({ ...value, kind: k })}
- />
-
- Name of the resource. Special value *{' '}
- means any name.
- >
- }
- disabled={isProcessing}
- value={name}
- rule={precomputed(validation.name)}
- onChange={e => onChange?.({ ...value, name: e.target.value })}
- />
-
- Namespace that contains the resource. Special value{' '}
- * means any namespace.
- >
- }
- disabled={isProcessing}
- value={namespace}
- rule={precomputed(validation.namespace)}
- onChange={e => onChange?.({ ...value, namespace: e.target.value })}
- />
- onChange?.({ ...value, verbs: v })}
- mb={0}
- />
-
- );
-}
-
-export function AppAccessSpecSection({
- value,
- validation,
- isProcessing,
- onChange,
-}: SectionProps) {
- return (
-
-
-
- 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)}
- />
-
- );
-}
-
-export function DatabaseAccessSpecSection({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) {
- return (
- <>
-
-
- Labels
-
- onChange?.({ ...value, labels })}
- rule={precomputed(validation.fields.labels)}
- />
-
-
- List of database names that this role is allowed to connect to.
- Special value * means any name.
- >
- }
- isDisabled={isProcessing}
- formatCreateLabel={label => `Database Name: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.names}
- onChange={names => onChange?.({ ...value, names })}
- />
-
- List of database users that this role is allowed to connect as.
- Special value * means any user.
- >
- }
- isDisabled={isProcessing}
- formatCreateLabel={label => `Database User: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.users}
- onChange={users => onChange?.({ ...value, users })}
- />
- `Database Role: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.roles}
- onChange={roles => onChange?.({ ...value, roles })}
- rule={precomputed(validation.fields.roles)}
- mb={0}
- />
- >
- );
-}
-
-export function WindowsDesktopAccessSpecSection({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) {
- return (
- <>
-
-
- Labels
-
- onChange?.({ ...value, labels })}
- rule={precomputed(validation.fields.labels)}
- />
-
- `Login: ${label}`}
- components={{
- DropdownIndicator: null,
- }}
- openMenuOnClick={false}
- value={value.logins}
- onChange={logins => onChange?.({ ...value, logins })}
- />
- >
- );
-}
-
-export function AccessRules({
- value,
- isProcessing,
- validation,
- onChange,
-}: SectionProps) {
- function addRule() {
- onChange?.([...value, newRuleModel()]);
- }
- function setRule(rule: RuleModel) {
- onChange?.(value.map(r => (r.id === rule.id ? rule : r)));
- }
- function removeRule(id: string) {
- onChange?.(value.filter(r => r.id !== id));
- }
- return (
-
- {value.map((rule, i) => (
- removeRule(rule.id)}
- />
- ))}
-
-
- Add New
-
-
- );
-}
-
-function AccessRule({
- value,
- isProcessing,
- validation,
- onChange,
- onRemove,
-}: SectionProps & {
- onRemove?(): void;
-}) {
- const { resources, verbs } = value;
- return (
-
- onChange?.({ ...value, resources: r })}
- rule={precomputed(validation.fields.resources)}
- />
- onChange?.({ ...value, verbs: v })}
- rule={precomputed(validation.fields.verbs)}
- mb={0}
- />
-
- );
-}
-
-const ResourceKindSelect = styled(
- FieldSelectCreatable
-)`
- .teleport-resourcekind__value--unknown {
- background: ${props => props.theme.colors.interactive.solid.alert.default};
- .react-select__multi-value__label,
- .react-select__multi-value__remove {
- color: ${props => props.theme.colors.text.primaryInverse};
- }
- }
-`;
-
-function ResourceKindMultiValue(props: MultiValueProps) {
- if (resourceKindOptionsMap.has(props.data.value)) {
- return ;
- }
- return (
-
-
-
- );
-}
-
-function Options({
- value,
- isProcessing,
- onChange,
-}: SectionProps) {
- const theme = useTheme();
- const id = useId();
- const maxSessionTTLId = `${id}-max-session-ttl`;
- const clientIdleTimeoutId = `${id}-client-idle-timeout`;
- const requireMFATypeId = `${id}-require-mfa-type`;
- const createHostUserModeId = `${id}-create-host-user-mode`;
- const createDBUserModeId = `${id}-create-db-user-mode`;
- const defaultSessionRecordingModeId = `${id}-default-session-recording-mode`;
- const sshSessionRecordingModeId = `${id}-ssh-session-recording-mode`;
- return (
-
- Global Settings
-
- Max Session TTL
- onChange({ ...value, maxSessionTTL: e.target.value })}
- />
-
-
- Client Idle Timeout
-
-
- onChange({ ...value, clientIdleTimeout: e.target.value })
- }
- />
-
- Disconnect When Certificate Expires
- onChange({ ...value, disconnectExpiredCert: d })}
- />
-
- Require Session MFA
- onChange?.({ ...value, requireMFAType: t })}
- />
-
-
- Default Session Recording Mode
-
- onChange?.({ ...value, defaultSessionRecordingMode: m })}
- />
-
- SSH
-
-
- Create Host User Mode
-
- onChange?.({ ...value, createHostUserMode: m })}
- />
-
- Agent Forwarding
- onChange({ ...value, forwardAgent: f })}
- />
-
-
- Session Recording Mode
-
- onChange?.({ ...value, sshSessionRecordingMode: m })}
- />
-
- Database
-
- Create Database User
- onChange({ ...value, createDBUser: c })}
- />
-
- {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks the
- createDBUserMode field. Fix it and add the field here. */}
-
- Create Database User Mode
-
- onChange?.({ ...value, createDBUserMode: m })}
- />
-
- Desktop
-
- Create Desktop User
- onChange({ ...value, createDesktopUser: c })}
- />
-
- Allow Clipboard Sharing
- onChange({ ...value, desktopClipboard: c })}
- />
-
- Allow Directory Sharing
- onChange({ ...value, desktopDirectorySharing: s })}
- />
-
- Record Desktop Sessions
- onChange({ ...value, recordDesktopSessions: r })}
- />
-
- );
-}
-
-const OptionsGridContainer = styled(Box)`
- display: grid;
- grid-template-columns: 1fr 1fr;
- align-items: baseline;
- row-gap: ${props => props.theme.space[3]}px;
-`;
-
-const OptionsHeader = styled(H4)<{ separator?: boolean }>`
- grid-column: 1/3;
- border-top: ${props =>
- props.separator
- ? `${props.theme.borders[1]} ${props.theme.colors.interactive.tonal.neutral[0]}`
- : 'none'};
- padding-top: ${props =>
- props.separator ? `${props.theme.space[3]}px` : '0'};
-`;
-
-function BoolRadioGroup({
- name,
- value,
- onChange,
-}: {
- name: string;
- value: boolean;
- onChange(b: boolean): void;
-}) {
- return (
- onChange(d === 'true')}
- />
- );
-}
-
-const OptionLabel = styled(LabelInput)`
- ${props => props.theme.typography.body2}
-`;
-
export const EditorWrapper = styled(Flex)<{ mute?: boolean }>`
flex-direction: column;
flex: 1;
opacity: ${p => (p.mute ? 0.4 : 1)};
pointer-events: ${p => (p.mute ? 'none' : '')};
`;
-
-// TODO(bl-nero): This should ideally use tonal neutral 1 from the opposite
-// theme as background.
-const MarkInverse = styled(Mark)`
- background: ${p => p.theme.colors.text.primaryInverse};
- color: ${p => p.theme.colors.text.main};
-`;
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StatefulSection.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StatefulSection.tsx
new file mode 100644
index 0000000000000..24642b574f840
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/StatefulSection.tsx
@@ -0,0 +1,59 @@
+/**
+ * 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 React, { useState } from 'react';
+
+import Validation, { Validator } from 'shared/components/Validation';
+
+import { SectionProps } from './sections';
+
+/** A helper for testing editor section components. */
+export function StatefulSection({
+ defaultValue,
+ component: Component,
+ onChange,
+ validatorRef,
+ validate,
+}: {
+ defaultValue: Spec;
+ component: React.ComponentType>;
+ onChange(spec: Spec): void;
+ validatorRef?(v: Validator): void;
+ validate(arg: Spec): ValidationResult;
+}) {
+ const [model, setModel] = useState(defaultValue);
+ const validation = validate(model);
+ return (
+
+ {({ validator }) => {
+ validatorRef?.(validator);
+ return (
+ {
+ setModel(spec);
+ onChange(spec);
+ }}
+ />
+ );
+ }}
+
+ );
+}
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/sections.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/sections.tsx
new file mode 100644
index 0000000000000..45e4be64d74b8
--- /dev/null
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/sections.tsx
@@ -0,0 +1,130 @@
+/**
+ * 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 Box from 'design/Box';
+import ButtonIcon from 'design/ButtonIcon';
+import Flex from 'design/Flex';
+import { Minus, Plus, Trash } from 'design/Icon';
+import { H3 } from 'design/Text';
+import { IconTooltip, HoverTooltip } from 'design/Tooltip';
+import { useState } from 'react';
+import { useValidation } from 'shared/components/Validation';
+import { ValidationResult } from 'shared/components/Validation/rules';
+import { useTheme } from 'styled-components';
+
+export type SectionProps = {
+ value: Model;
+ isProcessing: boolean;
+ validation?: ValidationResult;
+ onChange?(value: Model): void;
+};
+
+/**
+ * A wrapper for editor section. Its responsibility is rendering a header,
+ * expanding, collapsing, and removing the section.
+ */
+export const Section = ({
+ title,
+ tooltip,
+ children,
+ removable,
+ isProcessing,
+ validation,
+ onRemove,
+}: React.PropsWithChildren<{
+ title: string;
+ tooltip: string;
+ removable?: boolean;
+ isProcessing: boolean;
+ validation?: ValidationResult;
+ onRemove?(): void;
+}>) => {
+ const theme = useTheme();
+ const [expanded, setExpanded] = useState(true);
+ const ExpandIcon = expanded ? Minus : 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
+ // track of the state.
+ e.preventDefault();
+ setExpanded(expanded => !expanded);
+ };
+
+ const handleRemove = (e: React.MouseEvent) => {
+ // Don't let handle the event.
+ e.stopPropagation();
+ onRemove?.();
+ };
+
+ return (
+
+
+ {/* TODO(bl-nero): Show validation result in the summary. */}
+
+ {title}
+ {tooltip && {tooltip}}
+
+ {removable && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ {children}
+
+
+ );
+};