Merge branch 'develop' into fix/2071

This commit is contained in:
Matthew Penner 2020-07-04 15:22:25 -06:00 committed by GitHub
commit e4d141fa6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
165 changed files with 5484 additions and 4129 deletions

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import loginCheckpoint from '@/api/auth/loginCheckpoint';
import { httpErrorToHuman } from '@/api/http';
@ -14,6 +14,7 @@ import Field from '@/components/elements/Field';
interface Values {
code: string;
recoveryCode: '',
}
type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }>
@ -24,7 +25,8 @@ type Props = OwnProps & {
}
const LoginCheckpointContainer = () => {
const { isSubmitting } = useFormikContext<Values>();
const { isSubmitting, setFieldValue } = useFormikContext<Values>();
const [ isMissingDevice, setIsMissingDevice ] = useState(false);
return (
<LoginFormContainer
@ -34,10 +36,14 @@ const LoginCheckpointContainer = () => {
<div className={'mt-6'}>
<Field
light={true}
name={'code'}
title={'Authentication Code'}
description={'Enter the two-factor token generated by your device.'}
type={'number'}
name={isMissingDevice ? 'recoveryCode' : 'code'}
title={isMissingDevice ? 'Recovery Code' : 'Authentication Code'}
description={
isMissingDevice
? 'Enter one of the recovery codes generated when you setup 2-Factor authentication on this account in order to continue.'
: 'Enter the two-factor token generated by your device.'
}
type={isMissingDevice ? 'text' : 'number'}
autoFocus={true}
/>
</div>
@ -54,6 +60,18 @@ const LoginCheckpointContainer = () => {
}
</button>
</div>
<div className={'mt-6 text-center'}>
<span
onClick={() => {
setFieldValue('code', '');
setFieldValue('recoveryCode', '');
setIsMissingDevice(s => !s);
}}
className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
>
{!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
</span>
</div>
<div className={'mt-6 text-center'}>
<Link
to={'/auth/login'}
@ -67,10 +85,9 @@ const LoginCheckpointContainer = () => {
};
const EnhancedForm = withFormik<Props, Values>({
handleSubmit: ({ code }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
handleSubmit: ({ code, recoveryCode }, { setSubmitting, props: { addError, clearFlashes, location } }) => {
clearFlashes();
console.log(location.state.token, code);
loginCheckpoint(location.state?.token || '', code)
loginCheckpoint(location.state?.token || '', code, recoveryCode)
.then(response => {
if (response.complete) {
// @ts-ignore
@ -89,11 +106,7 @@ const EnhancedForm = withFormik<Props, Values>({
mapPropsToValues: () => ({
code: '',
}),
validationSchema: object().shape({
code: string().required('An authentication code must be provided.')
.length(6, 'Authentication code must be 6 digits in length.'),
recoveryCode: '',
}),
})(LoginCheckpointContainer);

View file

@ -15,7 +15,7 @@ import classNames from 'classnames';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
const isAlarmState = (current: number, limit: number): boolean => {
const limitInBytes = limit * 1000 * 1000;
const limitInBytes = limit * 1024 * 1024;
return current / limitInBytes >= 0.90;
};
@ -52,6 +52,8 @@ export default ({ server, className }: { server: Server; className: string | und
alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory);
alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk);
}
const disklimit = server.limits.disk != 0 ? megabytesToHuman(server.limits.disk) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? megabytesToHuman(server.limits.memory) : "Unlimited";
return (
<Link to={`/server/${server.id}`} className={`grey-row-box cursor-pointer ${className}`}>
@ -127,7 +129,8 @@ export default ({ server, className }: { server: Server; className: string | und
{bytesToHuman(stats.memoryUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {megabytesToHuman(server.limits.memory)}</p>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {memorylimit}</p>
</div>
<div className={'flex-1 ml-4'}>
<div className={'flex justify-center'}>
@ -147,9 +150,8 @@ export default ({ server, className }: { server: Server; className: string | und
{bytesToHuman(stats.diskUsageInBytes)}
</p>
</div>
<p className={'text-xs text-neutral-600 text-center mt-1'}>
of {megabytesToHuman(server.limits.disk)}
</p>
<p className={'text-xs text-neutral-600 text-center mt-1'}>of {disklimit}</p>
</div>
</React.Fragment>
}

View file

@ -2,21 +2,22 @@ import React, { useEffect, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import Field from '@/components/elements/Field';
import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl';
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
interface Values {
code: string;
}
export default ({ ...props }: RequiredModalProps) => {
export default ({ onDismissed, ...props }: RequiredModalProps) => {
const [ token, setToken ] = useState('');
const [ loading, setLoading ] = useState(true);
const [ recoveryTokens, setRecoveryTokens ] = useState<string[]>([]);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -27,22 +28,30 @@ export default ({ ...props }: RequiredModalProps) => {
.then(setToken)
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'account:two-factor' });
});
}, []);
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('account:two-factor');
enableAccountTwoFactor(code)
.then(() => {
updateUserData({ useTotp: true });
props.onDismissed();
.then(tokens => {
setRecoveryTokens(tokens);
})
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'account:two-factor' });
setSubmitting(false);
});
})
.then(() => setSubmitting(false));
};
const dismiss = () => {
if (recoveryTokens.length > 0) {
updateUserData({ useTotp: true });
}
onDismissed();
};
return (
@ -58,47 +67,73 @@ export default ({ ...props }: RequiredModalProps) => {
{({ isSubmitting, isValid }) => (
<Modal
{...props}
onDismissed={dismiss}
dismissable={!isSubmitting}
showSpinnerOverlay={loading || isSubmitting}
closeOnEscape={!recoveryTokens}
closeOnBackground={!recoveryTokens}
>
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<div className={'flex flex-wrap'}>
<div className={'w-full md:flex-1'}>
<div className={'w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto'}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
className={'w-64 h-64 rounded'}
{recoveryTokens.length > 0 ?
<>
<h2 className={'mb-4'}>Two-factor authentication enabled</h2>
<p className={'text-neutral-300'}>
Two-factor authentication has been enabled on your account. Should you loose access to
this device you'll need to use on of the codes displayed below in order to access your
account.
</p>
<p className={'text-neutral-300 mt-4'}>
<strong>These codes will not be displayed again.</strong> Please take note of them now
by storing them in a secure repository such as a password manager.
</p>
<pre className={'mt-4 rounded font-mono bg-neutral-900 p-4'}>
{recoveryTokens.map(token => <code key={token} className={'block mb-1'}>{token}</code>)}
</pre>
<div className={'text-right'}>
<button className={'mt-6 btn btn-lg btn-primary'} onClick={dismiss}>
Close
</button>
</div>
</>
:
<Form className={'mb-0'}>
<FlashMessageRender className={'mb-6'} byKey={'account:two-factor'}/>
<div className={'flex flex-wrap'}>
<div className={'w-full md:flex-1'}>
<div className={'w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto'}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
className={'w-64 h-64 rounded'}
/>
:
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
onLoad={() => setLoading(false)}
className={'w-full h-full shadow-none rounded-0'}
/>
}
</div>
</div>
<div className={'w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col'}>
<div className={'flex-1'}>
<Field
id={'code'}
name={'code'}
type={'text'}
title={'Code From Authenticator'}
description={'Enter the code from your authenticator device after scanning the QR image.'}
autoFocus={!loading}
/>
:
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
onLoad={() => setLoading(false)}
className={'w-full h-full shadow-none rounded-0'}
/>
}
</div>
<div className={'mt-6 md:mt-0 text-right'}>
<button className={'btn btn-primary btn-sm'} disabled={!isValid}>
Setup
</button>
</div>
</div>
</div>
<div className={'w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col'}>
<div className={'flex-1'}>
<Field
id={'code'}
name={'code'}
type={'text'}
title={'Code From Authenticator'}
description={'Enter the code from your authenticator device after scanning the QR image.'}
autoFocus={!loading}
/>
</div>
<div className={'mt-6 md:mt-0 text-right'}>
<button className={'btn btn-primary btn-sm'} disabled={!isValid}>
Setup
</button>
</div>
</div>
</div>
</Form>
</Form>
}
</Modal>
)}
</Formik>

View file

@ -81,6 +81,9 @@ export default () => {
};
}, [ instance, connected ]);
const disklimit = server.limits.disk != 0 ? megabytesToHuman(server.limits.disk) : "Unlimited";
const memorylimit = server.limits.memory != 0 ? megabytesToHuman(server.limits.memory) : "Unlimited";
return (
<PageContentBlock className={'flex'}>
<div className={'w-1/4'}>
@ -112,7 +115,7 @@ export default () => {
className={'mr-1'}
/>
&nbsp;{bytesToHuman(memory)}
<span className={'text-neutral-500'}> / {megabytesToHuman(server.limits.memory)}</span>
<span className={'text-neutral-500'}> / {memorylimit}</span>
</p>
<p className={'text-xs mt-2'}>
<FontAwesomeIcon
@ -121,7 +124,8 @@ export default () => {
className={'mr-1'}
/>
&nbsp;{bytesToHuman(disk)}
<span className={'text-neutral-500'}> / {megabytesToHuman(server.limits.disk)}</span>
<span className={'text-neutral-500'}> / {disklimit}</span>
</p>
</TitledGreyBox>
{!server.isInstalling ?

View file

@ -12,7 +12,7 @@ import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
export default () => {
const { uuid } = useServer();
const { uuid, featureLimits } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
@ -50,10 +50,22 @@ export default () => {
/>)}
</div>
}
{featureLimits.backups === 0 &&
<p className="text-center text-sm text-neutral-400">
Backups cannot be created for this server.
</p>
}
<Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
{backups.length} of {featureLimits.backups} backups have been created for this server.
</p>
}
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
<div className={'mt-6 flex justify-end'}>
<CreateBackupButton/>
</div>
}
</Can>
</PageContentBlock>
);

View file

@ -59,7 +59,12 @@ export default () => {
</p>
}
<Can action={'database.create'}>
{featureLimits.databases > 0 &&
{(featureLimits.databases > 0 && databases.length > 0) &&
<p className="text-center text-xs text-neutral-400 mt-2">
{databases.length} of {featureLimits.databases} databases have been allocated to this server.
</p>
}
{featureLimits.databases > 0 && featureLimits.databases !== databases.length &&
<div className={'mt-6 flex justify-end'}>
<CreateDatabaseButton/>
</div>

View file

@ -110,7 +110,7 @@ export default () => {
fetchContent={value => {
fetchFileContent = value;
}}
onContentSaved={() => null}
onContentSaved={() => save()}
/>
</div>
<div className={'flex justify-end mt-4'}>

View file

@ -25,10 +25,10 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
.filter(directory => !!directory)
.map((directory, index, dirs) => {
if (!withinFileEditor && index === dirs.length - 1) {
return { name: directory };
return { name: decodeURIComponent(directory) };
}
return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` };
return { name: decodeURIComponent(directory), path: `/${dirs.slice(0, index + 1).join('/')}` };
});
return (
@ -57,7 +57,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
}
{file &&
<React.Fragment>
<span className={'px-1 text-neutral-300'}>{file}</span>
<span className={'px-1 text-neutral-300'}>{decodeURIComponent(file)}</span>
</React.Fragment>
}
</div>

View file

@ -70,7 +70,12 @@ export default () => {
/>
<p className={'text-xs mt-2 text-neutral-400'}>
<span className={'text-neutral-200'}>This directory will be created as</span>
&nbsp;/home/container/<span className={'text-cyan-200'}>{join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')}</span>
&nbsp;/home/container/
<span className={'text-cyan-200'}>
{decodeURIComponent(
join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''),
)}
</span>
</p>
<div className={'flex justify-end'}>
<button className={'btn btn-sm btn-primary mt-8'}>

View file

@ -14,12 +14,26 @@ import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
import { faFileArchive } from '@fortawesome/free-solid-svg-icons/faFileArchive';
interface Props {
schedule: Schedule;
task: Task;
}
const getActionDetails = (action: string): [ string, any ] => {
switch (action) {
case 'command':
return ['Send Command', faCode];
case 'power':
return ['Send Power Action', faToggleOn];
case 'backup':
return ['Create Backup', faFileArchive];
default:
return ['Unknown Action', faCode];
}
};
export default ({ schedule, task }: Props) => {
const { uuid } = useServer();
const { clearFlashes, addError } = useFlash();
@ -43,6 +57,8 @@ export default ({ schedule, task }: Props) => {
});
};
const [ title, icon ] = getActionDetails(task.action);
return (
<div className={'flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}>
<SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/>
@ -56,14 +72,19 @@ export default ({ schedule, task }: Props) => {
onDismissed={() => setVisible(false)}
onConfirmed={() => onConfirmDeletion()}
/>
<FontAwesomeIcon icon={task.action === 'command' ? faCode : faToggleOn} className={'text-lg text-white'}/>
<FontAwesomeIcon icon={icon} className={'text-lg text-white'}/>
<div className={'flex-1'}>
<p className={'ml-6 text-neutral-300 mb-2 uppercase text-xs'}>
{task.action === 'command' ? 'Send command' : 'Send power action'}
<p className={'ml-6 text-neutral-300 uppercase text-xs'}>
{title}
</p>
<code className={'ml-6 font-mono bg-neutral-800 rounded py-1 px-2 text-sm'}>
{task.payload}
</code>
{task.payload &&
<div className={'ml-6 mt-2'}>
{task.action === 'backup' && <p className={'text-xs uppercase text-neutral-400 mb-1'}>Ignoring files & folders:</p>}
<div className={'font-mono bg-neutral-800 rounded py-1 px-2 text-sm w-auto whitespace-pre inline-block'}>
{task.payload}
</div>
</div>
}
</div>
{task.sequenceId > 1 &&
<div className={'mr-6'}>

View file

@ -71,7 +71,10 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
:
<div>
<label className={'input-dark-label'}>Ignored Files</label>
<FormikFieldWrapper name={'payload'}>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used.'}
>
<FormikField as={'textarea'} name={'payload'} className={'input-dark h-32'}/>
</FormikFieldWrapper>
</div>