From 66f1c57a2a827b11bc8e054b1a6bd691f61dd4ba Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Fri, 17 Feb 2023 23:27:51 +0530 Subject: [PATCH] feat(ui): completed org settings page revamp --- .../basic/table/ProjectUsersTable.tsx | 234 ++++++++---- frontend/src/pages/settings/org/[id].tsx | 361 +----------------- .../OrgSettingsPage/OrgSettingsPage.tsx | 308 +++++++++++++++ .../OrgIncidentContactsTable.tsx | 171 +++++++++ .../OrgIncidentContactsTable/index.tsx | 1 + .../OrgMembersTable/OrgMembersTable.tsx | 274 +++++++++++++ .../components/OrgMembersTable/index.tsx | 1 + .../OrgNameChangeSection.tsx | 68 ++++ .../components/OrgNameChangeSection/index.tsx | 1 + .../OrgSettingsPage/components/index.tsx | 3 + .../views/Settings/OrgSettingsPage/index.tsx | 1 + 11 files changed, 990 insertions(+), 433 deletions(-) create mode 100644 frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/index.tsx diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 27346195c..438517aa5 100644 --- a/frontend/src/components/basic/table/ProjectUsersTable.tsx +++ b/frontend/src/components/basic/table/ProjectUsersTable.tsx @@ -30,7 +30,7 @@ type Props = { type EnvironmentProps = { name: string; slug: string; -} +}; /** * This is the component that shows the users of a certin project @@ -91,26 +91,38 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { }); }; - const handlePermissionUpdate = (index: number, val: string, membershipId: string, slug: string ) => { - let denials: { ability: string; environmentSlug: string; }[]; - if (val === "Read Only") { - denials = [{ - ability: "write", - environmentSlug: slug - }]; - } else if (val === "No Access") { - denials = [{ - ability: "write", - environmentSlug: slug - }, { - ability: "read", - environmentSlug: slug - }]; - } else if (val === "Add Only") { - denials = [{ - ability: "read", - environmentSlug: slug - }]; + const handlePermissionUpdate = ( + index: number, + val: string, + membershipId: string, + slug: string + ) => { + let denials: { ability: string; environmentSlug: string }[]; + if (val === 'Read Only') { + denials = [ + { + ability: 'write', + environmentSlug: slug + } + ]; + } else if (val === 'No Access') { + denials = [ + { + ability: 'write', + environmentSlug: slug + }, + { + ability: 'read', + environmentSlug: slug + } + ]; + } else if (val === 'Add Only') { + denials = [ + { + ability: 'read', + environmentSlug: slug + } + ]; } else { denials = []; } @@ -118,8 +130,12 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { if (currentPlan !== plans.professional && host === 'https://app.infisical.com') { setIsUpgradeModalOpen(true); } else { - const allDenials = userData[index].deniedPermissions.filter((perm: { ability: string; environmentSlug: string; }) => perm.environmentSlug !== slug).concat(denials); - updateUserProjectPermission({ membershipId, denials: allDenials}); + const allDenials = userData[index].deniedPermissions + .filter( + (perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug + ) + .concat(denials); + updateUserProjectPermission({ membershipId, denials: allDenials }); changeData([ ...userData.slice(0, index), ...[ @@ -156,7 +172,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { orgId }); if (subscriptions) { - setCurrentPlan(subscriptions.data[0].plan.product) + setCurrentPlan(subscriptions.data[0].plan.product); } })(); }, [userData, myUser]); @@ -186,25 +202,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { const closeUpgradeModal = () => { setIsUpgradeModalOpen(false); - } + }; return ( -
-
+
+
- - +
+ - - - - {workspaceEnvs.map(env => ( - + + + {workspaceEnvs.map((env) => ( + ))} @@ -227,28 +246,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => { ) .map((row, index) => ( - - - - {workspaceEnvs.map((env) => )} - + ))} + diff --git a/frontend/src/pages/settings/org/[id].tsx b/frontend/src/pages/settings/org/[id].tsx index 562c45c9b..59c8c373c 100644 --- a/frontend/src/pages/settings/org/[id].tsx +++ b/frontend/src/pages/settings/org/[id].tsx @@ -1,374 +1,21 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { useEffect, useState } from 'react'; import Head from 'next/head'; -import { useRouter } from 'next/router'; import { useTranslation } from 'next-i18next'; -import { faCheck, faMagnifyingGlass, faPlus, faX } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { plans } from 'public/data/frequentConstants'; -import Button from '@app/components/basic/buttons/Button'; -import AddIncidentContactDialog from '@app/components/basic/dialog/AddIncidentContactDialog'; -import AddUserDialog from '@app/components/basic/dialog/AddUserDialog'; -import UpgradePlanModal from '@app/components/basic/dialog/UpgradePlan'; -import InputField from '@app/components/basic/InputField'; -import UserTable from '@app/components/basic/table/UserTable'; -import NavHeader from '@app/components/navigation/NavHeader'; -import guidGenerator from '@app/components/utilities/randomId'; import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; - -import addUserToOrg from '../../api/organization/addUserToOrg'; -import deleteIncidentContact from '../../api/organization/deleteIncidentContact'; -import getIncidentContacts from '../../api/organization/getIncidentContacts'; -import getOrganization from '../../api/organization/GetOrg'; -import getOrganizationSubscriptions from '../../api/organization/GetOrgSubscription'; -import getOrganizationUsers from '../../api/organization/GetOrgUsers'; -import renameOrg from '../../api/organization/renameOrg'; -import getUser from '../../api/user/getUser'; -import deleteWorkspace from '../../api/workspace/deleteWorkspace'; -import getWorkspaces from '../../api/workspace/getWorkspaces'; +import { OrgSettingsPage } from '@app/views/Settings/OrgSettingsPage'; export default function SettingsOrg() { - const [buttonReady, setButtonReady] = useState(false); - const router = useRouter(); - const host = window.location.origin; - const [orgName, setOrgName] = useState(''); - const [emailUser, setEmailUser] = useState(''); - const [workspaceToBeDeletedName, setWorkspaceToBeDeletedName] = useState(''); - const [searchUsers, setSearchUsers] = useState(''); - const [isAddIncidentContactOpen, setIsAddIncidentContactOpen] = useState(false); - const [isAddUserOpen, setIsAddUserOpen] = useState(router.asPath.split('?')[1] === 'invite'); - const [incidentContacts, setIncidentContacts] = useState([]); - const [searchIncidentContact, setSearchIncidentContact] = useState(''); - const [userList, setUserList] = useState([]); - const [personalEmail, setPersonalEmail] = useState(''); - const [email, setEmail] = useState(''); - const [currentPlan, setCurrentPlan] = useState(''); - - const workspaceId = router.query.id as string; - const { t } = useTranslation(); - useEffect(() => { - (async () => { - const orgId = localStorage.getItem('orgData.id') as string; - const org = await getOrganization({ - orgId - }); - - setOrgName(org.name); - const incidentContactsData = await getIncidentContacts( - localStorage.getItem('orgData.id') as string - ); - - setIncidentContacts(incidentContactsData?.map((contact) => contact.email)); - - const user = await getUser(); - setPersonalEmail(user.email); - - const orgUsers = await getOrganizationUsers({ - orgId - }); - - setUserList( - orgUsers.map((orgUser) => ({ - key: guidGenerator(), - firstName: orgUser.user?.firstName, - lastName: orgUser.user?.lastName, - email: orgUser.user?.email == null ? orgUser.inviteEmail : orgUser.user?.email, - role: orgUser?.role, - status: orgUser?.status, - userId: orgUser.user?._id, - membershipId: orgUser._id, - publicKey: orgUser.user?.publicKey - })) - ); - - const subscriptions = await getOrganizationSubscriptions({ - orgId - }); - if (subscriptions) { - setCurrentPlan(subscriptions.data[0].plan.product); - } - })(); - }, []); - - const modifyOrgName = (newName: string) => { - setButtonReady(true); - setOrgName(newName); - }; - - const submitChanges = (newOrgName: string) => { - renameOrg(localStorage.getItem('orgData.id') as string, newOrgName); - setButtonReady(false); - }; - - const closeAddUserModal = () => { - setIsAddUserOpen(false); - }; - - const closeAddIncidentContactModal = () => { - setIsAddIncidentContactOpen(false); - }; - - const openAddUserModal = () => { - setIsAddUserOpen(true); - }; - - const openAddIncidentContactModal = () => { - setIsAddIncidentContactOpen(true); - }; - - const submitAddUserModal = async (newUserEmail: string) => { - await addUserToOrg(newUserEmail, localStorage.getItem('orgData.id') as string); - setEmail(''); - setIsAddUserOpen(false); - router.reload(); - }; - - const deleteIncidentContactFully = (incidentContact: string) => { - setIncidentContacts(incidentContacts.filter((contact) => contact !== incidentContact)); - deleteIncidentContact(localStorage.getItem('orgData.id') as string, incidentContact); - }; - - /** - * This function deleted a workspace. - * It first checks if there is more than one workspace aviable. Otherwise, it doesn't delete - * It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete. - * It then deletes the workspace and forwards the user to another aviable workspace. - */ - const executeDeletingWorkspace = async () => { - const userWorkspaces = await getWorkspaces(); - - if (userWorkspaces.length > 1) { - if ( - userWorkspaces.filter((workspace) => workspace._id === workspaceId)[0].name === - workspaceToBeDeletedName - ) { - await deleteWorkspace(workspaceId); - const ws = await getWorkspaces(); - router.push(`/dashboard/${ws[0]._id}`); - } - } - }; - return ( -
+ <> {t('common:head-title', { title: t('settings-org:title') })} -
-
- - -
-
-

{t('settings-org:title')}

-

- {t('settings-org:description')} -

-
-
-
-
-
-
-
-

- {t('common:display-name')} -

- -
-
-
-
-
-
-
-
-
-

- {t('section-members:org-members')} -

-

- {t('section-members:org-members-description')} -

- - = 5 && currentPlan === plans.starter && host === 'https://app.infisical.com'} - onClose={closeAddUserModal} - text="You can add more members if you switch to Infisical's Team plan." - /> - {/* */} -
-
- - setSearchUsers(e.target.value)} - placeholder={t('section-members:search-members') as string} - /> -
-
-
-
- {userList && ( -
- -
- )} -
- -
-
-
-

- {t('section-incident:incident-contacts')} -

-

- {t('section-incident:incident-contacts-description')} -

-
-
-
-
-
- - setSearchIncidentContact(e.target.value)} - placeholder={t('common:search') as string} - /> -
- {incidentContacts?.filter((incidentEmail) => - incidentEmail.includes(searchIncidentContact) - ).length > 0 ? ( - incidentContacts - .filter((incidentEmail) => incidentEmail.includes(searchIncidentContact)) - .map((contact) => ( -
-

{contact}

-
-
-
- )) - ) : ( -
-

{t('section-incident:no-incident-contacts')}

-
- )} -
- - {/*
-

- Danger Zone -

-

- As soon as you delete an organization, you will - not be able to undo it. This will immediately - remove all organization members and cancel your - subscription. If you still want to do that, - please enter the name of the organization below. -

-
- -
- -

- Note: You can only delete a project in case you - have more than one. -

-
*/} -
-
-
-
+ + ); } diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx new file mode 100644 index 000000000..26c06a9d2 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -0,0 +1,308 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useTranslation } from 'next-i18next'; +import { plans } from 'public/data/frequentConstants'; + +import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider'; +import NavHeader from '@app/components/navigation/NavHeader'; +import { + decryptAssymmetric, + encryptAssymmetric +} from '@app/components/utilities/cryptography/crypto'; +import { useOrganization, useSubscription, useUser, useWorkspace } from '@app/context'; +import { + useAddIncidentContact, + useAddUserToOrg, + useDeleteIncidentContact, + useDeleteOrgMembership, + useGetOrgIncidentContact, + useGetOrgUsers, + useGetUserWorkspaceMemberships, + useGetUserWsKey, + useRenameOrg, + useUpdateOrgUserRole, + useUploadWsKey +} from '@app/hooks/api'; + +import { OrgIncidentContactsTable, OrgMembersTable, OrgNameChangeSection } from './components'; + +export const OrgSettingsPage = () => { + const host = window.location.origin; + + const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const { user } = useUser(); + const { subscriptionPlan } = useSubscription(); + const { createNotification } = useNotificationContext(); + + const orgId = currentOrg?._id || ''; + const { data: orgUsers } = useGetOrgUsers(orgId); + const { data: workspaceMemberships } = useGetUserWorkspaceMemberships(orgId); + const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || ''); + const { data: incidentContact } = useGetOrgIncidentContact(orgId); + + const renameOrg = useRenameOrg(); + const removeUserOrgMembership = useDeleteOrgMembership(); + const addUserToOrg = useAddUserToOrg(); + const updateOrgUserRole = useUpdateOrgUserRole(); + const uploadWsKey = useUploadWsKey(); + const addIncidentContact = useAddIncidentContact(); + const removeIncidentContact = useDeleteIncidentContact(); + + const isMoreUsersNotAllowed = + (orgUsers || []).length >= 5 && + subscriptionPlan === plans.starter && + host === 'https://app.infisical.com'; + + const onRenameOrg = async (name: string) => { + if (!currentOrg?._id) return; + + try { + await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); + createNotification({ + text: 'Successfully renamed organization', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to rename organization', + type: 'error' + }); + } + }; + + const onRemoveUserOrgMembership = async (membershipId: string) => { + if (!currentOrg?._id) return; + + try { + await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId }); + createNotification({ + text: 'Successfully removed used from org', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to remove user from org', + type: 'error' + }); + } + }; + const onAddUserToOrg = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await addUserToOrg.mutateAsync({ organizationId: currentOrg?._id, inviteeEmail: email }); + createNotification({ + text: 'Successfully invited user to org', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to invite user to org', + type: 'error' + }); + } + }; + + const onUpdateOrgUserRole = async (membershipId: string, role: string) => { + if (!currentOrg?._id) return; + + try { + await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role }); + createNotification({ + text: 'Successfully updated user role', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to update user role', + type: 'error' + }); + } + }; + + const onGrantUserAccess = async (userId: string, publicKey: string) => { + try { + const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; + if (!PRIVATE_KEY || !wsKey) return; + + // assymmetrically decrypt symmetric key with local private key + const key = decryptAssymmetric({ + ciphertext: wsKey.encryptedKey, + nonce: wsKey.nonce, + publicKey: wsKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadWsKey.mutateAsync({ + userId, + nonce, + encryptedKey: ciphertext, + workspaceId: currentWorkspace?._id || '' + }); + } catch (err) { + console.error(err); + createNotification({ + text: 'Failed to grant access to user', + type: 'error' + }); + } + }; + + const onAddIncidentContact = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await addIncidentContact.mutateAsync({ orgId, email }); + createNotification({ + text: 'Successfully added incident contact', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to add incident contact', + type: 'error' + }); + } + }; + + const onRemoveIncidentContact = async (email: string) => { + if (!currentOrg?._id) return; + + try { + await removeIncidentContact.mutateAsync({ orgId, email }); + createNotification({ + text: 'Successfully removed incident contact', + type: 'success' + }); + } catch (error) { + console.error(error); + createNotification({ + text: 'Failed to remove incident contact', + type: 'error' + }); + } + }; + + /** + * This function deleted a workspace. + * It first checks if there is more than one workspace aviable. Otherwise, it doesn't delete + * It then checks if the name of the workspace to be deleted is correct. Otherwise, it doesn't delete. + * It then deletes the workspace and forwards the user to another aviable workspace. + */ + // const executeDeletingWorkspace = async () => { + // const userWorkspaces = await getWorkspaces(); + // + // if (userWorkspaces.length > 1) { + // if ( + // userWorkspaces.filter((workspace) => workspace._id === workspaceId)[0].name === + // workspaceToBeDeletedName + // ) { + // await deleteWorkspace(workspaceId); + // const ws = await getWorkspaces(); + // router.push(`/dashboard/${ws[0]._id}`); + // } + // } + // }; + // + return ( +
+ +
+
+

{t('settings-org:title')}

+

+ {t('settings-org:description')} +

+
+
+
+ +
+

+ {t('section-members:org-members')} +

+

+ {t('section-members:org-members-description')} +

+ +
+
+
+
+

+ {t('section-incident:incident-contacts')} +

+

+ {t('section-incident:incident-contacts-description')} +

+
+
+
+ +
+
+ {/*
+

+ Danger Zone +

+

+ As soon as you delete an organization, you will + not be able to undo it. This will immediately + remove all organization members and cancel your + subscription. If you still want to do that, + please enter the name of the organization below. +

+
+ +
+ +

+ Note: You can only delete a project in case you + have more than one. +

+
*/} +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx new file mode 100644 index 000000000..296e5031d --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { faMagnifyingGlass, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { + Button, + DeleteActionModal, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { IncidentContact } from '@app/hooks/api/types'; + +type Props = { + contacts?: IncidentContact[]; + onRemoveContact: (email: string) => Promise; + onAddContact: (email: string) => Promise; +}; + +const addContactFormSchema = yup.object({ + email: yup.string().email().required().label('Email').trim() +}); + +type TAddContactForm = yup.InferType; + +export const OrgIncidentContactsTable = ({ + contacts = [], + onAddContact, + onRemoveContact +}: Props) => { + const [searchContact, setSearchContact] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addContact', + 'removeContact' + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addContactFormSchema) }); + + const onAddIncidentContact = ({ email }: TAddContactForm) => { + onAddContact(email); + handlePopUpClose('addContact'); + reset(); + }; + + const onRemoveIncidentContact = async () => { + const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; + await onRemoveContact(incidentContactEmail); + handlePopUpClose('removeContact'); + }; + + return ( +
+
+
+ setSearchContact(e.target.value)} + leftIcon={} + placeholder="Search incident contact by email..." + /> +
+
+ +
+
+
+ +
NAMEEMAILROLE - {env.slug.toUpperCase()}
+
NAMEEMAILROLE + + {env.slug.toUpperCase()} +
+
{/* PERMISSION */}
+ {row.firstName} {row.lastName} + {row.email} -
- handleRoleUpdate(index, e)} value={row.role} - disabled={myRole !== 'admin' || myUser === row.email} + isDisabled={myRole !== 'admin' || myUser === row.email} // onOpenChange={(open) => setIsOpen(open)} > Admin Member {row.status === 'completed' && myUser !== row.email && ( -
+
- - No Access - Read Only - Add Only - Read & Write - - + + {myUser !== row.email && // row.role !== "admin" && myRole !== 'member' ? ( -
+
) : ( -
+
)}
+ + + + + + + {contacts + ?.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact)) + ?.map(({ email }) => ( + + + + + ))} + +
Email +
{email} + handlePopUpOpen('removeContact', { email })} + > + + +
+ +
+ { + handlePopUpToggle('addContact', isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ handlePopUpToggle('removeContact', isOpen)} + onDeleteApproved={onRemoveIncidentContact} + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx new file mode 100644 index 000000000..8fbc24611 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/index.tsx @@ -0,0 +1 @@ +export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx new file mode 100644 index 000000000..e23bf42ba --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -0,0 +1,274 @@ +import { useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { faMagnifyingGlass, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { + Button, + DeleteActionModal, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + Tag, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal +} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { OrgUser, Workspace } from '@app/hooks/api/types'; + +type Props = { + members?: OrgUser[]; + workspaceMemberships?: Record; + orgName: string; + isMoreUserNotAllowed: boolean; + onRemoveMember: (userId: string) => Promise; + onInviteMember: (email: string) => Promise; + onRoleChange: (membershipId: string, role: string) => Promise; + onGrantAccess: (userId: string, publicKey: string) => Promise; + // the current user id to block remove org button + userId: string; +}; + +const addMemberFormSchema = yup.object({ + email: yup.string().email().required().label('Email').trim() +}); + +type TAddMemberForm = yup.InferType; + +export const OrgMembersTable = ({ + members = [], + workspaceMemberships = {}, + orgName, + isMoreUserNotAllowed, + onRemoveMember, + onInviteMember, + onGrantAccess, + onRoleChange, + userId +}: Props) => { + const [searchMemberFilter, setSearchMemberFilter] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addMember', + 'removeMember', + 'upgradePlan' + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addMemberFormSchema) }); + + const onAddMember = ({ email }: TAddMemberForm) => { + onInviteMember(email); + handlePopUpClose('addMember'); + reset(); + }; + + const onRemoveOrgMemberApproved = async () => { + const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id; + await onRemoveMember(orgMembershipId); + handlePopUpClose('removeMember'); + }; + + const isIamOwner = useMemo( + () => members.find(({ user }) => userId === user?._id)?.role === 'owner', + [userId, members] + ); + + const filterdUser = useMemo( + () => + members.filter( + ({ user, inviteEmail }) => + user?.firstName?.toLowerCase().includes(searchMemberFilter) || + user?.lastName?.toLowerCase().includes(searchMemberFilter) || + user?.email?.toLowerCase().includes(searchMemberFilter) || + inviteEmail?.includes(searchMemberFilter) + ), + [members, searchMemberFilter] + ); + + return ( +
+
+
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
+
+ +
+
+
+ + + + + + + + + + + + {filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { + const name = user ? `${user.firstName} ${user.lastName}` : '-'; + const email = user?.email || inviteEmail; + const userWs = workspaceMemberships?.[user?._id]; + + return ( + + + + + + + + ); + })} + +
NameEmailRoleProjects +
{name}{email} + {status === 'accepted' && ( + + )} + {(status === 'invited' || status === 'verified') && ( + + )} + {status === 'completed' && ( + + )} + + {userWs ? ( + userWs?.map(({ name: wsName, _id }) => ( + + {wsName} + + )) + ) : ( + This user isn't part of any projects yet + )} + + handlePopUpOpen('removeMember', { id: orgMembershipId })} + > + + +
+
+
+ { + handlePopUpToggle('addMember', isOpen); + reset(); + }} + > + + An invite is specific to an email address and expires after 1 day. +
+ For security reasons, you will need to separately add members to projects. + + } + > +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ handlePopUpToggle('removeMember', isOpen)} + onDeleteApproved={onRemoveOrgMemberApproved} + /> + handlePopUpToggle('upgradePlan', isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx new file mode 100644 index 000000000..729d06592 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/index.tsx @@ -0,0 +1 @@ +export { OrgMembersTable } from './OrgMembersTable'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx new file mode 100644 index 000000000..9b18285c0 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { faCheck } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; + +import { Button, FormControl, Input } from '@app/components/v2'; + +type Props = { + orgName?: string; + onOrgNameChange: (name: string) => Promise; +}; + +const formSchema = yup.object({ + name: yup.string().required().label('Project Name') +}); + +type FormData = yup.InferType; + +export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => { + const { + handleSubmit, + control, + reset, + formState: { isDirty, isSubmitting } + } = useForm({ resolver: yupResolver(formSchema) }); + const { t } = useTranslation(); + + useEffect(() => { + reset({ name: orgName }); + }, [orgName]); + + const onFormSubmit = async ({ name }: FormData) => { + await onOrgNameChange(name); + }; + + return ( +
+
+

{t('common:display-name')}

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx new file mode 100644 index 000000000..b668d3732 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/index.tsx @@ -0,0 +1 @@ +export { OrgNameChangeSection } from './OrgNameChangeSection'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx new file mode 100644 index 000000000..15a09b0d2 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx @@ -0,0 +1,3 @@ +export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; +export { OrgMembersTable } from './OrgMembersTable'; +export { OrgNameChangeSection } from './OrgNameChangeSection'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/index.tsx new file mode 100644 index 000000000..3ea2e3b0f --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/index.tsx @@ -0,0 +1 @@ +export { OrgSettingsPage } from './OrgSettingsPage';