diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index 2587458ef..4f64aec48 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -44,7 +44,7 @@ export default () => { - + diff --git a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx index 393e46d95..60d524add 100644 --- a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx +++ b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx @@ -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 (
- {visible && - (isEnabled ? ( - setVisible(false)} /> - ) : ( - setVisible(false)} /> - ))} + setVisible(null)} /> + setVisible(null)} />

{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.'}

- + {isEnabled ? ( + setVisible('disable')}>Disable Two-Step + ) : ( + + )}
); diff --git a/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx b/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx new file mode 100644 index 000000000..8e4cc75e2 --- /dev/null +++ b/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx @@ -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 ( + + + +
+                    {grouped.map((value) => (
+                        
+                            {value[0]}
+                             
+                            {value[1]}
+                             
+                        
+                    ))}
+                
+
+ + These codes will not be shown again. + + + Done + +
+ ); +}; diff --git a/resources/scripts/components/dashboard/forms/SetupTOTPModal.tsx b/resources/scripts/components/dashboard/forms/SetupTOTPModal.tsx new file mode 100644 index 000000000..d9e3da072 --- /dev/null +++ b/resources/scripts/components/dashboard/forms/SetupTOTPModal.tsx @@ -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([]); + const [token, setToken] = useState(null); + const { clearAndAddHttpError } = useFlashKey('account:two-step'); + const updateUserData = useStoreActions((actions: Actions) => 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 ( + <> + 0} onClose={onClose} /> + + +
+ {!token ? ( + + ) : ( + + )} +
+ +

+ {token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'} +

+
+
+

+ 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. +

+
+ setValue(e.currentTarget.value)} + className={'mt-4'} + placeholder={'000000'} + type={'text'} + inputMode={'numeric'} + autoComplete={'one-time-code'} + pattern={'\\d{6}'} + /> + + Cancel + + + + +
+ + ); +}; diff --git a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx deleted file mode 100644 index 95c505d0b..000000000 --- a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx +++ /dev/null @@ -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(null); - const [recoveryTokens, setRecoveryTokens] = useState([]); - - const { dismiss, setPropOverrides } = useContext(ModalContext); - const updateUserData = useStoreActions((actions: Actions) => actions.user.updateUserData); - const { clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - - useEffect(() => { - getTwoFactorTokenData() - .then(setToken) - .catch((error) => { - console.error(error); - clearAndAddHttpError({ error, key: 'account:two-factor' }); - }); - }, []); - - const submit = ({ code }: Values, { setSubmitting }: FormikHelpers) => { - 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 ( - - {recoveryTokens.length > 0 ? ( - <> -

Two-factor authentication enabled

-

- 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. -

-

- These codes will not be displayed again. Please take note of them now by - storing them in a secure repository such as a password manager. -

-
-                        {recoveryTokens.map((token) => (
-                            
-                                {token}
-                            
-                        ))}
-                    
-
- -
- - ) : ( -
- -
-
-
- {!token ? ( - - )} -
-
-
-
- - {token && ( -
- Alternatively, enter the following token into your authenticator application: - -
- {token.secret} -
-
-
- )} -
-
- -
-
-
- - )} -
- ); -}; - -export default asModal()(SetupTwoFactorModal); diff --git a/resources/scripts/components/elements/CopyOnClick.tsx b/resources/scripts/components/elements/CopyOnClick.tsx index 84793f308..431e9c8d6 100644 --- a/resources/scripts/components/elements/CopyOnClick.tsx +++ b/resources/scripts/components/elements/CopyOnClick.tsx @@ -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
-

Copied "{text}" to clipboard.

+

+ {showInNotification + ? `Copied "${String(text)}" to clipboard.` + : 'Copied text to clipboard.'} +

diff --git a/resources/scripts/components/elements/alert/Alert.tsx b/resources/scripts/components/elements/alert/Alert.tsx index 9d58b096a..e99d6d57e 100644 --- a/resources/scripts/components/elements/alert/Alert.tsx +++ b/resources/scripts/components/elements/alert/Alert.tsx @@ -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 (
- + {type === 'danger' ? ( + + ) : ( + + )} {children}
);