Merge pull request #337 from akhilmhdh/feat/new-org-settings

Revamped the org settings page
This commit is contained in:
mv-turtle
2023-02-20 17:25:27 -08:00
committed by GitHub
32 changed files with 1263 additions and 469 deletions
@@ -30,7 +30,7 @@ type Props = {
type EnvironmentProps = { type EnvironmentProps = {
name: string; name: string;
slug: string; slug: string;
} };
/** /**
* This is the component that shows the users of a certin project * 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 ) => { const handlePermissionUpdate = (
let denials: { ability: string; environmentSlug: string; }[]; index: number,
if (val === "Read Only") { val: string,
denials = [{ membershipId: string,
ability: "write", slug: string
environmentSlug: slug ) => {
}]; let denials: { ability: string; environmentSlug: string }[];
} else if (val === "No Access") { if (val === 'Read Only') {
denials = [{ denials = [
ability: "write", {
environmentSlug: slug ability: 'write',
}, { environmentSlug: slug
ability: "read", }
environmentSlug: slug ];
}]; } else if (val === 'No Access') {
} else if (val === "Add Only") { denials = [
denials = [{ {
ability: "read", ability: 'write',
environmentSlug: slug environmentSlug: slug
}]; },
{
ability: 'read',
environmentSlug: slug
}
];
} else if (val === 'Add Only') {
denials = [
{
ability: 'read',
environmentSlug: slug
}
];
} else { } else {
denials = []; denials = [];
} }
@@ -118,8 +130,12 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
if (currentPlan !== plans.professional && host === 'https://app.infisical.com') { if (currentPlan !== plans.professional && host === 'https://app.infisical.com') {
setIsUpgradeModalOpen(true); setIsUpgradeModalOpen(true);
} else { } else {
const allDenials = userData[index].deniedPermissions.filter((perm: { ability: string; environmentSlug: string; }) => perm.environmentSlug !== slug).concat(denials); const allDenials = userData[index].deniedPermissions
updateUserProjectPermission({ membershipId, denials: allDenials}); .filter(
(perm: { ability: string; environmentSlug: string }) => perm.environmentSlug !== slug
)
.concat(denials);
updateUserProjectPermission({ membershipId, denials: allDenials });
changeData([ changeData([
...userData.slice(0, index), ...userData.slice(0, index),
...[ ...[
@@ -156,7 +172,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
orgId orgId
}); });
if (subscriptions) { if (subscriptions) {
setCurrentPlan(subscriptions.data[0].plan.product) setCurrentPlan(subscriptions.data[0].plan.product);
} }
})(); })();
}, [userData, myUser]); }, [userData, myUser]);
@@ -186,25 +202,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
const closeUpgradeModal = () => { const closeUpgradeModal = () => {
setIsUpgradeModalOpen(false); setIsUpgradeModalOpen(false);
} };
return ( return (
<div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1 min-w-max"> <div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-700 bg-bunker">
<div className="absolute rounded-t-md w-full h-[3.1rem] bg-white/5" /> <div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
<UpgradePlanModal <UpgradePlanModal
isOpen={isUpgradeModalOpen} isOpen={isUpgradeModalOpen}
onClose={closeUpgradeModal} onClose={closeUpgradeModal}
text="You can change user permissions if you switch to Infisical's Professional plan." text="You can change user permissions if you switch to Infisical's Professional plan."
/> />
<table className="w-full my-0.5"> <table className="my-0.5 w-full">
<thead className="text-gray-400 text-xs font-light"> <thead className="text-xs font-light text-gray-400">
<tr> <tr>
<th className="text-left pl-4 py-3.5">NAME</th> <th className="py-3.5 pl-4 text-left">NAME</th>
<th className="text-left pl-4 py-3.5">EMAIL</th> <th className="py-3.5 pl-4 text-left">EMAIL</th>
<th className="text-left pl-6 pr-10 py-3.5">ROLE</th> <th className="py-3.5 pl-6 pr-10 text-left">ROLE</th>
{workspaceEnvs.map(env => ( {workspaceEnvs.map((env) => (
<th key={guidGenerator()} className="text-left pl-2 py-1 max-w-min break-normal"> <th key={guidGenerator()} className="max-w-min break-normal py-1 pl-2 text-left">
<span>{env.slug.toUpperCase()}<br/></span> <span>
{env.slug.toUpperCase()}
<br />
</span>
{/* <span>PERMISSION</span> */} {/* <span>PERMISSION</span> */}
</th> </th>
))} ))}
@@ -227,28 +246,28 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
) )
.map((row, index) => ( .map((row, index) => (
<tr key={guidGenerator()} className="bg-bunker-600 text-sm hover:bg-bunker-500"> <tr key={guidGenerator()} className="bg-bunker-600 text-sm hover:bg-bunker-500">
<td className="pl-4 py-2 border-mineshaft-700 border-t text-gray-300"> <td className="border-t border-mineshaft-700 py-2 pl-4 text-gray-300">
{row.firstName} {row.lastName} {row.firstName} {row.lastName}
</td> </td>
<td className="pl-4 py-2 border-mineshaft-700 border-t text-gray-300"> <td className="border-t border-mineshaft-700 py-2 pl-4 text-gray-300">
{row.email} {row.email}
</td> </td>
<td className="pl-6 pr-10 py-2 border-mineshaft-700 border-t text-gray-300"> <td className="border-t border-mineshaft-700 py-2 pl-6 pr-10 text-gray-300">
<div className="justify-start h-full flex flex-row items-center"> <div className="flex h-full flex-row items-center justify-start">
<Select <Select
className="w-36 bg-mineshaft-700" className="w-36 bg-mineshaft-700"
dropdownContainerClassName="bg-mineshaft-700" dropdownContainerClassName="bg-mineshaft-700"
// open={isOpen} // open={isOpen}
onValueChange={(e) => handleRoleUpdate(index, e)} onValueChange={(e) => handleRoleUpdate(index, e)}
value={row.role} value={row.role}
disabled={myRole !== 'admin' || myUser === row.email} isDisabled={myRole !== 'admin' || myUser === row.email}
// onOpenChange={(open) => setIsOpen(open)} // onOpenChange={(open) => setIsOpen(open)}
> >
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem> <SelectItem value="member">Member</SelectItem>
</Select> </Select>
{row.status === 'completed' && myUser !== row.email && ( {row.status === 'completed' && myUser !== row.email && (
<div className="border border-mineshaft-700 rounded-md bg-white/5 hover:bg-primary text-white hover:text-black duration-200"> <div className="rounded-md border border-mineshaft-700 bg-white/5 text-white duration-200 hover:bg-primary hover:text-black">
<Button <Button
onButtonPressed={() => grantAccess(row.userId, row.publicKey)} onButtonPressed={() => grantAccess(row.userId, row.publicKey)}
color="mineshaft" color="mineshaft"
@@ -259,43 +278,106 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
)} )}
</div> </div>
</td> </td>
{workspaceEnvs.map((env) => <td key={guidGenerator()} className="pl-2 py-2 border-mineshaft-700 border-t text-gray-300"> {workspaceEnvs.map((env) => (
<Select <td
className="w-16 bg-mineshaft-700" key={guidGenerator()}
dropdownContainerClassName="bg-mineshaft-700" className="border-t border-mineshaft-700 py-2 pl-2 text-gray-300"
position="item-aligned"
// open={isOpen}
onValueChange={(val) => handlePermissionUpdate(index, val, row.membershipId, env.slug)}
value={
// eslint-disable-next-line no-nested-ternary
(row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read"))
? "No Access"
// eslint-disable-next-line no-nested-ternary
: (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? "Read Only"
: !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? "Add Only" : "Read & Write")
}
icon={
// eslint-disable-next-line no-nested-ternary
(row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read"))
? faEyeSlash
// eslint-disable-next-line no-nested-ternary
: (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? faEye
: !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? faPlus : faPenToSquare)
}
disabled={myRole !== 'admin'}
// onOpenChange={(open) => setIsOpen(open)}
> >
<SelectItem value="No Access" customIcon={faEyeSlash}>No Access</SelectItem> <Select
<SelectItem value="Read Only" customIcon={faEye}>Read Only</SelectItem> className="w-16 bg-mineshaft-700"
<SelectItem value="Add Only" customIcon={faPlus}>Add Only</SelectItem> dropdownContainerClassName="bg-mineshaft-700"
<SelectItem value="Read & Write" customIcon={faPenToSquare}>Read & Write</SelectItem> position="item-aligned"
</Select> // open={isOpen}
</td>)} onValueChange={(val) =>
<td className="flex flex-row justify-end pl-8 pr-8 py-2 border-t border-0.5 border-mineshaft-700"> handlePermissionUpdate(index, val, row.membershipId, env.slug)
}
value={
// eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? 'No Access'
: // eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
!row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? 'Read Only'
: !row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? 'Add Only'
: 'Read & Write'
}
icon={
// eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? faEyeSlash
: // eslint-disable-next-line no-nested-ternary
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
!row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? faEye
: !row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('write') &&
row.deniedPermissions
.filter((perm: any) => perm.environmentSlug === env.slug)
.map((perm: { ability: string }) => perm.ability)
.includes('read')
? faPlus
: faPenToSquare
}
isDisabled={myRole !== 'admin'}
// onOpenChange={(open) => setIsOpen(open)}
>
<SelectItem value="No Access" customIcon={faEyeSlash}>
No Access
</SelectItem>
<SelectItem value="Read Only" customIcon={faEye}>
Read Only
</SelectItem>
<SelectItem value="Add Only" customIcon={faPlus}>
Add Only
</SelectItem>
<SelectItem value="Read & Write" customIcon={faPenToSquare}>
Read & Write
</SelectItem>
</Select>
</td>
))}
<td className="border-0.5 flex flex-row justify-end border-t border-mineshaft-700 py-2 pl-8 pr-8">
{myUser !== row.email && {myUser !== row.email &&
// row.role !== "admin" && // row.role !== "admin" &&
myRole !== 'member' ? ( myRole !== 'member' ? (
<div className="opacity-50 hover:opacity-100 flex items-center mt-0.5"> <div className="mt-0.5 flex items-center opacity-50 hover:opacity-100">
<Button <Button
onButtonPressed={() => handleDelete(row.membershipId, index)} onButtonPressed={() => handleDelete(row.membershipId, index)}
color="red" color="red"
@@ -304,7 +386,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
/> />
</div> </div>
) : ( ) : (
<div className="w-9 h-9" /> <div className="h-9 w-9" />
)} )}
</td> </td>
</tr> </tr>
+8 -3
View File
@@ -3,14 +3,19 @@ import { twMerge } from 'tailwind-merge';
export type CardTitleProps = { export type CardTitleProps = {
children: ReactNode; children: ReactNode;
subTitle?: string; subTitle?: ReactNode;
className?: string; className?: string;
}; };
export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => ( export const CardTitle = ({ children, className, subTitle }: CardTitleProps) => (
<div className={twMerge('px-6 py-4 mb-5 font-sans text-lg font-normal border-b border-mineshaft-600', className)}> <div
className={twMerge(
'px-6 py-4 mb-5 font-sans text-lg font-normal border-b border-mineshaft-600',
className
)}
>
{children} {children}
{subTitle && <p className="pt-0.5 text-sm font-normal text-gray-400">{subTitle}</p>} {subTitle && <p className="pt-2 text-sm font-normal text-gray-400">{subTitle}</p>}
</div> </div>
); );
@@ -5,7 +5,7 @@ import { useToggle } from '@app/hooks';
import { Button } from '../Button'; import { Button } from '../Button';
import { FormControl } from '../FormControl'; import { FormControl } from '../FormControl';
import { Input } from '../Input'; import { Input } from '../Input';
import { Modal, ModalContent } from '../Modal'; import { Modal, ModalClose, ModalContent } from '../Modal';
type Props = { type Props = {
isOpen?: boolean; isOpen?: boolean;
@@ -64,9 +64,11 @@ export const DeleteActionModal = ({
> >
Delete Delete
</Button> </Button>
<Button variant="plain" colorSchema="secondary" onClick={onClose}> <ModalClose asChild>
Cancel <Button variant="plain" colorSchema="secondary" onClick={onClose}>
</Button> Cancel
</Button>
</ModalClose>{' '}
</div> </div>
} }
onClose={onClose} onClose={onClose}
+4 -4
View File
@@ -84,19 +84,19 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
): JSX.Element => { ): JSX.Element => {
return ( return (
<div className={inputParentContainerVariants({ isRounded, isError, isFullWidth, variant })}> <div className={inputParentContainerVariants({ isRounded, isError, isFullWidth, variant })}>
{leftIcon && <span className="absolute left-0 ml-2">{leftIcon}</span>} {leftIcon && <span className="absolute left-0 ml-3">{leftIcon}</span>}
<input <input
{...props} {...props}
required={isRequired} required={isRequired}
ref={ref} ref={ref}
disabled={isDisabled} disabled={isDisabled}
className={twMerge( className={twMerge(
leftIcon ? 'pl-9' : 'pl-2.5', leftIcon ? 'pl-10' : 'pl-2.5',
rightIcon ? 'pr-9' : 'pr-2.5', rightIcon ? 'pr-10' : 'pr-2.5',
inputVariants({ className, isError, size, isRounded, variant }) inputVariants({ className, isError, size, isRounded, variant })
)} )}
/> />
{rightIcon && <span className="absolute right-0 mr-2">{rightIcon}</span>} {rightIcon && <span className="absolute right-0 mr-3">{rightIcon}</span>}
</div> </div>
); );
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import { IconButton } from '../IconButton';
export type ModalContentProps = DialogPrimitive.DialogContentProps & { export type ModalContentProps = DialogPrimitive.DialogContentProps & {
title?: ReactNode; title?: ReactNode;
subTitle?: string; subTitle?: ReactNode;
footerContent?: ReactNode; footerContent?: ReactNode;
onClose?: () => void; onClose?: () => void;
}; };
+20 -9
View File
@@ -14,18 +14,28 @@ type Props = {
dropdownContainerClassName?: string; dropdownContainerClassName?: string;
isLoading?: boolean; isLoading?: boolean;
position?: 'item-aligned' | 'popper'; position?: 'item-aligned' | 'popper';
isDisabled?: boolean;
icon?: IconProp; icon?: IconProp;
}; };
export type SelectProps = SelectPrimitive.SelectProps & Props; export type SelectProps = Omit<SelectPrimitive.SelectProps, 'disabled'> & Props;
export const Select = forwardRef<HTMLButtonElement, SelectProps>( export const Select = forwardRef<HTMLButtonElement, SelectProps>(
( (
{ children, placeholder, className, isLoading, dropdownContainerClassName, position, ...props }, {
children,
placeholder,
className,
isLoading,
isDisabled,
dropdownContainerClassName,
position,
...props
},
ref ref
): JSX.Element => { ): JSX.Element => {
return ( return (
<SelectPrimitive.Root {...props}> <SelectPrimitive.Root {...props} disabled={isDisabled}>
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={twMerge( className={twMerge(
@@ -34,10 +44,10 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
className className
)} )}
> >
<SelectPrimitive.Value placeholder={placeholder}> <SelectPrimitive.Value placeholder={placeholder}>
{props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder} {props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder}
</SelectPrimitive.Value> </SelectPrimitive.Value>
{!props.disabled && ( {!isDisabled && (
<SelectPrimitive.Icon className="ml-3"> <SelectPrimitive.Icon className="ml-3">
<FontAwesomeIcon icon={faChevronDown} size="sm" /> <FontAwesomeIcon icon={faChevronDown} size="sm" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
@@ -46,7 +56,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
className={twMerge( className={twMerge(
'relative top-1 overflow-hidden rounded-md bg-bunker-800 font-inter text-bunker-100 shadow-md z-[100]', 'relative top-1 z-[100] overflow-hidden rounded-md bg-bunker-800 font-inter text-bunker-100 shadow-md',
dropdownContainerClassName dropdownContainerClassName
)} )}
position={position} position={position}
@@ -89,11 +99,12 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
<SelectPrimitive.Item <SelectPrimitive.Item
{...props} {...props}
className={twMerge( className={twMerge(
`relative flex cursor-pointer `relative mb-0.5 flex
select-none items-center rounded-md py-2 pl-10 pr-4 mb-0.5 text-sm cursor-pointer select-none items-center rounded-md py-2 pl-10 pr-4 text-sm
outline-none transition-all hover:bg-mineshaft-500`, outline-none transition-all hover:bg-mineshaft-500`,
isSelected && 'bg-primary', isSelected && 'bg-primary',
isDisabled && 'cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600', isDisabled &&
'cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600',
className className
)} )}
ref={forwardedRef} ref={forwardedRef}
+24
View File
@@ -0,0 +1,24 @@
import { ReactNode } from 'react';
import { cva, VariantProps } from 'cva';
import { twMerge } from 'tailwind-merge';
type Props = {
children: ReactNode;
className?: string;
} & VariantProps<typeof tagVariants>;
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) => (
<div className={twMerge(tagVariants({ colorSchema, className, size }))}>{children}</div>
);
+1
View File
@@ -0,0 +1 @@
export { Tag } from './Tag';
+1
View File
@@ -12,5 +12,6 @@ export * from './Select';
export * from './Spinner'; export * from './Spinner';
export * from './Switch'; export * from './Switch';
export * from './Table'; export * from './Table';
export * from './Tag';
export * from './TextArea'; export * from './TextArea';
export * from './UpgradePlanModal'; export * from './UpgradePlanModal';
@@ -0,0 +1,5 @@
export {
useAddIncidentContact,
useDeleteIncidentContact,
useGetOrgIncidentContact
} from './queries';
@@ -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));
}
});
};
@@ -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;
};
+1
View File
@@ -1,4 +1,5 @@
export * from './auth'; export * from './auth';
export * from './incidentContacts';
export * from './keys'; export * from './keys';
export * from './organization'; export * from './organization';
export * from './serviceTokens'; export * from './serviceTokens';
+1 -1
View File
@@ -1 +1 @@
export { useGetOrganization } from './queries'; export { useGetOrganization, useRenameOrg } from './queries';
@@ -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 { apiRequest } from '@app/config/request';
import { Organization } from './types'; import { Organization, RenameOrgDTO } from './types';
const organizationKeys = { const organizationKeys = {
getUserOrganization: ['organization'] as const getUserOrganization: ['organization'] as const
@@ -16,3 +16,16 @@ const fetchUserOrganization = async () => {
export const useGetOrganization = () => export const useGetOrganization = () =>
useQuery({ queryKey: organizationKeys.getUserOrganization, queryFn: fetchUserOrganization }); 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);
}
});
};
@@ -4,3 +4,8 @@ export type Organization = {
createAt: string; createAt: string;
updatedAt: string; updatedAt: string;
}; };
export type RenameOrgDTO = {
orgId: string;
newOrgName: string;
};
+4 -1
View File
@@ -1,14 +1,17 @@
export type { GetAuthTokenAPI } from './auth/types'; export type { GetAuthTokenAPI } from './auth/types';
export type { IncidentContact } from './incidentContacts/types';
export type { UserWsKeyPair } from './keys/types'; export type { UserWsKeyPair } from './keys/types';
export type { Organization } from './organization/types'; export type { Organization } from './organization/types';
export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types'; export type { CreateServiceTokenDTO, ServiceToken } from './serviceTokens/types';
export type { GetSubscriptionPlan, SubscriptionPlan } from './subscriptions/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 { export type {
CreateEnvironmentDTO, CreateEnvironmentDTO,
CreateWorkspaceDTO,
DeleteEnvironmentDTO, DeleteEnvironmentDTO,
DeleteWorkspaceDTO, DeleteWorkspaceDTO,
RenameWorkspaceDTO, RenameWorkspaceDTO,
ToggleAutoCapitalizationDTO,
UpdateEnvironmentDTO, UpdateEnvironmentDTO,
Workspace, Workspace,
WorkspaceEnv, WorkspaceEnv,
+4 -1
View File
@@ -1,7 +1,10 @@
export { export {
fetchOrgUsers, fetchOrgUsers,
useAddUserToOrg,
useAddUserToWs, useAddUserToWs,
useDeleteOrgMembership,
useGetOrgUsers, useGetOrgUsers,
useGetUser, useGetUser,
useLogoutUser useLogoutUser,
useUpdateOrgUserRole
} from './queries'; } from './queries';
+56 -3
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
decryptAssymmetric, decryptAssymmetric,
@@ -8,7 +8,15 @@ import { apiRequest } from '@app/config/request';
import { setAuthToken } from '@app/reactQuery'; import { setAuthToken } from '@app/reactQuery';
import { useUploadWsKey } from '../keys/queries'; import { useUploadWsKey } from '../keys/queries';
import { AddUserToWsDTO, AddUserToWsRes, OrgUser, User } from './types'; import {
AddUserToOrgDTO,
AddUserToWsDTO,
AddUserToWsRes,
DeletOrgMembershipDTO,
OrgUser,
UpdateOrgUserRoleDTO,
User
} from './types';
const userKeys = { const userKeys = {
getUser: ['user'] as const, getUser: ['user'] as const,
@@ -32,7 +40,11 @@ export const fetchOrgUsers = async (orgId: string) => {
}; };
export const useGetOrgUsers = (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 // mutation
export const useAddUserToWs = () => { 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 = () => export const useLogoutUser = () =>
useMutation({ useMutation({
mutationFn: () => apiRequest.post('/api/v1/auth/logout'), mutationFn: () => apiRequest.post('/api/v1/auth/logout'),
+17 -1
View File
@@ -32,7 +32,7 @@ export type OrgUser = {
inviteEmail: string; inviteEmail: string;
organization: string; organization: string;
role: 'owner' | 'admin' | 'member'; role: 'owner' | 'admin' | 'member';
status: 'invited' | 'accepted'; status: 'invited' | 'accepted' | 'verified' | 'completed';
deniedPermissions: any[]; deniedPermissions: any[];
}; };
@@ -45,3 +45,19 @@ export type AddUserToWsRes = {
invitee: OrgUser['user']; invitee: OrgUser['user'];
latestKey: UserWsKeyPair; 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;
};
+1 -1
View File
@@ -3,9 +3,9 @@ export {
useCreateWsEnvironment, useCreateWsEnvironment,
useDeleteWorkspace, useDeleteWorkspace,
useDeleteWsEnvironment, useDeleteWsEnvironment,
useGetUserWorkspaceMemberships,
useGetUserWorkspaces, useGetUserWorkspaces,
useGetWorkspaceById, useGetWorkspaceById,
useRenameWorkspace, useRenameWorkspace,
useToggleAutoCapitalization, useToggleAutoCapitalization,
useUpdateWsEnvironment} from './queries'; useUpdateWsEnvironment} from './queries';
+24 -5
View File
@@ -13,16 +13,18 @@ import {
Workspace Workspace
} from './types'; } from './types';
const workspaceKeys = { const workspaceKeys = {
getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const, getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const,
getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'],
getAllUserWorkspace: ['workspaces'] as const getAllUserWorkspace: ['workspaces'] as const
}; };
const fetchWorkspaceById = async (workspaceId: string) => { const fetchWorkspaceById = async (workspaceId: string) => {
const { data } = await apiRequest.get<{ workspace: Workspace }>(`/api/v1/workspace/${workspaceId}`); const { data } = await apiRequest.get<{ workspace: Workspace }>(
return data.workspace; `/api/v1/workspace/${workspaceId}`
} );
return data.workspace;
};
const fetchUserWorkspaces = async () => { const fetchUserWorkspaces = async () => {
const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace'); const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace');
@@ -40,6 +42,21 @@ export const useGetWorkspaceById = (workspaceId: string) => {
export const useGetUserWorkspaces = () => export const useGetUserWorkspaces = () =>
useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces); useQuery(workspaceKeys.getAllUserWorkspace, fetchUserWorkspaces);
const fetchUserWorkspaceMemberships = async (orgId: string) => {
const { data } = await apiRequest.get<Record<string, Workspace[]>>(
`/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 // mutation
export const useCreateWorkspace = () => { export const useCreateWorkspace = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -70,7 +87,9 @@ export const useToggleAutoCapitalization = () => {
return useMutation<{}, {}, ToggleAutoCapitalizationDTO>({ return useMutation<{}, {}, ToggleAutoCapitalizationDTO>({
mutationFn: ({ workspaceID, state }) => mutationFn: ({ workspaceID, state }) =>
apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, { autoCapitalization: state }), apiRequest.patch(`/api/v2/workspace/${workspaceID}/auto-capitalization`, {
autoCapitalization: state
}),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace);
} }
+4 -357
View File
@@ -1,374 +1,21 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { useEffect, useState } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next'; 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 { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps';
import { OrgSettingsPage } from '@app/views/Settings/OrgSettingsPage';
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';
export default function SettingsOrg() { 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<string[]>([]);
const [searchIncidentContact, setSearchIncidentContact] = useState('');
const [userList, setUserList] = useState<any[]>([]);
const [personalEmail, setPersonalEmail] = useState('');
const [email, setEmail] = useState('');
const [currentPlan, setCurrentPlan] = useState('');
const workspaceId = router.query.id as string;
const { t } = useTranslation(); 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 ( return (
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white"> <>
<Head> <Head>
<title>{t('common:head-title', { title: t('settings-org:title') })}</title> <title>{t('common:head-title', { title: t('settings-org:title') })}</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
</Head> </Head>
<div className="flex flex-row"> <OrgSettingsPage />
<div className="w-full max-h-screen pb-2"> </>
<NavHeader pageName={t('settings-org:title')} />
<AddIncidentContactDialog
isOpen={isAddIncidentContactOpen}
closeModal={closeAddIncidentContactModal}
incidentContacts={incidentContacts}
setIncidentContacts={setIncidentContacts}
/>
<div className="flex flex-row justify-between items-center ml-6 my-8 text-xl max-w-5xl">
<div className="flex flex-col justify-start items-start text-3xl">
<p className="font-semibold mr-4 text-gray-200">{t('settings-org:title')}</p>
<p className="font-normal mr-4 text-gray-400 text-base">
{t('settings-org:description')}
</p>
</div>
</div>
<div className="flex flex-col ml-6 text-mineshaft-50 mr-6 max-w-8xl">
<div className="flex flex-col">
<div className="min-w-md mt-2 flex flex-col items-end pb-4">
<div className="bg-white/5 rounded-md px-6 py-4 flex flex-col items-start w-full mb-6">
<div className="max-h-28 w-full max-w-md mr-auto">
<p className="font-semibold mr-4 text-gray-200 text-xl mb-2">
{t('common:display-name')}
</p>
<InputField
label=""
// label="Organization Name"
onChangeHandler={modifyOrgName}
type="varName"
value={orgName}
placeholder=""
isRequired
/>
</div>
<div className="flex justify-start w-full">
<div className="flex justify-start max-w-sm mt-4 mb-2">
<Button
text={t('common:save-changes') as string}
onButtonPressed={() => submitChanges(orgName)}
color="mineshaft"
size="md"
active={buttonReady}
iconDisabled={faCheck}
textDisabled={t('common:saved') as string}
/>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white/5 rounded-md px-6 pt-6 pb-2 flex flex-col items-start w-full mb-6">
<p className="font-semibold mr-4 text-white text-xl">
{t('section-members:org-members')}
</p>
<p className="mr-4 text-gray-400 mt-2 mb-2">
{t('section-members:org-members-description')}
</p>
<AddUserDialog
isOpen={isAddUserOpen && (userList.length < 5 || currentPlan !== plans.starter || host !== 'https://app.infisical.com')}
closeModal={closeAddUserModal}
submitModal={submitAddUserModal}
email={emailUser}
setEmail={setEmailUser}
orgName={orgName}
/>
<UpgradePlanModal
isOpen={isAddUserOpen && userList.length >= 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."
/>
{/* <DeleteUserDialog isOpen={isDeleteOpen} closeModal={closeDeleteModal} submitModal={deleteMembership} userIdToBeDeleted={userIdToBeDeleted}/> */}
<div className="pb-1 w-full flex flex-row items-start">
<div className="h-10 w-full bg-white/5 mt-2 flex items-center rounded-md flex-row ">
<FontAwesomeIcon
className="bg-white/5 rounded-l-md py-3 pl-4 pr-2 text-gray-400"
icon={faMagnifyingGlass}
/>
<input
className="pl-2 text-gray-400 rounded-r-md bg-white/5 w-full h-full outline-none"
value={searchUsers}
onChange={(e) => setSearchUsers(e.target.value)}
placeholder={t('section-members:search-members') as string}
/>
</div>
<div className="mt-2 ml-2 min-w-max flex flex-row items-start justify-start">
<Button
text={t('section-members:add-member') as string}
onButtonPressed={openAddUserModal}
color="mineshaft"
size="md"
icon={faPlus}
/>
</div>
</div>
{userList && (
<div className="overflow-y-auto w-full">
<UserTable
userData={userList}
changeData={setUserList}
myUser={personalEmail}
filter={searchUsers.toLowerCase()}
resendInvite={submitAddUserModal}
isOrg
// onClick={openDeleteModal}
// deleteUser={deleteMembership}
// setUserIdToBeDeleted={setUserIdToBeDeleted}
/>
</div>
)}
</div>
<div className="bg-white/5 rounded-md px-6 pt-6 pb-6 flex flex-col items-start w-full mb-6 mt-4">
<div className="flex flex-row max-w-5xl justify-between items-center w-full">
<div className="flex flex-col justify-between w-full max-w-3xl">
<p className="text-xl font-semibold mb-3 min-w-max">
{t('section-incident:incident-contacts')}
</p>
<p className="text-xs text-gray-500 mb-2 min-w-max">
{t('section-incident:incident-contacts-description')}
</p>
</div>
<div className="mt-4 mb-2 min-w-max flex flex-row items-end justify-center">
<Button
text={t('section-incident:add-contact') as string}
onButtonPressed={openAddIncidentContactModal}
color="mineshaft"
size="md"
icon={faPlus}
/>
</div>
</div>
<div className="h-12 w-full max-w-5xl bg-white/5 mt-2 flex items-center rounded-t-md flwex-row">
<FontAwesomeIcon
className="bg-white/5 rounded-tl-md py-4 pl-4 pr-2 text-gray-400"
icon={faMagnifyingGlass}
/>
<input
className="pl-2 text-gray-400 rounded-tr-md bg-white/5 w-full h-full outline-none"
value={searchIncidentContact}
onChange={(e) => setSearchIncidentContact(e.target.value)}
placeholder={t('common:search') as string}
/>
</div>
{incidentContacts?.filter((incidentEmail) =>
incidentEmail.includes(searchIncidentContact)
).length > 0 ? (
incidentContacts
.filter((incidentEmail) => incidentEmail.includes(searchIncidentContact))
.map((contact) => (
<div
key={guidGenerator()}
className="flex flex-row items-center justify-between max-w-5xl px-4 py-3 hover:bg-white/5 border-t border-gray-600 w-full"
>
<p className="text-gray-300">{contact}</p>
<div className="opacity-50 hover:opacity-100 duration-200">
<Button
onButtonPressed={() => deleteIncidentContactFully(contact)}
color="red"
size="icon-sm"
icon={faX}
/>
</div>
</div>
))
) : (
<div className="w-full flex flex-row justify-center mt-6 max-w-4xl ml-6">
<p className="text-gray-400">{t('section-incident:no-incident-contacts')}</p>
</div>
)}
</div>
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
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.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
</div>
</div>
</div>
); );
} }
@@ -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 (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<NavHeader pageName={t('settings-org:title')} />
<div className="my-8 ml-6 flex max-w-5xl flex-row items-center justify-between text-xl">
<div className="flex flex-col items-start justify-start text-3xl">
<p className="mr-4 font-semibold text-gray-200">{t('settings-org:title')}</p>
<p className="mr-4 text-base font-normal text-gray-400">
{t('settings-org:description')}
</p>
</div>
</div>
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
<OrgNameChangeSection orgName={currentOrg?.name} onOrgNameChange={onRenameOrg} />
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<p className="mr-4 text-xl font-semibold text-white">
{t('section-members:org-members')}
</p>
<p className="mr-4 mt-2 mb-2 text-gray-400">
{t('section-members:org-members-description')}
</p>
<OrgMembersTable
isMoreUserNotAllowed={isMoreUsersNotAllowed}
orgName={currentOrg?.name || ''}
members={orgUsers}
workspaceMemberships={workspaceMemberships}
onInviteMember={onAddUserToOrg}
userId={user?._id || ''}
onRemoveMember={onRemoveUserOrgMembership}
onRoleChange={onUpdateOrgUserRole}
onGrantAccess={onGrantUserAccess}
/>
</div>
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
<div className="flex w-full max-w-3xl flex-col justify-between">
<p className="mb-3 min-w-max text-xl font-semibold">
{t('section-incident:incident-contacts')}
</p>
<p className="mb-2 min-w-max text-xs text-gray-500">
{t('section-incident:incident-contacts-description')}
</p>
</div>
</div>
<div className="w-full">
<OrgIncidentContactsTable
contacts={incidentContact}
onRemoveContact={onRemoveIncidentContact}
onAddContact={onAddIncidentContact}
/>
</div>
</div>
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
<p className="text-xl font-bold text-red">
Danger Zone
</p>
<p className="mt-4 text-md text-gray-400">
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.
</p>
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
<InputField
label="Organization to be Deleted"
onChangeHandler={
setWorkspaceToBeDeletedName
}
type="varName"
value={workspaceToBeDeletedName}
placeholder=""
isRequired
/>
</div>
<button
type="button"
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
onClick={executeDeletingWorkspace}
>
Delete Project
</button>
<p className="mt-0.5 ml-1 text-xs text-gray-500">
Note: You can only delete a project in case you
have more than one.
</p>
</div> */}
</div>
</div>
);
};
@@ -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<void>;
onAddContact: (email: string) => Promise<void>;
};
const addContactFormSchema = yup.object({
email: yup.string().email().required().label('Email').trim()
});
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
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<TAddContactForm>({ 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 (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchContact}
onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..."
/>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen('addContact')}
>
Add Contact
</Button>
</div>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Email</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{contacts
?.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact))
?.map(({ email }) => (
<Tr key={email}>
<Td className="w-full">{email}</Td>
<Td className="mr-4">
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen('removeContact', { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</div>
<Modal
isOpen={popUp?.addContact?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle('addContact', isOpen);
reset();
}}
>
<ModalContent
title="Add an Incident Contact"
subTitle="This contact will be notified in the unlikely event of a severe incident."
>
<form onSubmit={handleSubmit(onAddIncidentContact)}>
<Controller
control={control}
defaultValue=""
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Incident Contact
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose('addContact')}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeContact.isOpen}
deleteKey="remove"
title="Do you want to remove this email from incident contact?"
onChange={(isOpen) => handlePopUpToggle('removeContact', isOpen)}
onDeleteApproved={onRemoveIncidentContact}
/>
</div>
);
};
@@ -0,0 +1 @@
export { OrgIncidentContactsTable } from './OrgIncidentContactsTable';
@@ -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<string, Workspace[]>;
orgName: string;
isMoreUserNotAllowed: boolean;
onRemoveMember: (userId: string) => Promise<void>;
onInviteMember: (email: string) => Promise<void>;
onRoleChange: (membershipId: string, role: string) => Promise<void>;
onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
// 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<typeof addMemberFormSchema>;
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<TAddMemberForm>({ 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 (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
if (isMoreUserNotAllowed) {
handlePopUpOpen('upgradePlan');
} else {
handlePopUpOpen('addMember');
}
}}
>
Add Member
</Button>
</div>
</div>
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Email</Th>
<Th>Role</Th>
<Th>Projects</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{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 (
<Tr key={`org-membership-${orgMembershipId}`}>
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
{status === 'accepted' && (
<Select
defaultValue={role}
isDisabled={userId === user?._id}
className="w-full bg-mineshaft-600"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(isIamOwner || role === 'owner') && (
<SelectItem value="owner">owner</SelectItem>
)}
<SelectItem value="admin">admin</SelectItem>
<SelectItem value="member">member</SelectItem>
</Select>
)}
{(status === 'invited' || status === 'verified') && (
<Button colorSchema="secondary" onClick={() => onInviteMember(email)}>
Resent Invite
</Button>
)}
{status === 'completed' && (
<Button
colorSchema="secondary"
onClick={() => onGrantAccess(user?._id, user?.publicKey)}
>
Grant Access
</Button>
)}
</Td>
<Td>
{userWs ? (
userWs?.map(({ name: wsName, _id }) => (
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
{wsName}
</Tag>
))
) : (
<Tag colorSchema="red">This user isn&apos;t part of any projects yet</Tag>
)}
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
isDisabled={userId === user?._id}
onClick={() => handlePopUpOpen('removeMember', { id: orgMembershipId })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
</div>
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle('addMember', isOpen);
reset();
}}
>
<ModalContent
title={`Invite others to ${orgName}`}
subTitle={
<>
An invite is specific to an email address and expires after 1 day.
<br />
For security reasons, you will need to separately add members to projects.
</>
}
>
<form onSubmit={handleSubmit(onAddMember)}>
<Controller
control={control}
defaultValue=""
name="email"
render={({ field, fieldState: { error } }) => (
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Add Member
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose('addMember')}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
title="Do you want to remove this user from the org?"
onChange={(isOpen) => handlePopUpToggle('removeMember', isOpen)}
onDeleteApproved={onRemoveOrgMemberApproved}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle('upgradePlan', isOpen)}
text="You can add custom environments if you switch to Infisical's Team plan."
/>
</div>
);
};
@@ -0,0 +1 @@
export { OrgMembersTable } from './OrgMembersTable';
@@ -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<void>;
};
const formSchema = yup.object({
name: yup.string().required().label('Project Name')
});
type FormData = yup.InferType<typeof formSchema>;
export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => {
const {
handleSubmit,
control,
reset,
formState: { isDirty, isSubmitting }
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
const { t } = useTranslation();
useEffect(() => {
reset({ name: orgName });
}, [orgName]);
const onFormSubmit = async ({ name }: FormData) => {
await onOrgNameChange(name);
};
return (
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-3">
<p className="mb-4 mt-2 text-xl font-semibold">{t('common:display-name')}</p>
<div className="mb-2 w-full max-w-lg">
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Input placeholder="Type your org name" {...field} />
</FormControl>
)}
control={control}
name="name"
/>
</div>
<Button
isLoading={isSubmitting}
color="mineshaft"
size="sm"
type="submit"
isDisabled={!isDirty || isSubmitting}
leftIcon={<FontAwesomeIcon icon={faCheck} />}
>
{t('common:save-changes')}
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { OrgNameChangeSection } from './OrgNameChangeSection';
@@ -0,0 +1,3 @@
export { OrgIncidentContactsTable } from './OrgIncidentContactsTable';
export { OrgMembersTable } from './OrgMembersTable';
export { OrgNameChangeSection } from './OrgNameChangeSection';
@@ -0,0 +1 @@
export { OrgSettingsPage } from './OrgSettingsPage';