feat(ssh-keys): add ssh key endpoints and ui components

This commit is contained in:
Matthew Penner 2021-07-17 15:45:46 -06:00
parent 9d64c6751b
commit f9114e2de0
17 changed files with 375 additions and 7 deletions

View file

@ -0,0 +1,10 @@
import http from '@/api/http';
import { SSHKey, rawDataToSSHKey } from '@/api/account/ssh/getSSHKeys';
export default (name: string, publicKey: string): Promise<SSHKey> => {
return new Promise((resolve, reject) => {
http.post('/api/client/account/ssh', { name, public_key: publicKey })
.then(({ data }) => resolve(rawDataToSSHKey(data.attributes)))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (id: number): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/account/ssh/${id}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -0,0 +1,23 @@
import http from '@/api/http';
export interface SSHKey {
id: number;
name: string;
publicKey: string;
createdAt: Date;
}
export const rawDataToSSHKey = (data: any): SSHKey => ({
id: data.id,
name: data.name,
publicKey: data.public_key,
createdAt: new Date(data.created_at),
});
export default (): Promise<SSHKey[]> => {
return new Promise((resolve, reject) => {
http.get('/api/client/account/ssh')
.then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToSSHKey(d.attributes))))
.catch(reject);
});
};

View file

@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { Field as FormikField, Form, Formik, FormikHelpers } from 'formik';
import tw from 'twin.macro';
import { object, string } from 'yup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import createSSHKey from '@/api/account/ssh/createSSHKey';
import deleteSSHKey from '@/api/account/ssh/deleteSSHKey';
import getSSHKeys, { SSHKey } from '@/api/account/ssh/getSSHKeys';
import FlashMessageRender from '@/components/FlashMessageRender';
import Button from '@/components/elements/Button';
import ContentBox from '@/components/elements/ContentBox';
import Field from '@/components/elements/Field';
import GreyRowBox from '@/components/elements/GreyRowBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import { Textarea } from '@/components/elements/Input';
interface Values {
name: string;
publicKey: string;
}
const AddSSHKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SSHKey) => void }) => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const submit = ({ name, publicKey }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
clearFlashes('ssh_keys');
createSSHKey(name, publicKey)
.then(key => {
resetForm();
onKeyAdded(key);
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
})
.then(() => setSubmitting(false));
};
return (
<Formik
onSubmit={submit}
initialValues={{ name: '', publicKey: '' }}
validationSchema={object().shape({
name: string().required(),
publicKey: string().required(),
})}
>
{({ isSubmitting }) => (
<Form>
<SpinnerOverlay visible={isSubmitting}/>
<Field
type={'text'}
id={'name'}
name={'name'}
label={'Name'}
description={'A descriptive name for this SSH key.'}
/>
<div css={tw`mt-6`}>
<FormikFieldWrapper
name={'publicKey'}
label={'Public Key'}
description={'SSH Public Key starting with ssh-*'}
>
<FormikField as={Textarea} name={'publicKey'} rows={6}/>
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>
</div>
</Form>
)}
</Formik>
);
};
export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ keys, setKeys ] = useState<SSHKey[]>([]);
const [ loading, setLoading ] = useState(true);
const [ deleteId, setDeleteId ] = useState<number | null>(null);
const doDeletion = (id: number | null) => {
if (id === null) {
return;
}
clearFlashes('ssh_keys');
deleteSSHKey(id)
.then(() => setKeys(s => ([
...(s || []).filter(key => key.id !== id),
])))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
});
};
useEffect(() => {
clearFlashes('ssh_keys');
getSSHKeys()
.then(keys => setKeys(keys))
.then(() => setLoading(false))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'ssh_keys', error });
});
}, []);
return (
<PageContentBlock title={'SSH Keys'}>
<FlashMessageRender byKey={'ssh_keys'}/>
<div css={tw`md:flex flex-nowrap my-10`}>
<ContentBox title={'SSH Keys'} css={tw`flex-1 md:mr-8`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal
visible={!!deleteId}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
doDeletion(deleteId);
setDeleteId(null);
}}
onModalDismissed={() => setDeleteId(null)}
>
Are you sure you wish to delete this SSH key?
</ConfirmationModal>
{keys.length === 0 ?
!loading ?
<p css={tw`text-center text-sm`}>
No SSH keys have been configured for this account.
</p>
: null
:
keys.map((key, index) => (
<GreyRowBox key={index} css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.name}</p>
</div>
<button css={tw`ml-4 p-2 text-sm`} onClick={() => setDeleteId(key.id)}>
<FontAwesomeIcon
icon={faTrashAlt}
css={tw`text-neutral-400 hover:text-red-400 transition-colors duration-150`}
/>
</button>
</GreyRowBox>
))
}
</ContentBox>
<ContentBox title={'Add SSH Key'} css={tw`flex-none w-full mt-8 md:mt-0 md:w-1/2`}>
<AddSSHKeyForm onKeyAdded={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
</div>
</PageContentBlock>
);
};

View file

@ -119,8 +119,8 @@ export default () => {
}}
onModalDismissed={() => setDeleteId(null)}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
Are you sure you wish to delete this security key?
You will no longer be able to authenticate using this key.
</ConfirmationModal>
{keys.length === 0 ?
!loading ?

View file

@ -6,6 +6,7 @@ import DashboardContainer from '@/components/dashboard/DashboardContainer';
import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer';
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
import SecurityKeyContainer from '@/components/dashboard/SecurityKeyContainer';
import SSHKeyContainer from '@/components/dashboard/SSHKeyContainer';
import { NotFound } from '@/components/elements/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';
@ -18,6 +19,7 @@ export default ({ location }: RouteComponentProps) => (
<NavLink to={'/account'} exact>Settings</NavLink>
<NavLink to={'/account/api'}>API Credentials</NavLink>
<NavLink to={'/account/keys/security'}>Security Keys</NavLink>
<NavLink to={'/account/keys/ssh'}>SSH Keys</NavLink>
</div>
</SubNavigation>
}
@ -35,6 +37,9 @@ export default ({ location }: RouteComponentProps) => (
<Route path={'/account/keys/security'} exact>
<SecurityKeyContainer/>
</Route>
<Route path={'/account/keys/ssh'} exact>
<SSHKeyContainer/>
</Route>
<Route path={'*'}>
<NotFound/>
</Route>