Merge branch 'develop' into fix/2071
This commit is contained in:
commit
e4d141fa6f
165 changed files with 5484 additions and 4129 deletions
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
{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'}
|
||||
/>
|
||||
{bytesToHuman(disk)}
|
||||
<span className={'text-neutral-500'}> / {megabytesToHuman(server.limits.disk)}</span>
|
||||
|
||||
<span className={'text-neutral-500'}> / {disklimit}</span>
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
{!server.isInstalling ?
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -110,7 +110,7 @@ export default () => {
|
|||
fetchContent={value => {
|
||||
fetchFileContent = value;
|
||||
}}
|
||||
onContentSaved={() => null}
|
||||
onContentSaved={() => save()}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex justify-end mt-4'}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
/home/container/<span className={'text-cyan-200'}>{join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, '')}</span>
|
||||
/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'}>
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue