Add support for locking backups to prevent any accidental deletions
This commit is contained in:
parent
5f48712c28
commit
5d5e4ca7b1
18 changed files with 250 additions and 88 deletions
|
@ -1,10 +1,16 @@
|
|||
import React, { useState } from 'react';
|
||||
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
faBoxOpen,
|
||||
faCloudDownloadAlt,
|
||||
faEllipsisH,
|
||||
faLock,
|
||||
faTrashAlt,
|
||||
faUnlock,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
|
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
|
|||
import { ServerContext } from '@/state/server';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { restoreServerBackup } from '@/api/server/backups';
|
||||
import http, { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
|
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
|
|||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
const onLockToggle = () => {
|
||||
if (backup.isLocked && modal !== 'unlock') {
|
||||
return setModal('unlock');
|
||||
}
|
||||
|
||||
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
|
||||
.then(() => mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
|
||||
...b,
|
||||
isLocked: !b.isLocked,
|
||||
}),
|
||||
}), false))
|
||||
.catch(error => alert(httpErrorToHuman(error)))
|
||||
.then(() => setModal(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChecksumModal
|
||||
appear
|
||||
visible={modal === 'checksum'}
|
||||
onDismissed={() => setModal('')}
|
||||
checksum={backup.checksum}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
visible={modal === 'unlock'}
|
||||
title={'Unlock this backup?'}
|
||||
onConfirmed={onLockToggle}
|
||||
onModalDismissed={() => setModal('')}
|
||||
buttonText={'Yes, unlock'}
|
||||
>
|
||||
Are you sure you want to unlock this backup? It will no longer be protected from automated or
|
||||
accidental deletions.
|
||||
</ConfirmationModal>
|
||||
<ConfirmationModal
|
||||
visible={modal === 'restore'}
|
||||
title={'Restore this backup?'}
|
||||
|
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
|
|||
<span css={tw`ml-2`}>Restore</span>
|
||||
</DropdownButtonRow>
|
||||
</Can>
|
||||
<DropdownButtonRow onClick={() => setModal('checksum')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Checksum</span>
|
||||
</DropdownButtonRow>
|
||||
<Can action={'backup.delete'}>
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
<>
|
||||
<DropdownButtonRow onClick={onLockToggle}>
|
||||
<FontAwesomeIcon
|
||||
fixedWidth
|
||||
icon={backup.isLocked ? faUnlock : faLock}
|
||||
css={tw`text-xs mr-2`}
|
||||
/>
|
||||
{backup.isLocked ? 'Unlock' : 'Lock'}
|
||||
</DropdownButtonRow>
|
||||
{!backup.isLocked &&
|
||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
}
|
||||
</>
|
||||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { bytesToHuman } from '@/helpers';
|
||||
|
@ -45,7 +45,10 @@ export default ({ backup, className }: Props) => {
|
|||
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
||||
<div css={tw`mr-4`}>
|
||||
{backup.completedAt ?
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
backup.isLocked ?
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
}
|
||||
|
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
|
|||
}
|
||||
</div>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
{backup.uuid}
|
||||
{backup.checksum}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
import React from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
|
||||
<Modal {...props}>
|
||||
<h3 css={tw`mb-6 text-2xl`}>Verify file checksum</h3>
|
||||
<p css={tw`text-sm`}>
|
||||
The checksum of this file is:
|
||||
</p>
|
||||
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
||||
<code css={tw`block font-mono overflow-auto`}>{checksum}</code>
|
||||
</pre>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default ChecksumModal;
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import { boolean, object, string } from 'yup';
|
||||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
@ -12,10 +12,13 @@ import tw from 'twin.macro';
|
|||
import { Textarea } from '@/components/elements/Input';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
ignored: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
|
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
<div css={tw`mt-6`}>
|
||||
<FormikFieldWrapper
|
||||
name={'ignored'}
|
||||
label={'Ignored Files & Directories'}
|
||||
|
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</div>
|
||||
<div css={tw`flex justify-end`}>
|
||||
<Can action={'backup.delete'}>
|
||||
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||
<FormikSwitch
|
||||
name={'isLocked'}
|
||||
label={'Locked'}
|
||||
description={'Prevents this backup from being deleted until explicitly unlocked.'}
|
||||
/>
|
||||
</div>
|
||||
</Can>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'submit'} disabled={isSubmitting}>
|
||||
Start backup
|
||||
</Button>
|
||||
|
@ -67,9 +77,9 @@ export default () => {
|
|||
clearFlashes('backups:create');
|
||||
}, [ visible ]);
|
||||
|
||||
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, name, ignored)
|
||||
createServerBackup(uuid, values)
|
||||
.then(backup => {
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||
setVisible(false);
|
||||
|
@ -85,10 +95,11 @@ export default () => {
|
|||
{visible &&
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '' }}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue