diff --git a/resources/scripts/components/server/users/AddSubuserButton.tsx b/resources/scripts/components/server/users/AddSubuserButton.tsx index 210578bb9..fa8b2f8e6 100644 --- a/resources/scripts/components/server/users/AddSubuserButton.tsx +++ b/resources/scripts/components/server/users/AddSubuserButton.tsx @@ -10,7 +10,7 @@ export default () => { return ( <> - {visible && setVisible(false)}/>} + setVisible(false)}/> diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index d81d15f08..9479b0ed8 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -1,14 +1,10 @@ -import React, { forwardRef, memo, useCallback, useEffect, useRef } from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import { Subuser } from '@/state/server/subusers'; -import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import { Form, Formik } from 'formik'; import { array, object, string } from 'yup'; -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import Field from '@/components/elements/Field'; import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; -import TitledGreyBox from '@/components/elements/TitledGreyBox'; -import Checkbox from '@/components/elements/Checkbox'; -import styled from 'styled-components/macro'; import createOrUpdateSubuser from '@/api/server/users/createOrUpdateSubuser'; import { ServerContext } from '@/state/server'; import FlashMessageRender from '@/components/FlashMessageRender'; @@ -17,104 +13,33 @@ import { usePermissions } from '@/plugins/usePermissions'; import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; -import Label from '@/components/elements/Label'; -import Input from '@/components/elements/Input'; -import isEqual from 'react-fast-compare'; +import PermissionTitleBox from '@/components/server/users/PermissionTitleBox'; +import asModal from '@/hoc/asModal'; +import PermissionRow from '@/components/server/users/PermissionRow'; +import ModalContext from '@/context/ModalContext'; type Props = { subuser?: Subuser; -} & RequiredModalProps; +}; interface Values { email: string; permissions: string[]; } -const PermissionLabel = styled.label` - ${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`}; - text-transform: none; +const EditSubuserModal = ({ subuser }: Props) => { + const ref = useRef(null); + 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); - &:not(.disabled) { - ${tw`cursor-pointer`}; - - &:hover { - ${tw`border-neutral-500 bg-neutral-800`}; - } - } - - &:not(:first-of-type) { - ${tw`mt-4 sm:mt-2`}; - } - - &.disabled { - ${tw`opacity-50`}; - - & input[type="checkbox"]:not(:checked) { - ${tw`border-0`}; - } - } -`; - -interface TitleProps { - isEditable: boolean; - permission: string; - permissions: string[]; - children: React.ReactNode; - className?: string; -} - -const PermissionTitledBox = memo(({ isEditable, permission, permissions, className, children }: TitleProps) => { - const { values, setFieldValue } = useFormikContext(); - - const onCheckboxClicked = useCallback((e: React.ChangeEvent) => { - console.log(e.currentTarget.checked, [ - ...values.permissions, - ...permissions.filter(p => !values.permissions.includes(p)), - ]); - - if (e.currentTarget.checked) { - setFieldValue('permissions', [ - ...values.permissions, - ...permissions.filter(p => !values.permissions.includes(p)), - ]); - } else { - setFieldValue('permissions', [ - ...values.permissions.filter(p => !permissions.includes(p)), - ]); - } - }, [ permissions, values.permissions ]); - - return ( - -

{permission}

- {isEditable && - values.permissions.includes(p))} - onChange={onCheckboxClicked} - /> - } - - } - className={className} - > - {children} -
- ); -}, isEqual); - -const EditSubuserModal = forwardRef(({ subuser, ...props }, ref) => { - const { isSubmitting } = useFormikContext(); - const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]); + const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin); const permissions = useStoreState(state => state.permissions.data); - - const user = useStoreState(state => state.user.data!); - // The currently logged in user's permissions. We're going to filter out any permissions // that they should not need. const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions); + const [ canEditUser ] = usePermissions(subuser ? [ 'user.update' ] : [ 'user.create' ]); // The permissions that can be modified by this user. const editablePermissions = useDeepCompareMemo(() => { @@ -123,111 +48,25 @@ const EditSubuserModal = forwardRef(({ subuser, ...pr const list: string[] = ([] as string[]).concat.apply([], Object.values(cleaned)); - if (user.rootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) { + if (isRootAdmin || (loggedInPermissions.length === 1 && loggedInPermissions[0] === '*')) { return list; } return list.filter(key => loggedInPermissions.indexOf(key) >= 0); - }, [ permissions, loggedInPermissions ]); + }, [ isRootAdmin, permissions, loggedInPermissions ]); - return ( - -

- {subuser ? - `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` - : - 'Create new subuser' - } -

- - {(!user.rootAdmin && loggedInPermissions[0] !== '*') && -
-

- Only permissions which your account is currently assigned may be selected when creating or - modifying other users. -

-
- } - {!subuser && -
- -
- } -
- {Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => { - const group = Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`); - - return ( - 0 ? tw`mt-4` : undefined} - > -

- {permissions[key].description} -

- {Object.keys(permissions[key].keys).map(pkey => ( - -
- -
-
- - {permissions[key].keys[pkey].length > 0 && -

- {permissions[key].keys[pkey]} -

- } -
-
- ))} -
- ); - })} -
- -
- -
-
-
- ); -}); - -export default ({ subuser, ...props }: Props) => { - const ref = useRef(null); - 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 submit = (values: Values, { setSubmitting }: FormikHelpers) => { + const submit = (values: Values) => { + toggleSpinner(true); clearFlashes('user:edit'); + createOrUpdateSubuser(uuid, values, subuser) .then(subuser => { appendSubuser(subuser); - props.onDismissed(); + dismiss(); }) .catch(error => { console.error(error); - setSubmitting(false); + toggleSpinner(false); clearAndAddHttpError({ key: 'user:edit', error }); if (ref.current) { @@ -236,10 +75,8 @@ export default ({ subuser, ...props }: Props) => { }); }; - useEffect(() => { - return () => { - clearFlashes('user:edit'); - }; + useEffect(() => () => { + clearFlashes('user:edit'); }, []); return ( @@ -258,8 +95,61 @@ export default ({ subuser, ...props }: Props) => { })} >
- +

+ {subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` : 'Create new subuser'} +

+ + {(!isRootAdmin && loggedInPermissions[0] !== '*') && +
+

+ Only permissions which your account is currently assigned may be selected when creating or + modifying other users. +

+
+ } + {!subuser && +
+ +
+ } +
+ {Object.keys(permissions).filter(key => key !== 'websocket').map((key, index) => ( + `${key}.${pkey}`)} + css={index > 0 ? tw`mt-4` : undefined} + > +

+ {permissions[key].description} +

+ {Object.keys(permissions[key].keys).map(pkey => ( + + ))} +
+ ))} +
+ +
+ +
+
); }; + +export default asModal({ + top: false, +})(EditSubuserModal); diff --git a/resources/scripts/components/server/users/PermissionRow.tsx b/resources/scripts/components/server/users/PermissionRow.tsx new file mode 100644 index 000000000..1f48eaaf0 --- /dev/null +++ b/resources/scripts/components/server/users/PermissionRow.tsx @@ -0,0 +1,65 @@ +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; +import Checkbox from '@/components/elements/Checkbox'; +import React from 'react'; +import { useStoreState } from 'easy-peasy'; +import Label from '@/components/elements/Label'; + +const Container = styled.label` + ${tw`flex items-center border border-transparent rounded md:p-2 transition-colors duration-75`}; + text-transform: none; + + &:not(.disabled) { + ${tw`cursor-pointer`}; + + &:hover { + ${tw`border-neutral-500 bg-neutral-800`}; + } + } + + &:not(:first-of-type) { + ${tw`mt-4 sm:mt-2`}; + } + + &.disabled { + ${tw`opacity-50`}; + + & input[type="checkbox"]:not(:checked) { + ${tw`border-0`}; + } + } +`; + +interface Props { + permission: string; + disabled: boolean; +} + +const PermissionRow = ({ permission, disabled }: Props) => { + const [ key, pkey ] = permission.split('.', 2); + const permissions = useStoreState(state => state.permissions.data); + + return ( + +
+ +
+
+ + {permissions[key].keys[pkey].length > 0 && +

+ {permissions[key].keys[pkey]} +

+ } +
+
+ ); +}; + +export default PermissionRow; diff --git a/resources/scripts/components/server/users/PermissionTitleBox.tsx b/resources/scripts/components/server/users/PermissionTitleBox.tsx new file mode 100644 index 000000000..a3e1453a4 --- /dev/null +++ b/resources/scripts/components/server/users/PermissionTitleBox.tsx @@ -0,0 +1,50 @@ +import React, { memo, useCallback } from 'react'; +import { useField } from 'formik'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import tw from 'twin.macro'; +import Input from '@/components/elements/Input'; +import isEqual from 'react-fast-compare'; + +interface Props { + isEditable: boolean; + title: string; + permissions: string[]; + className?: string; +} + +const PermissionTitleBox: React.FC = memo(({ isEditable, title, permissions, className, children }) => { + const [ { value }, , { setValue } ] = useField('permissions'); + + const onCheckboxClicked = useCallback((e: React.ChangeEvent) => { + if (e.currentTarget.checked) { + setValue([ + ...value, + ...permissions.filter(p => !value.includes(p)), + ]); + } else { + setValue(value.filter(p => !permissions.includes(p))); + } + }, [ permissions, value ]); + + return ( + +

{title}

+ {isEditable && + value.includes(p))} + onChange={onCheckboxClicked} + /> + } + + } + className={className} + > + {children} +
+ ); +}, isEqual); + +export default PermissionTitleBox; diff --git a/resources/scripts/components/server/users/UserRow.tsx b/resources/scripts/components/server/users/UserRow.tsx index 237621660..aa5e55a39 100644 --- a/resources/scripts/components/server/users/UserRow.tsx +++ b/resources/scripts/components/server/users/UserRow.tsx @@ -19,14 +19,11 @@ export default ({ subuser }: Props) => { return ( - {visible && setVisible(false)} + visible={visible} + onModalDismissed={() => setVisible(false)} /> - } diff --git a/resources/scripts/hoc/asModal.tsx b/resources/scripts/hoc/asModal.tsx index 7db437c14..81027f5d3 100644 --- a/resources/scripts/hoc/asModal.tsx +++ b/resources/scripts/hoc/asModal.tsx @@ -1,7 +1,6 @@ import React from 'react'; import Modal, { ModalProps } from '@/components/elements/Modal'; import ModalContext from '@/context/ModalContext'; -import isEqual from 'react-fast-compare'; export interface AsModalProps { visible: boolean; @@ -13,7 +12,7 @@ type ExtendedModalProps = Omit interface State { render: boolean; visible: boolean; - modalProps: ExtendedModalProps | undefined; + showSpinnerOverlay?: boolean; } type ExtendedComponentType = (C: React.ComponentType) => React.ComponentType; @@ -30,17 +29,18 @@ function asModal

(modalProps?: ExtendedModalProps | ((props: P this.state = { render: props.visible, visible: props.visible, - modalProps: typeof modalProps === 'function' ? modalProps(this.props) : modalProps, + showSpinnerOverlay: undefined, + }; + } + + get modalProps () { + return { + ...(typeof modalProps === 'function' ? modalProps(this.props) : modalProps), + showSpinnerOverlay: this.state.showSpinnerOverlay, }; } componentDidUpdate (prevProps: Readonly

) { - const mapped = typeof modalProps === 'function' ? modalProps(this.props) : modalProps; - if (!isEqual(this.state.modalProps, mapped)) { - // noinspection JSPotentiallyInvalidUsageOfThis - this.setState({ modalProps: mapped }); - } - if (prevProps.visible && !this.props.visible) { // noinspection JSPotentiallyInvalidUsageOfThis this.setState({ visible: false }); @@ -52,39 +52,32 @@ function asModal

(modalProps?: ExtendedModalProps | ((props: P dismiss = () => this.setState({ visible: false }); - toggleSpinner = (value?: boolean) => this.setState(s => ({ - modalProps: { - ...s.modalProps, - showSpinnerOverlay: value || false, - }, - })); + toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value }); render () { return ( - - { - this.state.render ? - this.setState({ render: false }, () => { - if (typeof this.props.onModalDismissed === 'function') { - this.props.onModalDismissed(); - } - })} - {...this.state.modalProps} - > - - - : - null - } - + this.state.render ? + this.setState({ render: false }, () => { + if (typeof this.props.onModalDismissed === 'function') { + this.props.onModalDismissed(); + } + })} + {...this.modalProps} + > + + + + + : + null ); } };