Use new two-step configuration modal
This commit is contained in:
parent
870a964050
commit
822949408f
7 changed files with 219 additions and 182 deletions
|
@ -44,7 +44,7 @@ export default () => {
|
|||
<ContentBox css={tw`mt-8 sm:mt-0 sm:ml-8`} title={'Update Email Address'} showFlashes={'account:email'}>
|
||||
<UpdateEmailAddressForm />
|
||||
</ContentBox>
|
||||
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Configure Two Factor'}>
|
||||
<ContentBox css={tw`md:ml-8 mt-8 md:mt-0`} title={'Two-Step Verification'}>
|
||||
<ConfigureTwoFactorForm />
|
||||
</ContentBox>
|
||||
</Container>
|
||||
|
|
|
@ -1,32 +1,30 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import SetupTwoFactorModal from '@/components/dashboard/forms/SetupTwoFactorModal';
|
||||
import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import SetupTOTPModal from '@/components/dashboard/forms/SetupTOTPModal';
|
||||
import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal';
|
||||
|
||||
export default () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [visible, setVisible] = useState<'enable' | 'disable' | null>(null);
|
||||
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{visible &&
|
||||
(isEnabled ? (
|
||||
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
) : (
|
||||
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)} />
|
||||
))}
|
||||
<SetupTOTPModal open={visible === 'enable'} onClose={() => setVisible(null)} />
|
||||
<DisableTwoFactorModal visible={visible === 'disable'} onModalDismissed={() => setVisible(null)} />
|
||||
<p css={tw`text-sm`}>
|
||||
{isEnabled
|
||||
? 'Two-factor authentication is currently enabled on your account.'
|
||||
: 'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'}
|
||||
? 'Two-step verification is currently enabled on your account.'
|
||||
: 'You do not currently have two-step verification enabled on your account. Click the button below to begin configuring it.'}
|
||||
</p>
|
||||
<div css={tw`mt-6`}>
|
||||
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
|
||||
{isEnabled ? 'Disable' : 'Enable'}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button.Danger onClick={() => setVisible('disable')}>Disable Two-Step</Button.Danger>
|
||||
) : (
|
||||
<Button onClick={() => setVisible('enable')}>Enable Two-Step</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import React from 'react';
|
||||
import { DialogProps } from '@/components/elements/dialog/Dialog';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import { Alert } from '@/components/elements/alert';
|
||||
|
||||
interface RecoveryTokenDialogProps extends DialogProps {
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => {
|
||||
const grouped = [] as [string, string][];
|
||||
tokens.forEach((token, index) => {
|
||||
if (index % 2 === 0) {
|
||||
grouped.push([token, tokens[index + 1] || '']);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={'Two-Step Authentication Enabled'}
|
||||
description={
|
||||
'Store the codes below somewhere safe. If you lose access to your phone you can use these backup codes to sign in.'
|
||||
}
|
||||
hideCloseIcon
|
||||
preventExternalClose
|
||||
>
|
||||
<Dialog.Icon position={'container'} type={'success'} />
|
||||
<CopyOnClick text={tokens.join('\n')} showInNotification={false}>
|
||||
<pre className={'bg-gray-800 rounded p-2 mt-6'}>
|
||||
{grouped.map((value) => (
|
||||
<span key={value.join('_')} className={'block'}>
|
||||
{value[0]}
|
||||
<span className={'mx-2 selection:bg-gray-800'}> </span>
|
||||
{value[1]}
|
||||
<span className={'selection:bg-gray-800'}> </span>
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
</CopyOnClick>
|
||||
<Alert type={'danger'} className={'mt-3'}>
|
||||
These codes will not be shown again.
|
||||
</Alert>
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={onClose}>Done</Button.Text>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
128
resources/scripts/components/dashboard/forms/SetupTOTPModal.tsx
Normal file
128
resources/scripts/components/dashboard/forms/SetupTOTPModal.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Dialog } from '@/components/elements/dialog';
|
||||
import { DialogProps } from '@/components/elements/dialog/Dialog';
|
||||
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
|
||||
import { useFlashKey } from '@/plugins/useFlash';
|
||||
import tw from 'twin.macro';
|
||||
import QRCode from 'qrcode.react';
|
||||
import { Button } from '@/components/elements/button/index';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { Input } from '@/components/elements/inputs';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
import Tooltip from '@/components/elements/tooltip/Tooltip';
|
||||
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import RecoveryTokensDialog from '@/components/dashboard/forms/RecoveryTokensDialog';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
||||
type SetupTOTPModalProps = DialogProps;
|
||||
|
||||
export default ({ open, onClose }: SetupTOTPModalProps) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [tokens, setTokens] = useState<string[]>([]);
|
||||
const [token, setToken] = useState<TwoFactorTokenData | null>(null);
|
||||
const { clearAndAddHttpError } = useFlashKey('account:two-step');
|
||||
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
getTwoFactorTokenData()
|
||||
.then(setToken)
|
||||
.then(() => updateUserData({ useTotp: true }))
|
||||
.catch((error) => clearAndAddHttpError(error));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
return () => {
|
||||
setToken(null);
|
||||
setValue('');
|
||||
setSubmitting(false);
|
||||
clearAndAddHttpError(undefined);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
clearAndAddHttpError();
|
||||
|
||||
enableAccountTwoFactor(value)
|
||||
.then(setTokens)
|
||||
.catch(clearAndAddHttpError)
|
||||
.then(() => setSubmitting(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<RecoveryTokensDialog tokens={tokens} open={open && tokens.length > 0} onClose={onClose} />
|
||||
<Dialog
|
||||
open={open && !tokens.length}
|
||||
onClose={onClose}
|
||||
title={'Enable Two-Step Verification'}
|
||||
preventExternalClose={submitting}
|
||||
description={
|
||||
"Help protect your account from unauthorized access. You'll be prompted for a verification code each time you sign in."
|
||||
}
|
||||
>
|
||||
<FlashMessageRender byKey={'account:two-step'} className={'mt-4'} />
|
||||
<div
|
||||
className={
|
||||
'flex items-center justify-center w-56 h-56 p-2 bg-gray-800 rounded-lg shadow mx-auto mt-6'
|
||||
}
|
||||
>
|
||||
{!token ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<QRCode
|
||||
renderAs={'svg'}
|
||||
value={token.image_url_data}
|
||||
css={tw`w-full h-full shadow-none rounded`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CopyOnClick text={token?.secret}>
|
||||
<p className={'font-mono text-sm text-gray-100 text-center mt-2'}>
|
||||
{token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'}
|
||||
</p>
|
||||
</CopyOnClick>
|
||||
<div className={'mt-6'}>
|
||||
<p>
|
||||
Scan the QR code above using the two-step authentication app of your choice. Then, enter the
|
||||
6-digit code generated into the field below.
|
||||
</p>
|
||||
</div>
|
||||
<Input.Text
|
||||
variant={Input.Text.Variants.Loose}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
className={'mt-4'}
|
||||
placeholder={'000000'}
|
||||
type={'text'}
|
||||
inputMode={'numeric'}
|
||||
autoComplete={'one-time-code'}
|
||||
pattern={'\\d{6}'}
|
||||
/>
|
||||
<Dialog.Footer>
|
||||
<Button.Text onClick={onClose}>Cancel</Button.Text>
|
||||
<Tooltip
|
||||
disabled={value.length === 6}
|
||||
content={
|
||||
!token ? 'Waiting for QR code to load...' : 'You must enter the 6-digit code to continue.'
|
||||
}
|
||||
delay={100}
|
||||
>
|
||||
<Button disabled={!token || value.length !== 6} onClick={submit}>
|
||||
Enable
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,159 +0,0 @@
|
|||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
|
||||
import enableAccountTwoFactor from '@/api/account/enableAccountTwoFactor';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Field from '@/components/elements/Field';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
import QRCode from 'qrcode.react';
|
||||
import CopyOnClick from '@/components/elements/CopyOnClick';
|
||||
|
||||
interface Values {
|
||||
code: string;
|
||||
}
|
||||
|
||||
const SetupTwoFactorModal = () => {
|
||||
const [token, setToken] = useState<TwoFactorTokenData | null>(null);
|
||||
const [recoveryTokens, setRecoveryTokens] = useState<string[]>([]);
|
||||
|
||||
const { dismiss, setPropOverrides } = useContext(ModalContext);
|
||||
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
|
||||
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
||||
useEffect(() => {
|
||||
getTwoFactorTokenData()
|
||||
.then(setToken)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
setPropOverrides((state) => ({ ...state, showSpinnerOverlay: true }));
|
||||
enableAccountTwoFactor(code)
|
||||
.then((tokens) => {
|
||||
setRecoveryTokens(tokens);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
clearAndAddHttpError({ error, key: 'account:two-factor' });
|
||||
})
|
||||
.then(() => {
|
||||
setSubmitting(false);
|
||||
setPropOverrides((state) => ({ ...state, showSpinnerOverlay: false }));
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPropOverrides((state) => ({
|
||||
...state,
|
||||
closeOnEscape: !recoveryTokens.length,
|
||||
closeOnBackground: !recoveryTokens.length,
|
||||
}));
|
||||
|
||||
return () => {
|
||||
if (recoveryTokens.length > 0) {
|
||||
updateUserData({ useTotp: true });
|
||||
}
|
||||
};
|
||||
}, [recoveryTokens]);
|
||||
|
||||
return (
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ code: '' }}
|
||||
validationSchema={object().shape({
|
||||
code: string()
|
||||
.required('You must provide an authentication code to continue.')
|
||||
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
|
||||
})}
|
||||
>
|
||||
{recoveryTokens.length > 0 ? (
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
|
||||
<p css={tw`text-neutral-300`}>
|
||||
Two-factor authentication has been enabled on your account. Should you lose access to your
|
||||
authenticator device, you'll need to use one of the codes displayed below in order to
|
||||
access your account.
|
||||
</p>
|
||||
<p css={tw`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 css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
|
||||
{recoveryTokens.map((token) => (
|
||||
<code key={token} css={tw`block mb-1`}>
|
||||
{token}
|
||||
</code>
|
||||
))}
|
||||
</pre>
|
||||
<div css={tw`text-right`}>
|
||||
<Button css={tw`mt-6`} onClick={dismiss}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Form css={tw`mb-0`}>
|
||||
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'} />
|
||||
<div css={tw`flex flex-wrap`}>
|
||||
<div css={tw`w-full md:flex-1`}>
|
||||
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
|
||||
{!token ? (
|
||||
<img
|
||||
src={
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
}
|
||||
css={tw`w-64 h-64 rounded`}
|
||||
/>
|
||||
) : (
|
||||
<QRCode
|
||||
renderAs={'svg'}
|
||||
value={token.image_url_data}
|
||||
css={tw`w-full h-full shadow-none rounded-none`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
|
||||
<div css={tw`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.'
|
||||
}
|
||||
/>
|
||||
{token && (
|
||||
<div css={tw`mt-4 pt-4 border-t border-neutral-500 text-neutral-200`}>
|
||||
Alternatively, enter the following token into your authenticator application:
|
||||
<CopyOnClick text={token.secret}>
|
||||
<div css={tw`text-sm bg-neutral-900 rounded mt-2 py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>{token.secret}</code>
|
||||
</div>
|
||||
</CopyOnClick>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div css={tw`mt-6 md:mt-0 text-right`}>
|
||||
<Button>Setup</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default asModal()(SetupTwoFactorModal);
|
|
@ -4,7 +4,13 @@ import Portal from '@/components/elements/Portal';
|
|||
import copy from 'copy-to-clipboard';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const CopyOnClick: React.FC<{ text: string | number | null | undefined }> = ({ text, children }) => {
|
||||
interface CopyOnClickProps {
|
||||
text: string | number | null | undefined;
|
||||
showInNotification?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -43,7 +49,11 @@ const CopyOnClick: React.FC<{ text: string | number | null | undefined }> = ({ t
|
|||
<Fade in appear timeout={250} key={copied ? 'visible' : 'invisible'}>
|
||||
<div className={'fixed z-50 bottom-0 right-0 m-4'}>
|
||||
<div className={'rounded-md py-3 px-4 text-gray-200 bg-neutral-600/95 shadow'}>
|
||||
<p>Copied "{text}" to clipboard.</p>
|
||||
<p>
|
||||
{showInNotification
|
||||
? `Copied "${String(text)}" to clipboard.`
|
||||
: 'Copied text to clipboard.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Fade>
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
import { ExclamationIcon } from '@heroicons/react/outline';
|
||||
import { ExclamationIcon, ShieldExclamationIcon } from '@heroicons/react/outline';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface AlertProps {
|
||||
type: 'warning';
|
||||
type: 'warning' | 'danger';
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default ({ className, children }: AlertProps) => {
|
||||
export default ({ type, className, children }: AlertProps) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center border-l-8 border-yellow-500 text-gray-50 bg-yellow-500/25 rounded-md shadow px-4 py-3',
|
||||
'flex items-center border-l-8 text-gray-50 rounded-md shadow px-4 py-3',
|
||||
{
|
||||
['border-red-500 bg-red-500/25']: type === 'danger',
|
||||
['border-yellow-500 bg-yellow-500/25']: type === 'warning',
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ExclamationIcon className={'w-6 h-6 text-yellow-500 mr-2'} />
|
||||
{type === 'danger' ? (
|
||||
<ShieldExclamationIcon className={'w-6 h-6 text-red-400 mr-2'} />
|
||||
) : (
|
||||
<ExclamationIcon className={'w-6 h-6 text-yellow-500 mr-2'} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue