diff --git a/web/.storybook/preview.js b/web/.storybook/preview.js index 02a1dba03f7c4..f0b78642e478a 100644 --- a/web/.storybook/preview.js +++ b/web/.storybook/preview.js @@ -74,7 +74,6 @@ export const globalTypes = { toolbar: { icon: 'contrast', items: ['Light Theme', 'Dark Theme'], - showName: true, dynamicTitle: true, }, }, diff --git a/web/packages/shared/components/Validation/index.js b/web/packages/shared/components/Validation/index.ts similarity index 100% rename from web/packages/shared/components/Validation/index.js rename to web/packages/shared/components/Validation/index.ts diff --git a/web/packages/shared/components/Validation/rules.test.js b/web/packages/shared/components/Validation/rules.test.ts similarity index 79% rename from web/packages/shared/components/Validation/rules.test.js rename to web/packages/shared/components/Validation/rules.test.ts index 322384540fe04..43995b5b003b3 100644 --- a/web/packages/shared/components/Validation/rules.test.js +++ b/web/packages/shared/components/Validation/rules.test.ts @@ -19,6 +19,7 @@ import { requiredPassword, requiredConfirmedPassword, requiredField, + requiredRoleArn, } from './rules'; describe('requiredField', () => { @@ -61,6 +62,23 @@ describe('requiredPassword', () => { }); }); +describe('requiredRoleArn', () => { + test.each` + roleArn | valid + ${'arn:aws:iam::123456:role/some-role-name'} | ${true} + ${'arn:aws:iam::123456:role:some-role-name'} | ${true} + ${'arn:aws:iam:123456:role:some-role-name'} | ${true} + ${'arn:iam:123456:role:some-role-name'} | ${false} + ${'arn:aws:iam:123456:some-role-name'} | ${false} + ${'arn:aws:123456:role:some-role-name'} | ${false} + ${''} | ${false} + ${null} | ${false} + `('test valid role arn: $roleArn', ({ roleArn, valid }) => { + const result = requiredRoleArn(roleArn)(); + expect(result.valid).toEqual(valid); + }); +}); + describe('requiredConfirmedPassword', () => { const mismatchError = 'Password does not match'; const confirmError = 'Please confirm your password'; diff --git a/web/packages/shared/components/Validation/rules.js b/web/packages/shared/components/Validation/rules.ts similarity index 53% rename from web/packages/shared/components/Validation/rules.js rename to web/packages/shared/components/Validation/rules.ts index a063338e40549..086b98c9d8e8c 100644 --- a/web/packages/shared/components/Validation/rules.js +++ b/web/packages/shared/components/Validation/rules.ts @@ -20,7 +20,7 @@ limitations under the License. * @param message The custom error message to display to users. * @param value The value user entered. */ -const requiredField = message => value => () => { +const requiredField = (message: string) => (value: string) => () => { const valid = !(!value || value.length === 0); return { valid, @@ -28,7 +28,7 @@ const requiredField = message => value => () => { }; }; -const requiredToken = value => () => { +const requiredToken = (value: string) => () => { if (!value || value.length === 0) { return { valid: false, @@ -41,7 +41,7 @@ const requiredToken = value => () => { }; }; -const requiredPassword = value => () => { +const requiredPassword = (value: string) => () => { if (!value || value.length < 6) { return { valid: false, @@ -54,23 +54,51 @@ const requiredPassword = value => () => { }; }; -const requiredConfirmedPassword = password => confirmedPassword => () => { - if (!confirmedPassword) { +const requiredConfirmedPassword = + (password: string) => (confirmedPassword: string) => () => { + if (!confirmedPassword) { + return { + valid: false, + message: 'Please confirm your password', + }; + } + + if (confirmedPassword !== password) { + return { + valid: false, + message: 'Password does not match', + }; + } + return { - valid: false, - message: 'Please confirm your password', + valid: true, }; + }; + +// requiredRoleArn checks provided arn (AWS role name) is somewhat +// in the format as documented here: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html +const requiredRoleArn = (roleArn: string) => () => { + let parts = []; + if (roleArn) { + parts = roleArn.split(':role'); } - if (confirmedPassword !== password) { + if ( + parts.length == 2 && + parts[0].startsWith('arn:aws:iam:') && + // the `:role` part can be followed by a forward slash or a colon, + // followed by the role name. + parts[1].length >= 2 + ) { return { - valid: false, - message: 'Password does not match', + valid: true, }; } return { - valid: true, + valid: false, + message: 'invalid role ARN format', }; }; @@ -79,4 +107,5 @@ export { requiredPassword, requiredConfirmedPassword, requiredField, + requiredRoleArn, }; diff --git a/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx b/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx new file mode 100644 index 0000000000000..f470315e2d690 --- /dev/null +++ b/web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx @@ -0,0 +1,102 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState } from 'react'; +import { ButtonSecondary, ButtonPrimary, Alert, Text } from 'design'; +import Dialog, { + DialogHeader, + DialogTitle, + DialogContent, + DialogFooter, +} from 'design/DialogConfirmation'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import FieldInput from 'shared/components/FieldInput'; +import Validation, { Validator } from 'shared/components/Validation'; +import { requiredRoleArn } from 'shared/components/Validation/rules'; + +import { Integration } from 'teleport/services/integrations'; + +import { EditableIntegrationFields } from './Operations/useIntegrationOperation'; + +type Props = { + close(): void; + edit(req: EditableIntegrationFields): Promise; + integration: Integration; +}; + +export function EditIntegrationDialog(props: Props) { + const { close, edit, integration } = props; + const { attempt, run } = useAttempt(); + + const [roleArn, setRoleArn] = useState(integration.spec.roleArn); + + const isProcessing = attempt.status === 'processing'; + + function handleEdit(validator: Validator) { + if (!validator.validate()) { + return; + } + + run(() => edit({ roleArn })); + } + + return ( + + {({ validator }) => ( + + + Edit Integration + + + {attempt.status === 'failed' && ( + + )} + + setRoleArn(e.target.value)} + toolTipContent={ + + Role ARN can be found in the format:
+ {`arn:aws:iam:::role/`} +
+ } + /> +
+ + handleEdit(validator)} + > + Save + + + Cancel + + +
+ )} +
+ ); +} diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx index 72c5461057379..b1adb69b50afb 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/AwsOidc.tsx @@ -74,6 +74,7 @@ const RestartAnimation = styled.div` left: 50%; transform: translate(-50%, 0); box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); + color: ${props => props.theme.colors.light}; &:hover { box-shadow: 0 0 15px rgba(0, 0, 0, 0.5); diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx index 1e0b4b08dba22..4e907ab91ce67 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SecondStageInstructions.tsx @@ -18,15 +18,16 @@ import React, { useState } from 'react'; import Text from 'design/Text'; import Box from 'design/Box'; - import { ButtonPrimary } from 'design'; +import * as Icons from 'design/Icon'; import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; - +import useAttempt from 'shared/hooks/useAttemptNext'; import { requiredField } from 'shared/components/Validation/rules'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; +import { integrationService } from 'teleport/services/integrations'; import { InstructionsContainer } from './common'; @@ -34,15 +35,28 @@ import type { CommonInstructionsProps } from './common'; export function SecondStageInstructions(props: CommonInstructionsProps) { const [thumbprint, setThumbprint] = useState(''); + const { attempt, run } = useAttempt(); function handleSubmit(validator: Validator) { if (!validator.validate()) { return; } - // TODO(lisa): validate thumbprint with the back. - // This is a nice to have, so not a blocker. - props.onNext(); + run(() => + integrationService.fetchThumbprint().then(fetchedThumbprint => { + if (thumbprint === fetchedThumbprint) { + props.onNext(); + return; + } + + // the wrapper `run` will catch this error and + // set the attempt to failed. + throw new Error( + `the thumbprint provided is incorrect, make sure\ + you copied the correct thumbprint from the AWS page` + ); + }) + ); } return ( @@ -92,16 +106,27 @@ export function SecondStageInstructions(props: CommonInstructionsProps) { <> setThumbprint(e.target.value)} value={thumbprint} placeholder="Paste the thumbprint here" rule={requiredField('Thumbprint is required')} + markAsError={attempt.status === 'failed'} /> - - handleSubmit(validator)}> + {attempt.status === 'failed' && ( + + + Error: {attempt.statusText} + + )} + + handleSubmit(validator)} + disabled={attempt.status === 'processing'} + > Next diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx index d3cc7a3fbd1b5..651db0b5fadea 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/instructions/SeventhStageInstructions.tsx @@ -30,7 +30,10 @@ import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; import useAttempt from 'shared/hooks/useAttemptNext'; -import { requiredField } from 'shared/components/Validation/rules'; +import { + requiredField, + requiredRoleArn, +} from 'shared/components/Validation/rules'; import { IntegrationKind, @@ -83,7 +86,13 @@ export function SeventhStageInstructions() { onChange={e => setRoleArn(e.target.value)} value={roleArn} placeholder="Role ARN" - rule={requiredField('Role ARN is required')} + rule={requiredRoleArn} + toolTipContent={ + + Role ARN can be found in the format:
+ {`arn:aws:iam:::role/`} +
+ } />
Give this AWS integration a name diff --git a/web/packages/teleport/src/Integrations/IntegrationList.tsx b/web/packages/teleport/src/Integrations/IntegrationList.tsx index 7b74503bf812a..2bc9de1ef13e1 100644 --- a/web/packages/teleport/src/Integrations/IntegrationList.tsx +++ b/web/packages/teleport/src/Integrations/IntegrationList.tsx @@ -37,7 +37,10 @@ import { type Props = { list: IntegrationLike[]; onDeletePlugin?(p: Plugin): void; - onDeleteIntegration?(i: Integration): void; + integrationOps?: { + onDeleteIntegration(i: Integration): void; + onEditIntegration(i: Integration): void; + }; }; type IntegrationLike = Integration | Plugin; @@ -87,7 +90,16 @@ export function IntegrationList(props: Props) { return ( - props.onDeleteIntegration(item)}> + props.integrationOps.onEditIntegration(item)} + > + Edit... + + + props.integrationOps.onDeleteIntegration(item) + } + > Delete... diff --git a/web/packages/teleport/src/Integrations/Integrations.story.tsx b/web/packages/teleport/src/Integrations/Integrations.story.tsx index b63a10d71d740..ed79f552eca61 100644 --- a/web/packages/teleport/src/Integrations/Integrations.story.tsx +++ b/web/packages/teleport/src/Integrations/Integrations.story.tsx @@ -16,8 +16,14 @@ import React from 'react'; +import { + IntegrationKind, + IntegrationStatusCode, +} from 'teleport/services/integrations'; + import { IntegrationList } from './IntegrationList'; -import { DeleteIntegrationDialog } from './DeleteIntegrationDialog'; +import { DeleteIntegrationDialog } from './RemoveIntegrationDialog'; +import { EditIntegrationDialog } from './EditIntegrationDialog'; import { plugins, integrations } from './fixtures'; export default { @@ -31,9 +37,25 @@ export function List() { export function DeleteDialog() { return ( null} - onDelete={() => null} + close={() => null} + remove={() => null} name="some-integration-name" /> ); } + +export function EditDialog() { + return ( + null} + edit={() => null} + integration={{ + resourceType: 'integration', + kind: IntegrationKind.AwsOidc, + name: 'some-integration-name', + spec: { roleArn: 'arn:aws:iam::123456789012:roles/johndoe' }, + statusCode: IntegrationStatusCode.Running, + }} + /> + ); +} diff --git a/web/packages/teleport/src/Integrations/Integrations.tsx b/web/packages/teleport/src/Integrations/Integrations.tsx index fb4b605fba0ec..94708573eafd7 100644 --- a/web/packages/teleport/src/Integrations/Integrations.tsx +++ b/web/packages/teleport/src/Integrations/Integrations.tsx @@ -28,10 +28,10 @@ import { integrationService } from 'teleport/services/integrations'; import { IntegrationsAddButton } from './IntegrationsAddButton'; import { IntegrationList } from './IntegrationList'; -import { DeleteIntegrationDialog } from './DeleteIntegrationDialog'; -import { useIntegrationOperation } from './useIntegrationOperation'; +import { useIntegrationOperation, IntegrationOperations } from './Operations'; import type { Integration } from 'teleport/services/integrations'; +import type { EditableIntegrationFields } from './Operations/useIntegrationOperation'; export function Integrations() { const integrationOps = useIntegrationOperation(); @@ -49,7 +49,7 @@ export function Integrations() { ); }, []); - function deleteIntegration() { + function removeIntegration() { return integrationOps.remove().then(() => { const updatedItems = items.filter( i => i.name !== integrationOps.item.name @@ -59,6 +59,19 @@ export function Integrations() { }); } + function editIntegration(req: EditableIntegrationFields) { + return integrationOps.edit(req).then(updatedIntegration => { + const updatedItems = items.map(item => { + if (item.name == integrationOps.item.name) { + return updatedIntegration; + } + return item; + }); + setItems(updatedItems); + integrationOps.clear(); + }); + } + return ( <> @@ -75,17 +88,20 @@ export function Integrations() { {attempt.status === 'success' && ( )} - {integrationOps.type === 'delete' && ( - - )} + ); } diff --git a/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx b/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx new file mode 100644 index 0000000000000..75a1e07a98f3d --- /dev/null +++ b/web/packages/teleport/src/Integrations/Operations/IntegrationOperations.tsx @@ -0,0 +1,65 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; + +import { Integration } from 'teleport/services/integrations'; + +import { DeleteIntegrationDialog } from '../RemoveIntegrationDialog'; +import { EditIntegrationDialog } from '../EditIntegrationDialog'; + +import { + OperationType, + EditableIntegrationFields, +} from './useIntegrationOperation'; + +type Props = { + operation: OperationType; + integration: Integration; + close(): void; + edit(req: EditableIntegrationFields): Promise; + remove(): Promise; +}; + +export function IntegrationOperations({ + operation, + integration, + close, + edit, + remove, +}: Props) { + if (operation === 'delete') { + return ( + + ); + } + + if (operation === 'edit') { + return ( + + ); + } + + return null; +} diff --git a/web/packages/teleport/src/Integrations/Operations/index.ts b/web/packages/teleport/src/Integrations/Operations/index.ts new file mode 100644 index 0000000000000..3a4681fab212a --- /dev/null +++ b/web/packages/teleport/src/Integrations/Operations/index.ts @@ -0,0 +1,18 @@ +/** + * Copyright 2023 Gravitational, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { IntegrationOperations } from './IntegrationOperations'; +export { useIntegrationOperation } from './useIntegrationOperation'; diff --git a/web/packages/teleport/src/Integrations/useIntegrationOperation.ts b/web/packages/teleport/src/Integrations/Operations/useIntegrationOperation.ts similarity index 74% rename from web/packages/teleport/src/Integrations/useIntegrationOperation.ts rename to web/packages/teleport/src/Integrations/Operations/useIntegrationOperation.ts index b96b80336e594..236c17e27433d 100644 --- a/web/packages/teleport/src/Integrations/useIntegrationOperation.ts +++ b/web/packages/teleport/src/Integrations/Operations/useIntegrationOperation.ts @@ -32,19 +32,37 @@ export function useIntegrationOperation() { return integrationService.deleteIntegration(operation.item.name); } + function edit(req: EditableIntegrationFields) { + return integrationService.updateIntegration(operation.item.name, { + awsoidc: { roleArn: req.roleArn }, + }); + } + function onRemove(item: Integration) { setOperation({ type: 'delete', item }); } + function onEdit(item: Integration) { + setOperation({ type: 'edit', item }); + } + return { ...operation, clear, remove, + edit, onRemove, + onEdit, }; } +export type EditableIntegrationFields = { + roleArn: string; +}; + +export type OperationType = 'create' | 'edit' | 'delete' | 'reset' | 'none'; + export type Operation = { - type: 'create' | 'edit' | 'delete' | 'reset' | 'none'; + type: OperationType; item?: Plugin | Integration; }; diff --git a/web/packages/teleport/src/Integrations/DeleteIntegrationDialog.tsx b/web/packages/teleport/src/Integrations/RemoveIntegrationDialog.tsx similarity index 86% rename from web/packages/teleport/src/Integrations/DeleteIntegrationDialog.tsx rename to web/packages/teleport/src/Integrations/RemoveIntegrationDialog.tsx index 8f315edb20402..32f21772834ff 100644 --- a/web/packages/teleport/src/Integrations/DeleteIntegrationDialog.tsx +++ b/web/packages/teleport/src/Integrations/RemoveIntegrationDialog.tsx @@ -25,22 +25,22 @@ import Dialog, { import useAttempt from 'shared/hooks/useAttemptNext'; type Props = { - onClose(): void; - onDelete(): Promise; + close(): void; + remove(): Promise; name: string; }; export function DeleteIntegrationDialog(props: Props) { - const { onClose, onDelete } = props; + const { close, remove } = props; const { attempt, run } = useAttempt(); const isDisabled = attempt.status === 'processing'; function onOk() { - run(() => onDelete()); + run(() => remove()); } return ( - + Delete Integration? @@ -56,9 +56,9 @@ export function DeleteIntegrationDialog(props: Props) { - Yes, Remove Integration + Yes, Delete Integration - + Cancel diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index de9ea5ff8b750..67edd2b64f8c9 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -220,6 +220,7 @@ const cfg = { headlessLogin: '/v1/webapi/headless/:headless_authentication_id', integrationsPath: '/v1/webapi/sites/:clusterId/integrations/:name?', + thumbprintPath: '/v1/webapi/thumbprint', awsRdsDbListPath: '/v1/webapi/sites/:clusterId/integrations/aws-oidc/:name/databases', diff --git a/web/packages/teleport/src/services/integrations/integrations.ts b/web/packages/teleport/src/services/integrations/integrations.ts index 98af8fab4b009..5012b20455090 100644 --- a/web/packages/teleport/src/services/integrations/integrations.ts +++ b/web/packages/teleport/src/services/integrations/integrations.ts @@ -20,6 +20,7 @@ import cfg from 'teleport/config'; import { Integration, IntegrationCreateRequest, + IntegrationUpdateRequest, IntegrationStatusCode, IntegrationListResponse, AwsOidcListDatabasesRequest, @@ -47,14 +48,21 @@ export const integrationService = { return api.post(cfg.getIntegrationsUrl(), req); }, - updateIntegration(name: string): Promise { - return api.put(cfg.getIntegrationsUrl(name)); + updateIntegration( + name: string, + req: IntegrationUpdateRequest + ): Promise { + return api.put(cfg.getIntegrationsUrl(name), req).then(makeIntegration); }, deleteIntegration(name: string): Promise { return api.delete(cfg.getIntegrationsUrl(name)); }, + fetchThumbprint(): Promise { + return api.get(cfg.api.thumbprintPath); + }, + fetchAwsRdsDatabases( integrationName, rdsEngineIdentifier: RdsEngineIdentifier, diff --git a/web/packages/teleport/src/services/integrations/types.ts b/web/packages/teleport/src/services/integrations/types.ts index 5d932bd256eef..6b49c6a8d134d 100644 --- a/web/packages/teleport/src/services/integrations/types.ts +++ b/web/packages/teleport/src/services/integrations/types.ts @@ -201,3 +201,9 @@ export type ListAwsRdsDatabaseResponse = { // Empty value means last page. nextToken?: string; }; + +export type IntegrationUpdateRequest = { + awsoidc: { + roleArn: string; + }; +};