diff --git a/frontend/src/components/basic/table/ProjectUsersTable.tsx b/frontend/src/components/basic/table/ProjectUsersTable.tsx index 27346195c1..438517aa5f 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/components/v2/Card/Card.tsx b/frontend/src/components/v2/Card/Card.tsx index 4bb3c36748..0950186143 100644 --- a/frontend/src/components/v2/Card/Card.tsx +++ b/frontend/src/components/v2/Card/Card.tsx @@ -3,14 +3,19 @@ import { twMerge } from 'tailwind-merge'; export type CardTitleProps = { children: ReactNode; - subTitle?: string; + subTitle?: ReactNode; className?: string; }; export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => ( -
+
{children} - {subTitle &&

{subTitle}

} + {subTitle &&

{subTitle}

}
); diff --git a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx index 1d072cc89c..31dcc611ab 100644 --- a/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx +++ b/frontend/src/components/v2/DeleteActionModal/DeleteActionModal.tsx @@ -5,7 +5,7 @@ import { useToggle } from '@app/hooks'; import { Button } from '../Button'; import { FormControl } from '../FormControl'; import { Input } from '../Input'; -import { Modal, ModalContent } from '../Modal'; +import { Modal, ModalClose, ModalContent } from '../Modal'; type Props = { isOpen?: boolean; @@ -64,9 +64,11 @@ export const DeleteActionModal = ({ > Delete - + + + {' '}
} onClose={onClose} diff --git a/frontend/src/components/v2/Input/Input.tsx b/frontend/src/components/v2/Input/Input.tsx index 28524c72d4..ed9ead6468 100644 --- a/frontend/src/components/v2/Input/Input.tsx +++ b/frontend/src/components/v2/Input/Input.tsx @@ -84,19 +84,19 @@ export const Input = forwardRef( ): JSX.Element => { return (
- {leftIcon && {leftIcon}} + {leftIcon && {leftIcon}} - {rightIcon && {rightIcon}} + {rightIcon && {rightIcon}}
); } diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index c338817725..fc9a90642e 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -9,7 +9,7 @@ import { IconButton } from '../IconButton'; export type ModalContentProps = DialogPrimitive.DialogContentProps & { title?: ReactNode; - subTitle?: string; + subTitle?: ReactNode; footerContent?: ReactNode; onClose?: () => void; }; diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index f8d559abe8..ffeaf3c3f8 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -14,18 +14,28 @@ type Props = { dropdownContainerClassName?: string; isLoading?: boolean; position?: 'item-aligned' | 'popper'; + isDisabled?: boolean; icon?: IconProp; }; -export type SelectProps = SelectPrimitive.SelectProps & Props; +export type SelectProps = Omit & Props; export const Select = forwardRef( ( - { children, placeholder, className, isLoading, dropdownContainerClassName, position, ...props }, + { + children, + placeholder, + className, + isLoading, + isDisabled, + dropdownContainerClassName, + position, + ...props + }, ref ): JSX.Element => { return ( - + ( className )} > - + {props.icon ? : placeholder} - {!props.disabled && ( + {!isDisabled && ( @@ -46,7 +56,7 @@ export const Select = forwardRef( ( ; + +const tagVariants = cva('inline-flex whitespace-nowrap text-sm rounded-md mx-1 text-bunker-200 ', { + variants: { + colorSchema: { + gray: 'bg-mineshaft-500', + red: 'bg-red/80 text-bunker-100' + }, + size: { + sm: 'px-2 py-1' + } + } +}); + +export const Tag = ({ children, className, colorSchema = 'gray', size = 'sm' }: Props) => ( +
{children}
+); diff --git a/frontend/src/components/v2/Tag/index.tsx b/frontend/src/components/v2/Tag/index.tsx new file mode 100644 index 0000000000..ba2338b7be --- /dev/null +++ b/frontend/src/components/v2/Tag/index.tsx @@ -0,0 +1 @@ +export { Tag } from './Tag'; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 0f89a3a2b7..d69b0bfb44 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -12,5 +12,6 @@ export * from './Select'; export * from './Spinner'; export * from './Switch'; export * from './Table'; +export * from './Tag'; export * from './TextArea'; export * from './UpgradePlanModal'; diff --git a/frontend/src/hooks/api/incidentContacts/index.tsx b/frontend/src/hooks/api/incidentContacts/index.tsx new file mode 100644 index 0000000000..6c53db28eb --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/index.tsx @@ -0,0 +1,5 @@ +export { + useAddIncidentContact, + useDeleteIncidentContact, + useGetOrgIncidentContact +} from './queries'; diff --git a/frontend/src/hooks/api/incidentContacts/queries.tsx b/frontend/src/hooks/api/incidentContacts/queries.tsx new file mode 100644 index 0000000000..4f028f16db --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/queries.tsx @@ -0,0 +1,57 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { AddIncidentContactDTO, DeleteIncidentContactDTO, IncidentContact } from './types'; + +const incidentContactKeys = { + getAllContact: (orgId: string) => ['org-incident-contacts', { orgId }] as const +}; + +const fetchOrgIncidentContacts = async (orgId: string) => { + const { data } = await apiRequest.get<{ incidentContactsOrg: IncidentContact[] }>( + `/api/v1/organization/${orgId}/incidentContactOrg` + ); + + return data.incidentContactsOrg; +}; + +export const useGetOrgIncidentContact = (orgId: string) => + useQuery({ + queryKey: incidentContactKeys.getAllContact(orgId), + queryFn: () => fetchOrgIncidentContacts(orgId), + enabled: Boolean(orgId) + }); + +// mutation +export const useAddIncidentContact = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, AddIncidentContactDTO>({ + mutationFn: async ({ orgId, email }) => { + const { data } = await apiRequest.post(`/api/v1/organization/${orgId}/incidentContactOrg`, { + email + }); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(incidentContactKeys.getAllContact(orgId)); + } + }); +}; + +export const useDeleteIncidentContact = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, DeleteIncidentContactDTO>({ + mutationFn: async ({ orgId, email }) => { + const { data } = await apiRequest.delete(`/api/v1/organization/${orgId}/incidentContactOrg`, { + data: { email } + }); + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(incidentContactKeys.getAllContact(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/incidentContacts/types.ts b/frontend/src/hooks/api/incidentContacts/types.ts new file mode 100644 index 0000000000..a1ef8ab957 --- /dev/null +++ b/frontend/src/hooks/api/incidentContacts/types.ts @@ -0,0 +1,18 @@ +export type IncidentContact = { + _id: string; + email: string; + organization: string; + __v: number; + createdAt: Date; + updatedAt: Date; +}; + +export type DeleteIncidentContactDTO = { + orgId: string; + email: string; +}; + +export type AddIncidentContactDTO = { + orgId: string; + email: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ad9acf0c3e..c2035bbbab 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,4 +1,5 @@ export * from './auth'; +export * from './incidentContacts'; export * from './keys'; export * from './organization'; export * from './serviceTokens'; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 4d9dd04f8b..a11a7dd741 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -1 +1 @@ -export { useGetOrganization } from './queries'; +export { useGetOrganization, useRenameOrg } from './queries'; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index b2f9c7ce6e..0770fc4136 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -1,8 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; -import { Organization } from './types'; +import { Organization, RenameOrgDTO } from './types'; const organizationKeys = { getUserOrganization: ['organization'] as const @@ -16,3 +16,16 @@ const fetchUserOrganization = async () => { export const useGetOrganization = () => useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization }); + +// mutation +export const useRenameOrg = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, RenameOrgDTO>({ + mutationFn: ({ newOrgName, orgId }) => + apiRequest.patch(`/api/v1/organization/${orgId}/name`, { name: newOrgName }), + onSuccess: () => { + queryClient.invalidateQueries(organizationKeys.getUserOrganization); + } + }); +}; diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 74720320ba..92a5c5f1dd 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -4,3 +4,8 @@ export type Organization = { createAt: string; updatedAt: string; }; + +export type RenameOrgDTO = { + orgId: string; + newOrgName: string; +}; diff --git a/frontend/src/hooks/api/types.ts b/frontend/src/hooks/api/types.ts index 699e9a81dc..a8821e5a51 100644 --- a/frontend/src/hooks/api/types.ts +++ b/frontend/src/hooks/api/types.ts @@ -1,14 +1,17 @@ export type { GetAuthTokenAPI } from './auth/types'; +export type { IncidentContact } from './incidentContacts/types'; export type { UserWsKeyPair } from './keys/types'; export type { Organization } from './organization/types'; export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types'; export type { GetSubscriptionPlan, SubscriptionPlan } from './subscriptions/types'; -export type { User } from './users/types'; +export type { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './users/types'; export type { CreateEnvironmentDTO, + CreateWorkspaceDTO, DeleteEnvironmentDTO, DeleteWorkspaceDTO, RenameWorkspaceDTO, + ToggleAutoCapitalizationDTO, UpdateEnvironmentDTO, Workspace, WorkspaceEnv, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 69c0fc98a1..0980d4108b 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,7 +1,10 @@ export { fetchOrgUsers, + useAddUserToOrg, useAddUserToWs, + useDeleteOrgMembership, useGetOrgUsers, useGetUser, - useLogoutUser + useLogoutUser, + useUpdateOrgUserRole } from './queries'; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 1ddcf3809e..c6b35960d3 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { decryptAssymmetric, @@ -8,7 +8,15 @@ import { apiRequest } from '@app/config/request'; import { setAuthToken } from '@app/reactQuery'; import { useUploadWsKey } from '../keys/queries'; -import { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './types'; +import { + AddUserToOrgDTO, + AddUserToWsDTO, + AddUserToWsRes, + DeletOrgMembershipDTO, + OrgUser, + UpdateOrgUserRoleDTO, + User +} from './types'; const userKeys = { getUser: ['user'] as const, @@ -32,7 +40,11 @@ export const fetchOrgUsers = async (orgId: string) => { }; export const useGetOrgUsers = (orgId: string) => - useQuery(userKeys.getOrgUsers(orgId), () => fetchOrgUsers(orgId)); + useQuery({ + queryKey: userKeys.getOrgUsers(orgId), + queryFn: () => fetchOrgUsers(orgId), + enabled: Boolean(orgId) + }); // mutation export const useAddUserToWs = () => { @@ -69,6 +81,47 @@ export const useAddUserToWs = () => { }); }; +export const useAddUserToOrg = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, AddUserToOrgDTO>({ + mutationFn: (dto) => apiRequest.post(`/api/v1/invite-org/signup`, dto), + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + } + }); +}; + +export const useDeleteOrgMembership = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, DeletOrgMembershipDTO>({ + mutationFn: ({ membershipId, orgId }) => + apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`), + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(orgId)); + } + }); +}; + +export const useUpdateOrgUserRole = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdateOrgUserRoleDTO>({ + mutationFn: ({ organizationId, membershipId, role }) => + apiRequest.patch(`/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { + role + }), + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + }, + // to remove old states + onError: (_, { organizationId }) => { + queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); + } + }); +}; + export const useLogoutUser = () => useMutation({ mutationFn: () => apiRequest.post('/api/v1/auth/logout'), diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 1b17cd023e..b3fb5cf6bf 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -32,7 +32,7 @@ export type OrgUser = { inviteEmail: string; organization: string; role: 'owner' | 'admin' | 'member'; - status: 'invited' | 'accepted'; + status: 'invited' | 'accepted' | 'verified' | 'completed'; deniedPermissions: any[]; }; @@ -45,3 +45,19 @@ export type AddUserToWsRes = { invitee: OrgUser['user']; latestKey: UserWsKeyPair; }; + +export type UpdateOrgUserRoleDTO = { + organizationId: string; + membershipId: string; + role: string; +}; + +export type DeletOrgMembershipDTO = { + membershipId: string; + orgId: string; +}; + +export type AddUserToOrgDTO = { + inviteeEmail: string; + organizationId: string; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 52a01029eb..cab8617bd9 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,9 +3,9 @@ export { useCreateWsEnvironment, useDeleteWorkspace, useDeleteWsEnvironment, + useGetUserWorkspaceMemberships, useGetUserWorkspaces, useGetWorkspaceById, useRenameWorkspace, useToggleAutoCapitalization, useUpdateWsEnvironment} from './queries'; - \ No newline at end of file diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 0c4ba970b6..2bca2e9a52 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -13,16 +13,18 @@ import { Workspace } from './types'; - const workspaceKeys = { getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const, + getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'], getAllUserWorkspace: ['workspaces'] as const }; const fetchWorkspaceById = async (workspaceId: string) => { - const { data } = await apiRequest.get<{ workspace: Workspace }>(`/api/v1/workspace/${workspaceId}`); - return data.workspace; -} + const { data } = await apiRequest.get<{ workspace: Workspace }>( + `/api/v1/workspace/${workspaceId}` + ); + return data.workspace; +}; const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace'); @@ -40,6 +42,21 @@ export const useGetWorkspaceById = (workspaceId: string) => { export const useGetUserWorkspaces = () => useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); +const fetchUserWorkspaceMemberships = async (orgId: string) => { + const { data } = await apiRequest.get>( + `/api/v1/organization/${orgId}/workspace-memberships` + ); + return data; +}; + +// to get all userids in an org with the workspace they are part of +export const useGetUserWorkspaceMemberships = (orgId: string) => + useQuery({ + queryKey: workspaceKeys.getWorkspaceMemberships(orgId), + queryFn: () => fetchUserWorkspaceMemberships(orgId), + enabled: Boolean(orgId) + }); + // mutation export const useCreateWorkspace = () => { const queryClient = useQueryClient(); @@ -70,7 +87,9 @@ export const useToggleAutoCapitalization = () => { return useMutation<{}, {}, ToggleAutoCapitalizationDTO>({ mutationFn: ({ workspaceID, state }) => - apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, { autoCapitalization: state }), + apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, { + autoCapitalization: state + }), onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); } diff --git a/frontend/src/pages/settings/org/[id].tsx b/frontend/src/pages/settings/org/[id].tsx index 562c45c9ba..59c8c373ca 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 0000000000..26c06a9d21 --- /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 0000000000..296e5031d3 --- /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 0000000000..8fbc246111 --- /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 0000000000..e23bf42ba3 --- /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 0000000000..729d065929 --- /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 0000000000..9b18285c0a --- /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 0000000000..b668d37323 --- /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 0000000000..15a09b0d23 --- /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 0000000000..3ea2e3b0f5 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/index.tsx @@ -0,0 +1 @@ +export { OrgSettingsPage } from './OrgSettingsPage';