diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 864d9cb..7917cfa 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -22,6 +22,7 @@ "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-dropzone": "^12.0.4", "react-icons": "^4.3.1", "react-router-dom": "^6.2.1", "typescript": "^4.5.4", @@ -1251,6 +1252,14 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-macros": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", @@ -1631,6 +1640,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-selector": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.4.0.tgz", + "integrity": "sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg==", + "dependencies": { + "tslib": "^2.0.3" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -1914,9 +1934,9 @@ } }, "node_modules/prop-types": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.0.tgz", - "integrity": "sha512-fDGekdaHh65eI3lMi5OnErU6a8Ighg2KjcjQxO7m8VHyWjcPyj5kiOgV1LQDOOOgVy3+5FgjXvdSSX7B8/5/4g==", + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -1959,6 +1979,22 @@ "react": "17.0.2" } }, + "node_modules/react-dropzone": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.0.4.tgz", + "integrity": "sha512-fcqHEYe1MzAghU6/Hz86lHDlBNsA+lO48nAcm7/wA+kIzwS6uuJbUG33tBZjksj7GAZ1iUQ6NHwjUURPmSGang==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.4.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8" + } + }, "node_modules/react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", @@ -3226,6 +3262,11 @@ } } }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, "babel-plugin-macros": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz", @@ -3478,6 +3519,14 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" }, + "file-selector": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.4.0.tgz", + "integrity": "sha512-iACCiXeMYOvZqlF1kTiYINzgepRBymz1wwjiuup9u9nayhb6g4fSwiyJ/6adli+EPwrWtpgQAh2PoS7HukEGEg==", + "requires": { + "tslib": "^2.0.3" + } + }, "find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -3707,9 +3756,9 @@ } }, "prop-types": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.0.tgz", - "integrity": "sha512-fDGekdaHh65eI3lMi5OnErU6a8Ighg2KjcjQxO7m8VHyWjcPyj5kiOgV1LQDOOOgVy3+5FgjXvdSSX7B8/5/4g==", + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -3743,6 +3792,16 @@ "scheduler": "^0.20.2" } }, + "react-dropzone": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-12.0.4.tgz", + "integrity": "sha512-fcqHEYe1MzAghU6/Hz86lHDlBNsA+lO48nAcm7/wA+kIzwS6uuJbUG33tBZjksj7GAZ1iUQ6NHwjUURPmSGang==", + "requires": { + "attr-accept": "^2.2.2", + "file-selector": "^0.4.0", + "prop-types": "^15.8.1" + } + }, "react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index af68d43..9f854ba 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -24,6 +24,7 @@ "lodash": "^4.17.21", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-dropzone": "^12.0.4", "react-icons": "^4.3.1", "react-router-dom": "^6.2.1", "typescript": "^4.5.4", diff --git a/dashboard/src/components/InviteMembersModal.tsx b/dashboard/src/components/InviteMembersModal.tsx new file mode 100644 index 0000000..cf18f21 --- /dev/null +++ b/dashboard/src/components/InviteMembersModal.tsx @@ -0,0 +1,372 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Button, + Center, + Flex, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + useDisclosure, + useToast, + Tabs, + TabList, + Tab, + TabPanels, + TabPanel, + InputGroup, + Input, + InputRightElement, + Text, + Link, +} from '@chakra-ui/react'; +import { useClient } from 'urql'; +import { FaUserPlus, FaMinusCircle, FaPlus, FaUpload } from 'react-icons/fa'; +import { useDropzone } from 'react-dropzone'; +import { escape } from 'lodash'; +import { validateEmail, validateURI } from '../utils'; +import { InviteMembers } from '../graphql/mutation'; +import { ArrayInputOperations, csvDemoData } from '../constants'; +import parseCSV from '../utils/parseCSV'; + +interface stateDataTypes { + value: string; + isInvalid: boolean; +} + +interface requestParamTypes { + emails: string[]; + redirect_uri?: string; +} + +const initData: stateDataTypes = { + value: '', + isInvalid: false, +}; + +const InviteMembersModal = ({ + updateUserList, + disabled = true, +}: { + updateUserList: Function; + disabled: boolean; +}) => { + const client = useClient(); + const toast = useToast(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const [tabIndex, setTabIndex] = useState(0); + const [redirectURI, setRedirectURI] = useState({ + ...initData, + }); + const [emails, setEmails] = useState([{ ...initData }]); + const [disableSendButton, setDisableSendButton] = useState(false); + const [loading, setLoading] = React.useState(false); + useEffect(() => { + if (redirectURI.isInvalid) { + setDisableSendButton(true); + } else if (emails.some((emailData) => emailData.isInvalid)) { + setDisableSendButton(true); + } else { + setDisableSendButton(false); + } + }, [redirectURI, emails]); + useEffect(() => { + return () => { + setRedirectURI({ ...initData }); + setEmails([{ ...initData }]); + }; + }, []); + const sendInviteHandler = async () => { + setLoading(true); + try { + const emailList = emails + .filter((emailData) => !emailData.isInvalid) + .map((emailData) => emailData.value); + const params: requestParamTypes = { + emails: emailList, + }; + if (redirectURI.value !== '' && !redirectURI.isInvalid) { + params.redirect_uri = redirectURI.value; + } + if (emailList.length > 0) { + const res = await client + .mutation(InviteMembers, { + params, + }) + .toPromise(); + if (res.error) { + throw new Error('Internal server error'); + return; + } + toast({ + title: 'Invites sent successfully!', + isClosable: true, + status: 'success', + position: 'bottom-right', + }); + setLoading(false); + updateUserList(); + } else { + throw new Error('Please add emails'); + } + } catch (error: any) { + toast({ + title: error?.message || 'Error occurred, try again!', + isClosable: true, + status: 'error', + position: 'bottom-right', + }); + setLoading(false); + } + closeModalHandler(); + }; + const updateEmailListHandler = (operation: string, index: number = 0) => { + switch (operation) { + case ArrayInputOperations.APPEND: + setEmails([...emails, { ...initData }]); + break; + case ArrayInputOperations.REMOVE: + const updatedEmailList = [...emails]; + updatedEmailList.splice(index, 1); + setEmails(updatedEmailList); + break; + default: + break; + } + }; + const inputChangeHandler = (value: string, index: number) => { + const updatedEmailList = [...emails]; + updatedEmailList[index].value = value; + updatedEmailList[index].isInvalid = !validateEmail(value); + setEmails(updatedEmailList); + }; + const changeTabsHandler = (index: number) => { + setTabIndex(index); + }; + const onDrop = useCallback(async (acceptedFiles) => { + const result = await parseCSV(acceptedFiles[0], ','); + setEmails(result); + changeTabsHandler(0); + }, []); + const setRedirectURIHandler = (value: string) => { + const updatedRedirectURI: stateDataTypes = { + value: '', + isInvalid: false, + }; + updatedRedirectURI.value = value; + updatedRedirectURI.isInvalid = !validateURI(value); + setRedirectURI(updatedRedirectURI); + }; + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: 'text/csv', + }); + const closeModalHandler = () => { + setRedirectURI({ + value: '', + isInvalid: false, + }); + setEmails([ + { + value: '', + isInvalid: false, + }, + ]); + onClose(); + }; + return ( + <> + + + + + Invite Members + + + + + Enter emails + Upload CSV + + + + + + Redirect URI + + + + + setRedirectURIHandler(e.currentTarget.value) + } + /> + + + + Emails + + + + + + {emails.map((emailData, index) => ( + + + + inputChangeHandler(e.currentTarget.value, index) + } + /> + + + + + + ))} + + + + + + + {isDragActive ? ( + Drop the files here... + ) : ( + +
+ +
+ + Drag 'n' drop the csv file here, or click to select. + + + Download{' '} + e.stopPropagation()} + > + {' '} + sample.csv + {' '} + and modify it.{' '} + +
+ )} +
+
+
+
+
+ + + +
+
+ + ); +}; + +export default InviteMembersModal; diff --git a/dashboard/src/constants.ts b/dashboard/src/constants.ts index 64fa372..ead6fdd 100644 --- a/dashboard/src/constants.ts +++ b/dashboard/src/constants.ts @@ -88,3 +88,9 @@ export const ECDSAEncryptionType = { ES384: 'ES384', ES512: 'ES512', }; + +export const csvDemoData = `lakhan.demo@contentment.org +john@gmail.com +anik@contentment.org +harry@potter.com +anikgh89@gmail.com`; diff --git a/dashboard/src/graphql/mutation/index.ts b/dashboard/src/graphql/mutation/index.ts index 9736a01..df5db93 100644 --- a/dashboard/src/graphql/mutation/index.ts +++ b/dashboard/src/graphql/mutation/index.ts @@ -45,3 +45,11 @@ export const DeleteUser = ` } } `; + +export const InviteMembers = ` + mutation inviteMembers($params: InviteMemberInput!) { + _invite_members(params: $params) { + message + } + } +`; diff --git a/dashboard/src/graphql/queries/index.ts b/dashboard/src/graphql/queries/index.ts index 8528f3f..698452e 100644 --- a/dashboard/src/graphql/queries/index.ts +++ b/dashboard/src/graphql/queries/index.ts @@ -84,3 +84,11 @@ export const UserDetailsQuery = ` } } `; + +export const EmailVerificationQuery = ` + query { + _env{ + DISABLE_EMAIL_VERIFICATION + } + } +`; diff --git a/dashboard/src/pages/Users.tsx b/dashboard/src/pages/Users.tsx index 5d36c5b..a9d1d05 100644 --- a/dashboard/src/pages/Users.tsx +++ b/dashboard/src/pages/Users.tsx @@ -38,10 +38,11 @@ import { FaExclamationCircle, FaAngleDown, } from 'react-icons/fa'; -import { UserDetailsQuery } from '../graphql/queries'; +import { EmailVerificationQuery, UserDetailsQuery } from '../graphql/queries'; import { UpdateUser } from '../graphql/mutation'; import EditUserModal from '../components/EditUserModal'; import DeleteUserModal from '../components/DeleteUserModal'; +import InviteMembersModal from '../components/InviteMembersModal'; interface paginationPropTypes { limit: number; @@ -101,6 +102,8 @@ export default function Users() { }); const [userList, setUserList] = React.useState([]); const [loading, setLoading] = React.useState(false); + const [disableInviteMembers, setDisableInviteMembers] = + React.useState(true); const updateUserList = async () => { setLoading(true); const { data } = await client @@ -132,8 +135,18 @@ export default function Users() { } setLoading(false); }; + const checkEmailVerification = async () => { + setLoading(true); + const { data } = await client.query(EmailVerificationQuery).toPromise(); + if (data?._env) { + const { DISABLE_EMAIL_VERIFICATION } = data._env; + setDisableInviteMembers(DISABLE_EMAIL_VERIFICATION); + } + setLoading(false); + }; React.useEffect(() => { updateUserList(); + checkEmailVerification(); }, []); React.useEffect(() => { updateUserList(); @@ -177,6 +190,10 @@ export default function Users() { Users + {!loading ? ( userList.length > 0 ? ( diff --git a/dashboard/src/utils/index.ts b/dashboard/src/utils/index.ts index 64ca0bf..eccc7a8 100644 --- a/dashboard/src/utils/index.ts +++ b/dashboard/src/utils/index.ts @@ -64,3 +64,25 @@ export const getObjectDiff = (obj1: any, obj2: any) => { return diff; }; + +export const validateEmail = (email: string) => { + if (!email || email === '') return true; + return email + .toLowerCase() + .match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ) + ? true + : false; +}; + +export const validateURI = (uri: string) => { + if (!uri || uri === '') return true; + return uri + .toLowerCase() + .match( + /(?:^|\s)((https?:\/\/)?(?:localhost|[\w-]+(?:\.[\w-]+)+)(:\d+)?(\/\S*)?)/ + ) + ? true + : false; +}; diff --git a/dashboard/src/utils/parseCSV.ts b/dashboard/src/utils/parseCSV.ts new file mode 100644 index 0000000..bc73de2 --- /dev/null +++ b/dashboard/src/utils/parseCSV.ts @@ -0,0 +1,39 @@ +import _flatten from 'lodash/flatten'; +import { validateEmail } from '.'; + +interface dataTypes { + value: string; + isInvalid: boolean; +} + +const parseCSV = (file: File, delimiter: string): Promise => { + return new Promise((resolve) => { + const reader = new FileReader(); + + // When the FileReader has loaded the file... + reader.onload = (e: any) => { + // Split the result to an array of lines + const lines = e.target.result.split('\n'); + // Split the lines themselves by the specified + // delimiter, such as a comma + let result = lines.map((line: string) => line.split(delimiter)); + // As the FileReader reads asynchronously, + // we can't just return the result; instead, + // we're passing it to a callback function + result = _flatten(result); + resolve( + result.map((email: string) => { + return { + value: email.trim(), + isInvalid: !validateEmail(email.trim()), + }; + }) + ); + }; + + // Read the file content as a single string + reader.readAsText(file); + }); +}; + +export default parseCSV;