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 + onChange?.({ ...value, defaultSessionRecordingMode: m })} + /> + + SSH + + + Create Host User 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, 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, defaultSessionRecordingMode: m })} - /> - - SSH - - - Create Host User 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 - -