Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion web/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export const globalTypes = {
toolbar: {
icon: 'contrast',
items: ['Light Theme', 'Dark Theme'],
showName: true,
dynamicTitle: true,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
requiredPassword,
requiredConfirmedPassword,
requiredField,
requiredRoleArn,
} from './rules';

describe('requiredField', () => {
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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,
message: !valid ? message : '',
};
};

const requiredToken = value => () => {
const requiredToken = (value: string) => () => {
if (!value || value.length === 0) {
return {
valid: false,
Expand All @@ -41,7 +41,7 @@ const requiredToken = value => () => {
};
};

const requiredPassword = value => () => {
const requiredPassword = (value: string) => () => {
if (!value || value.length < 6) {
return {
valid: false,
Expand All @@ -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',
};
};

Expand All @@ -79,4 +107,5 @@ export {
requiredPassword,
requiredConfirmedPassword,
requiredField,
requiredRoleArn,
};
102 changes: 102 additions & 0 deletions web/packages/teleport/src/Integrations/EditIntegrationDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
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 (
<Validation>
{({ validator }) => (
<Dialog disableEscapeKeyDown={false} onClose={close} open={true}>
<DialogHeader>
<DialogTitle>Edit Integration</DialogTitle>
</DialogHeader>
<DialogContent width="450px">
{attempt.status === 'failed' && (
<Alert children={attempt.statusText} />
)}
<FieldInput
label="Integration Name"
value={integration.name}
readonly={true}
/>
<FieldInput
autoFocus
label="Role ARN"
rule={requiredRoleArn}
value={roleArn}
onChange={e => setRoleArn(e.target.value)}
toolTipContent={
<Text>
Role ARN can be found in the format: <br />
{`arn:aws:iam::<ACCOUNT_ID>:role/<ROLE_NAME>`}
</Text>
}
/>
</DialogContent>
<DialogFooter>
<ButtonPrimary
mr="3"
disabled={isProcessing || roleArn === integration.spec.roleArn}
onClick={() => handleEdit(validator)}
>
Save
</ButtonPrimary>
<ButtonSecondary disabled={isProcessing} onClick={close}>
Cancel
</ButtonSecondary>
</DialogFooter>
</Dialog>
)}
</Validation>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,31 +18,45 @@ 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';

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 (
Expand Down Expand Up @@ -92,16 +106,27 @@ export function SecondStageInstructions(props: CommonInstructionsProps) {
<>
<Box mt={2}>
<FieldInput
mb={1}
autoFocus
label="thumbprint"
onChange={e => setThumbprint(e.target.value)}
value={thumbprint}
placeholder="Paste the thumbprint here"
rule={requiredField('Thumbprint is required')}
markAsError={attempt.status === 'failed'}
/>
</Box>
<Box mt={5}>
<ButtonPrimary onClick={() => handleSubmit(validator)}>
{attempt.status === 'failed' && (
<Text color="error.main">
<Icons.Warning mr={2} color="error.main" />
Error: {attempt.statusText}
</Text>
)}
<Box mt={4}>
<ButtonPrimary
onClick={() => handleSubmit(validator)}
disabled={attempt.status === 'processing'}
>
Next
</ButtonPrimary>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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={
<Text>
Role ARN can be found in the format: <br />
{`arn:aws:iam::<ACCOUNT_ID>:role/<ROLE_NAME>`}
</Text>
}
/>
</Box>
<Text mt={5}>Give this AWS integration a name</Text>
Expand Down
16 changes: 14 additions & 2 deletions web/packages/teleport/src/Integrations/IntegrationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ import {
type Props<IntegrationLike> = {
list: IntegrationLike[];
onDeletePlugin?(p: Plugin): void;
onDeleteIntegration?(i: Integration): void;
integrationOps?: {
onDeleteIntegration(i: Integration): void;
onEditIntegration(i: Integration): void;
};
};

type IntegrationLike = Integration | Plugin;
Expand Down Expand Up @@ -87,7 +90,16 @@ export function IntegrationList(props: Props<IntegrationLike>) {
return (
<Cell align="right">
<MenuButton>
<MenuItem onClick={() => props.onDeleteIntegration(item)}>
<MenuItem
onClick={() => props.integrationOps.onEditIntegration(item)}
>
Edit...
</MenuItem>
<MenuItem
onClick={() =>
props.integrationOps.onDeleteIntegration(item)
}
>
Delete...
</MenuItem>
</MenuButton>
Expand Down
Loading