From 6b16b9bc2ae1471f3e4b363acf90e44250389ec5 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 16 May 2021 12:35:49 -0700 Subject: [PATCH] Cleanup logic for `asModal` to make it a little easier to use dynamically --- package.json | 1 + .../forms/ConfigureTwoFactorForm.tsx | 56 ++---- .../dashboard/forms/DisableTwoFactorModal.tsx | 53 +++--- .../dashboard/forms/SetupTwoFactorModal.tsx | 164 +++++++++--------- .../scripts/components/elements/Spinner.tsx | 24 ++- .../components/elements/SuspenseSpinner.tsx | 10 -- .../components/server/ServerConsole.tsx | 6 +- .../server/users/EditSubuserModal.tsx | 6 +- resources/scripts/context/ModalContext.ts | 5 +- resources/scripts/hoc/asModal.tsx | 67 ++++--- resources/scripts/routers/ServerRouter.tsx | 5 +- yarn.lock | 16 +- 12 files changed, 210 insertions(+), 203 deletions(-) delete mode 100644 resources/scripts/components/elements/SuspenseSpinner.tsx diff --git a/package.json b/package.json index 462b95054..2e3d7a14f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "i18next-chained-backend": "^2.0.0", "i18next-localstorage-backend": "^3.0.0", "i18next-xhr-backend": "^3.2.2", + "qrcode.react": "^1.0.1", "query-string": "^6.7.0", "react": "^16.13.1", "react-copy-to-clipboard": "^5.0.2", diff --git a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx index 0b0db4d77..9246707f0 100644 --- a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx +++ b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx @@ -7,53 +7,29 @@ import tw from 'twin.macro'; import Button from '@/components/elements/Button'; export default () => { - const user = useStoreState((state: ApplicationStore) => state.user.data!); const [ visible, setVisible ] = useState(false); + const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp); - return user.useTotp ? + return (
- {visible && - setVisible(false)} - /> - } + {visible && ( + isEnabled ? + setVisible(false)}/> + : + setVisible(false)}/> + )}

- Two-factor authentication is currently enabled on your account. + {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.' + }

-
- : -
- {visible && - setVisible(false)} - /> - } -

- You do not currently have two-factor authentication enabled on your account. Click - the button below to begin configuring it. -

-
- -
-
- ; + ); }; diff --git a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx index 8500711fc..17af9b7f6 100644 --- a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx @@ -1,6 +1,5 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { Form, Formik, FormikHelpers } from 'formik'; -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import FlashMessageRender from '@/components/FlashMessageRender'; import Field from '@/components/elements/Field'; import { object, string } from 'yup'; @@ -9,26 +8,31 @@ import { ApplicationStore } from '@/state'; import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import asModal from '@/hoc/asModal'; +import ModalContext from '@/context/ModalContext'; interface Values { password: string; } -export default ({ ...props }: RequiredModalProps) => { +const DisableTwoFactorModal = () => { + const { dismiss, setPropOverrides } = useContext(ModalContext); const { clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); const updateUserData = useStoreActions((actions: Actions) => actions.user.updateUserData); const submit = ({ password }: Values, { setSubmitting }: FormikHelpers) => { + setPropOverrides({ showSpinnerOverlay: true, dismissable: false }); disableAccountTwoFactor(password) .then(() => { updateUserData({ useTotp: false }); - props.onDismissed(); + dismiss(); }) .catch(error => { console.error(error); clearAndAddHttpError({ error, key: 'account:two-factor' }); setSubmitting(false); + setPropOverrides(null); }); }; @@ -42,29 +46,26 @@ export default ({ ...props }: RequiredModalProps) => { password: string().required('You must provider your current password in order to continue.'), })} > - {({ isSubmitting, isValid }) => ( - -
- - -
- -
- -
+ {({ isValid }) => ( +
+ + +
+ +
+ )} ); }; + +export default asModal()(DisableTwoFactorModal); diff --git a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx index c1a1db634..c5f37e0b2 100644 --- a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import React, { useContext, useEffect, useState } from 'react'; import { Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl'; @@ -10,16 +9,19 @@ 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'; interface Values { code: string; } -export default ({ onDismissed, ...props }: RequiredModalProps) => { +const SetupTwoFactorModal = () => { const [ token, setToken ] = useState(''); const [ loading, setLoading ] = useState(true); const [ recoveryTokens, setRecoveryTokens ] = useState([]); + const { dismiss, setPropOverrides } = useContext(ModalContext); const updateUserData = useStoreActions((actions: Actions) => actions.user.updateUserData); const { clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); @@ -33,6 +35,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => { }, []); const submit = ({ code }: Values, { setSubmitting }: FormikHelpers) => { + setPropOverrides(state => ({ ...state, showSpinnerOverlay: true })); enableAccountTwoFactor(code) .then(tokens => { setRecoveryTokens(tokens); @@ -42,16 +45,25 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => { clearAndAddHttpError({ error, key: 'account:two-factor' }); }) - .then(() => setSubmitting(false)); + .then(() => { + setSubmitting(false); + setPropOverrides(state => ({ ...state, showSpinnerOverlay: false })); + }); }; - const dismiss = () => { - if (recoveryTokens.length > 0) { - updateUserData({ useTotp: true }); - } + useEffect(() => { + setPropOverrides(state => ({ + ...state, + closeOnEscape: !recoveryTokens.length, + closeOnBackground: !recoveryTokens.length, + })); - onDismissed(); - }; + return () => { + if (recoveryTokens.length > 0) { + updateUserData({ useTotp: true }); + } + }; + }, [ recoveryTokens ]); return ( { .matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'), })} > - {({ isSubmitting }) => ( - - {recoveryTokens.length > 0 ? - <> -

Two-factor authentication enabled

-

- Two-factor authentication has been enabled on your account. Should you loose access to - this 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.length ? + + : + setLoading(false)} + css={tw`w-full h-full shadow-none rounded-none`} + /> + } +
+
+
+
+ +
+
+
- - : - - -
-
-
- {!token || !token.length ? - - : - setLoading(false)} - css={tw`w-full h-full shadow-none rounded-none`} - /> - } -
-
-
-
- -
-
- -
-
-
- - } - - )} +
+
+ + }
); }; + +export default asModal()(SetupTwoFactorModal); diff --git a/resources/scripts/components/elements/Spinner.tsx b/resources/scripts/components/elements/Spinner.tsx index d2f7ffb24..890d94ea0 100644 --- a/resources/scripts/components/elements/Spinner.tsx +++ b/resources/scripts/components/elements/Spinner.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import styled, { css, keyframes } from 'styled-components/macro'; import tw from 'twin.macro'; @@ -10,6 +10,11 @@ interface Props { isBlue?: boolean; } +interface Spinner extends React.FC { + Size: Record<'SMALL' | 'BASE' | 'LARGE', SpinnerSize>; + Suspense: React.FC; +} + const spin = keyframes` to { transform: rotate(360deg); } `; @@ -30,7 +35,7 @@ const SpinnerComponent = styled.div` border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'}; `; -const Spinner = ({ centered, ...props }: Props) => ( +const Spinner: Spinner = ({ centered, ...props }) => ( centered ?
( : ); -Spinner.DisplayName = 'Spinner'; +Spinner.displayName = 'Spinner'; Spinner.Size = { - SMALL: 'small' as SpinnerSize, - BASE: 'base' as SpinnerSize, - LARGE: 'large' as SpinnerSize, + SMALL: 'small', + BASE: 'base', + LARGE: 'large', }; +Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => ( + }> + {children} + +); +Spinner.Suspense.displayName = 'Spinner.Suspense'; + export default Spinner; diff --git a/resources/scripts/components/elements/SuspenseSpinner.tsx b/resources/scripts/components/elements/SuspenseSpinner.tsx deleted file mode 100644 index 3e7098cad..000000000 --- a/resources/scripts/components/elements/SuspenseSpinner.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React, { Suspense } from 'react'; -import Spinner from '@/components/elements/Spinner'; - -const SuspenseSpinner: React.FC = ({ children }) => ( - }> - {children} - -); - -export default SuspenseSpinner; diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index 63c2aa268..48385d7c0 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -1,6 +1,5 @@ import React, { lazy, memo } from 'react'; import { ServerContext } from '@/state/server'; -import SuspenseSpinner from '@/components/elements/SuspenseSpinner'; import Can from '@/components/elements/Can'; import ContentContainer from '@/components/elements/ContentContainer'; import tw from 'twin.macro'; @@ -10,6 +9,7 @@ import isEqual from 'react-fast-compare'; import PowerControls from '@/components/server/PowerControls'; import { EulaModalFeature } from '@feature/index'; import ErrorBoundary from '@/components/elements/ErrorBoundary'; +import Spinner from '@/components/elements/Spinner'; export type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; @@ -51,12 +51,12 @@ const ServerConsole = () => { }
- + - + {eggFeatures.includes('eula') && diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index 77a84bc08..d7caf3a8f 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -32,7 +32,7 @@ const EditSubuserModal = ({ subuser }: Props) => { const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser); const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - const { dismiss, toggleSpinner } = useContext(ModalContext); + const { dismiss, setPropOverrides } = useContext(ModalContext); const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin); const permissions = useStoreState(state => state.permissions.data); @@ -56,7 +56,7 @@ const EditSubuserModal = ({ subuser }: Props) => { }, [ isRootAdmin, permissions, loggedInPermissions ]); const submit = (values: Values) => { - toggleSpinner(true); + setPropOverrides({ showSpinnerOverlay: true }); clearFlashes('user:edit'); createOrUpdateSubuser(uuid, values, subuser) @@ -66,7 +66,7 @@ const EditSubuserModal = ({ subuser }: Props) => { }) .catch(error => { console.error(error); - toggleSpinner(false); + setPropOverrides(null); clearAndAddHttpError({ key: 'user:edit', error }); if (ref.current) { diff --git a/resources/scripts/context/ModalContext.ts b/resources/scripts/context/ModalContext.ts index d2a5d3646..17b5efc2e 100644 --- a/resources/scripts/context/ModalContext.ts +++ b/resources/scripts/context/ModalContext.ts @@ -1,13 +1,14 @@ import React from 'react'; +import { SettableModalProps } from '@/hoc/asModal'; export interface ModalContextValues { dismiss: () => void; - toggleSpinner: (visible?: boolean) => void; + setPropOverrides: (value: ((current: Readonly>) => Partial) | Partial | null) => void; } const ModalContext = React.createContext({ dismiss: () => null, - toggleSpinner: () => null, + setPropOverrides: () => null, }); ModalContext.displayName = 'ModalContext'; diff --git a/resources/scripts/hoc/asModal.tsx b/resources/scripts/hoc/asModal.tsx index 452aa7eb1..397c9a3e3 100644 --- a/resources/scripts/hoc/asModal.tsx +++ b/resources/scripts/hoc/asModal.tsx @@ -1,24 +1,25 @@ import React from 'react'; import PortaledModal, { ModalProps } from '@/components/elements/Modal'; -import ModalContext from '@/context/ModalContext'; +import ModalContext, { ModalContextValues } from '@/context/ModalContext'; export interface AsModalProps { visible: boolean; onModalDismissed?: () => void; } -type ExtendedModalProps = Omit; +export type SettableModalProps = Omit; interface State { render: boolean; visible: boolean; showSpinnerOverlay?: boolean; + propOverrides: Partial; } type ExtendedComponentType = (C: React.ComponentType) => React.ComponentType; // eslint-disable-next-line @typescript-eslint/ban-types -function asModal

(modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType

{ +function asModal

(modalProps?: SettableModalProps | ((props: P) => SettableModalProps)): ExtendedComponentType

{ return function (Component) { return class extends React.PureComponent

{ static displayName = `asModal(${Component.displayName})`; @@ -30,54 +31,64 @@ function asModal

(modalProps?: ExtendedModalProps | ((props: P render: props.visible, visible: props.visible, showSpinnerOverlay: undefined, + propOverrides: {}, }; } - get modalProps () { + get computedModalProps (): Readonly { return { ...(typeof modalProps === 'function' ? modalProps(this.props) : modalProps), showSpinnerOverlay: this.state.showSpinnerOverlay, + ...this.state.propOverrides, + visible: this.state.visible, }; } + /** + * @this {React.PureComponent

} + */ componentDidUpdate (prevProps: Readonly

) { if (prevProps.visible && !this.props.visible) { - // noinspection JSPotentiallyInvalidUsageOfThis this.setState({ visible: false, showSpinnerOverlay: false }); } else if (!prevProps.visible && this.props.visible) { - // noinspection JSPotentiallyInvalidUsageOfThis this.setState({ render: true, visible: true }); } + if (!this.state.render) { + this.setState({ propOverrides: {} }); + } } dismiss = () => this.setState({ visible: false }); - toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value }); + setPropOverrides: ModalContextValues['setPropOverrides'] = value => this.setState(state => ({ + propOverrides: !value ? {} : (typeof value === 'function' ? value(state.propOverrides) : value), + })); + /** + * @this {React.PureComponent

} + */ render () { + if (!this.state.render) return null; + return ( - this.state.render ? - this.setState({ render: false }, () => { - if (typeof this.props.onModalDismissed === 'function') { - this.props.onModalDismissed(); - } - })} - {...this.modalProps} + this.setState({ render: false }, () => { + if (typeof this.props.onModalDismissed === 'function') { + this.props.onModalDismissed(); + } + })} + {...this.computedModalProps} + > + - - - - - : - null + + + ); } }; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index a1fff7939..434b6192a 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -9,7 +9,6 @@ import { ServerContext } from '@/state/server'; import DatabasesContainer from '@/components/server/databases/DatabasesContainer'; import FileManagerContainer from '@/components/server/files/FileManagerContainer'; import { CSSTransition } from 'react-transition-group'; -import SuspenseSpinner from '@/components/elements/SuspenseSpinner'; import FileEditContainer from '@/components/server/files/FileEditContainer'; import SettingsContainer from '@/components/server/settings/SettingsContainer'; import ScheduleContainer from '@/components/server/schedules/ScheduleContainer'; @@ -151,9 +150,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) - + - + diff --git a/yarn.lock b/yarn.lock index 6e8bb1438..22ede6e37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5810,7 +5810,7 @@ promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" -prop-types@^15.5.0, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.0, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -5889,6 +5889,20 @@ purgecss@^3.1.3: postcss "^8.2.1" postcss-selector-parser "^6.0.2" +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8= + +qrcode.react@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-1.0.1.tgz#2834bb50e5e275ffe5af6906eff15391fe9e38a5" + integrity sha512-8d3Tackk8IRLXTo67Y+c1rpaiXjoz/Dd2HpcMdW//62/x8J1Nbho14Kh8x974t9prsLHN6XqVgcnRiBGFptQmg== + dependencies: + loose-envify "^1.4.0" + prop-types "^15.6.0" + qr.js "0.0.0" + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"