{isExpanded && !isDragging && (
- |
+ |
@@ -146,19 +168,26 @@ export const SecretImportItem = ({
)}
- {importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())).map(({ key, value, overriden }, index) => (
-
- |
- {key}
- |
-
-
- |
-
-
- |
-
- ))}
+ {importedSecrets
+ .filter((secret) =>
+ secret.key.toUpperCase().includes(searchTerm.toUpperCase())
+ )
+ .map(({ key, value, overriden }, index) => (
+
+ |
+ {key}
+ |
+
+
+ |
+
+
+ |
+
+ ))}
diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx
index 9d796d079..db12e84a4 100644
--- a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx
+++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx
@@ -59,13 +59,23 @@ export const computeImportedSecretRows = (
type Props = {
secrets?: DecryptedSecret[];
importedSecrets?: TImportedSecrets;
+ environment: string;
+ secretPath: string;
onSecretImportDelete: (env: string, secPath: string) => void;
items: { id: string; environment: string; secretPath: string }[];
searchTerm: string;
};
export const SecretImportSection = memo(
- ({ secrets = [], importedSecrets = [], onSecretImportDelete, items = [], searchTerm = "" }: Props) => {
+ ({
+ secrets = [],
+ environment,
+ secretPath,
+ importedSecrets = [],
+ onSecretImportDelete,
+ items = [],
+ searchTerm = ""
+ }: Props) => {
const { currentWorkspace } = useWorkspace();
const environments = currentWorkspace?.environments || [];
@@ -82,6 +92,8 @@ export const SecretImportSection = memo(
secrets,
environments
)}
+ secretPath={secretPath}
+ environment={environment}
onDelete={onSecretImportDelete}
importedSecPath={impSecPath}
searchTerm={searchTerm}
diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx
index 6c089d695..c31c6df2e 100644
--- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx
+++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx
@@ -8,6 +8,7 @@ import {
UseFormSetValue,
useWatch
} from "react-hook-form";
+import { subject } from "@casl/ability";
import {
faCheck,
faCodeBranch,
@@ -22,26 +23,34 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "cva";
import { twMerge } from "tailwind-merge";
+// TODO:(akhilmhdh): Refactor this
+import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent";
+import { ProjectPermissionCan } from "@app/components/permissions";
import {
+ FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
+ PopoverContent,
PopoverTrigger,
SecretInput,
Tag,
+ TextArea,
Tooltip
} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
import { WsTag } from "@app/hooks/api/types";
-import AddTagPopoverContent from "../../../../components/AddTagPopoverContent/AddTagPopoverContent";
import { FormData, SecretActionType } from "../../DashboardPage.utils";
type Props = {
index: number;
+ environment: string;
+ secretPath: string;
// backend generated unique id
secUniqId?: string;
// permission and external state's that decided to hide or show
@@ -69,6 +78,8 @@ type Props = {
export const SecretInputRow = memo(
({
index,
+ secretPath,
+ environment,
isSecretValueHidden,
onRowExpand,
isReadOnly,
@@ -79,7 +90,7 @@ export const SecretInputRow = memo(
onSecretDelete,
searchTerm,
control,
- // register,
+ register,
setValue,
isKeyError,
keyError,
@@ -217,7 +228,6 @@ export const SecretInputRow = memo(
{index + 1}
|
-
-
-
-
-
-
+
+ {(isAllowed) => (
+
+
+
+ )}
+
{!isAddOnly && (
-
-
-
-
-
-
-
+
+ {(isAllowed) => (
+
+
+
+
+
+ )}
+
)}
-
-
-
-
-
+
+
+
+
-
-
-
- onSelectTag(wsTag)}
- handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
- handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
- checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
- handleOnCreateTagOpen={() => onCreateTagOpen()}
- />
-
-
-
+ {(isAllowed) => (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
@@ -453,20 +492,27 @@ export const SecretInputRow = memo(
)}
-
- {
- onSecretDelete(index, secKey, secId, idOverride);
- }}
- >
-
-
-
+
+ {(isAllowed) => (
+ {
+ onSecretDelete(index, secKey, secId, idOverride);
+ }}
+ >
+
+
+ )}
+
diff --git a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx
index 054ce34a1..e8e4e5b2c 100644
--- a/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx
+++ b/frontend/src/views/IntegrationsPage/IntegrationsPage.tsx
@@ -4,7 +4,8 @@ import { useRouter } from "next/router";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { Button, Modal, ModalContent } from "@app/components/v2";
-import { useWorkspace } from "@app/context";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import {
useDeleteIntegration,
@@ -31,191 +32,194 @@ type Props = {
frameworkIntegrations: Array<{ name: string; slug: string; image: string; docsLink: string }>;
};
-export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
- const { t } = useTranslation();
- const { createNotification } = useNotificationContext();
- const router = useRouter();
+export const IntegrationsPage = withProjectPermission(
+ ({ frameworkIntegrations }: Props) => {
+ const { t } = useTranslation();
+ const { createNotification } = useNotificationContext();
+ const router = useRouter();
- const { currentWorkspace } = useWorkspace();
- const workspaceId = currentWorkspace?._id || "";
- const environments = currentWorkspace?.environments || [];
+ const { currentWorkspace } = useWorkspace();
+ const workspaceId = currentWorkspace?._id || "";
+ const environments = currentWorkspace?.environments || [];
- const { data: latestWsKey } = useGetUserWsKey(workspaceId);
+ const { data: latestWsKey } = useGetUserWsKey(workspaceId);
- const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
- "activeBot"
- ] as const);
+ const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
+ "activeBot"
+ ] as const);
- const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
- useGetCloudIntegrations();
+ const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
+ useGetCloudIntegrations();
- const { data: integrationAuths, isLoading: isIntegrationAuthLoading } =
- useGetWorkspaceAuthorizations(
- workspaceId,
- useCallback((data: IntegrationAuth[]) => {
- const groupBy: Record = {};
- data.forEach((el) => {
- groupBy[el.integration] = el;
- });
- return groupBy;
- }, [])
- );
- // mutation
- const {
- data: integrations,
- isLoading: isIntegrationLoading,
- isFetching: isIntegrationFetching
- } = useGetWorkspaceIntegrations(workspaceId);
+ const { data: integrationAuths, isLoading: isIntegrationAuthLoading } =
+ useGetWorkspaceAuthorizations(
+ workspaceId,
+ useCallback((data: IntegrationAuth[]) => {
+ const groupBy: Record = {};
+ data.forEach((el) => {
+ groupBy[el.integration] = el;
+ });
+ return groupBy;
+ }, [])
+ );
+ // mutation
+ const {
+ data: integrations,
+ isLoading: isIntegrationLoading,
+ isFetching: isIntegrationFetching
+ } = useGetWorkspaceIntegrations(workspaceId);
- const { data: bot } = useGetWorkspaceBot(workspaceId);
+ const { data: bot } = useGetWorkspaceBot(workspaceId);
- // mutation
- const { mutateAsync: updateBotActiveStatus, mutate: updateBotActiveStatusSync } =
- useUpdateBotActiveStatus();
- const { mutateAsync: deleteIntegration } = useDeleteIntegration();
- const {
- mutateAsync: deleteIntegrationAuth,
- isLoading: isDeleteIntegrationAuthSuccess,
- reset: resetDeleteIntegrationAuth
- } = useDeleteIntegrationAuth();
+ // mutation
+ const { mutateAsync: updateBotActiveStatus, mutate: updateBotActiveStatusSync } =
+ useUpdateBotActiveStatus();
+ const { mutateAsync: deleteIntegration } = useDeleteIntegration();
+ const {
+ mutateAsync: deleteIntegrationAuth,
+ isLoading: isDeleteIntegrationAuthSuccess,
+ reset: resetDeleteIntegrationAuth
+ } = useDeleteIntegrationAuth();
- // summary: this use effect is trigger when all integration auths are removed thus deactivate bot
- // details: so onsuccessfully deleting an integration auth, immediately integration list is refeteched
- // After the refetch is completed check if its empty. Then set bot active and reset the submit hook
- useEffect(() => {
- if (isDeleteIntegrationAuthSuccess && !isIntegrationFetching && !integrations?.length) {
- if (bot?._id)
- updateBotActiveStatusSync({
- isActive: false,
- botId: bot._id,
- workspaceId
- });
- resetDeleteIntegrationAuth();
- }
- }, [isIntegrationFetching, isDeleteIntegrationAuthSuccess, integrations?.length]);
-
- const handleProviderIntegration = async (provider: string) => {
- const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
- if (!selectedCloudIntegration) return;
-
- try {
- if (bot && !bot.isActive) {
- const botKey = generateBotKey(bot.publicKey, latestWsKey!);
- await updateBotActiveStatus({
- workspaceId,
- botKey,
- isActive: true,
- botId: bot._id
- });
+ // summary: this use effect is trigger when all integration auths are removed thus deactivate bot
+ // details: so onsuccessfully deleting an integration auth, immediately integration list is refeteched
+ // After the refetch is completed check if its empty. Then set bot active and reset the submit hook
+ useEffect(() => {
+ if (isDeleteIntegrationAuthSuccess && !isIntegrationFetching && !integrations?.length) {
+ if (bot?._id)
+ updateBotActiveStatusSync({
+ isActive: false,
+ botId: bot._id,
+ workspaceId
+ });
+ resetDeleteIntegrationAuth();
}
- const integrationAuthForProvider = integrationAuths?.[provider];
- if (!integrationAuthForProvider) {
- redirectForProviderAuth(selectedCloudIntegration);
+ }, [isIntegrationFetching, isDeleteIntegrationAuthSuccess, integrations?.length]);
+
+ const handleProviderIntegration = async (provider: string) => {
+ const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
+ if (!selectedCloudIntegration) return;
+
+ try {
+ if (bot && !bot.isActive) {
+ const botKey = generateBotKey(bot.publicKey, latestWsKey!);
+ await updateBotActiveStatus({
+ workspaceId,
+ botKey,
+ isActive: true,
+ botId: bot._id
+ });
+ }
+ const integrationAuthForProvider = integrationAuths?.[provider];
+ if (!integrationAuthForProvider) {
+ redirectForProviderAuth(selectedCloudIntegration);
+ return;
+ }
+
+ const url = redirectToIntegrationAppConfigScreen(provider, integrationAuthForProvider._id);
+ router.push(url);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ // function to strat integration for a provider
+ // confirmation to user passing the bot key for provider to get secret access
+ const handleProviderIntegrationStart = (provider: string) => {
+ if (!bot?.isActive) {
+ handlePopUpOpen("activeBot", { provider });
return;
}
+ handleProviderIntegration(provider);
+ };
- const url = redirectToIntegrationAppConfigScreen(provider, integrationAuthForProvider._id);
- router.push(url);
- } catch (error) {
- console.error(error);
- }
- };
+ const handleUserAcceptBotCondition = () => {
+ const { provider } = popUp.activeBot?.data as { provider: string };
+ handleProviderIntegration(provider);
+ handlePopUpClose("activeBot");
+ };
- // function to strat integration for a provider
- // confirmation to user passing the bot key for provider to get secret access
- const handleProviderIntegrationStart = (provider: string) => {
- if (!bot?.isActive) {
- handlePopUpOpen("activeBot", { provider });
- return;
- }
- handleProviderIntegration(provider);
- };
+ const handleIntegrationDelete = async (integrationId: string, cb: () => void) => {
+ try {
+ await deleteIntegration({ id: integrationId, workspaceId });
+ if (cb) cb();
+ createNotification({
+ type: "success",
+ text: "Deleted integration"
+ });
+ } catch (err) {
+ console.log(err);
+ createNotification({
+ type: "error",
+ text: "Failed to delete integration"
+ });
+ }
+ };
- const handleUserAcceptBotCondition = () => {
- const { provider } = popUp.activeBot?.data as { provider: string };
- handleProviderIntegration(provider);
- handlePopUpClose("activeBot");
- };
+ const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => {
+ const integrationAuthForProvider = integrationAuths?.[provider];
+ if (!integrationAuthForProvider) return;
+ try {
+ await deleteIntegrationAuth({
+ id: integrationAuthForProvider._id,
+ workspaceId
+ });
+ if (cb) cb();
+ createNotification({
+ type: "success",
+ text: "Revoked provider authentication"
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ type: "error",
+ text: "Failed to revoke provider authentication"
+ });
+ }
+ };
- const handleIntegrationDelete = async (integrationId: string, cb: () => void) => {
- try {
- await deleteIntegration({ id: integrationId, workspaceId });
- if (cb) cb();
- createNotification({
- type: "success",
- text: "Deleted integration"
- });
- } catch (err) {
- console.log(err);
- createNotification({
- type: "error",
- text: "Failed to delete integration"
- });
- }
- };
-
- const handleIntegrationAuthRevoke = async (provider: string, cb?: () => void) => {
- const integrationAuthForProvider = integrationAuths?.[provider];
- if (!integrationAuthForProvider) return;
- try {
- await deleteIntegrationAuth({
- id: integrationAuthForProvider._id,
- workspaceId
- });
- if (cb) cb();
- createNotification({
- type: "success",
- text: "Revoked provider authentication"
- });
- } catch (err) {
- console.error(err);
- createNotification({
- type: "error",
- text: "Failed to revoke provider authentication"
- });
- }
- };
-
- return (
-
- handleIntegrationDelete(id, cb)}
- />
-
- handlePopUpToggle("activeBot", isOpen)}
- >
-
-
-
-
- }
+ return (
+
+ handleIntegrationDelete(id, cb)}
+ />
+
+ handlePopUpToggle("activeBot", isOpen)}
>
- {t("integrations.why-infisical-needs-access")}
-
-
-
-
- );
-};
+
+
+
+
+ }
+ >
+ {t("integrations.why-infisical-needs-access")}
+
+
+
+
+ );
+ },
+ { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
+);
diff --git a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx
index 7f458d5e5..b0abc2603 100644
--- a/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx
+++ b/frontend/src/views/IntegrationsPage/components/CloudIntegrationSection/CloudIntegrationSection.tsx
@@ -2,7 +2,9 @@ import { useTranslation } from "react-i18next";
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { DeleteActionModal,Skeleton, Tooltip } from "@app/components/v2";
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks";
import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types";
@@ -28,11 +30,13 @@ export const CloudIntegrationSection = ({
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation"
] as const);
+ const permission = useProjectPermission();
+ const { createNotification } = useNotificationContext();
const isEmpty = !isLoading && !cloudIntegrations?.length;
const sortedCloudIntegrations = cloudIntegrations.sort((a, b) => a.name.localeCompare(b.name));
-
+
return (
@@ -57,6 +61,18 @@ export const CloudIntegrationSection = ({
} flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
onClick={() => {
if (!cloudIntegration.isAvailable) return;
+ if (
+ permission.cannot(
+ ProjectPermissionActions.Create,
+ ProjectPermissionSub.Integrations
+ )
+ ) {
+ createNotification({
+ type: "error",
+ text: "You do not have permission to create an integration"
+ });
+ return;
+ }
onIntegrationStart(cloudIntegration.slug);
}}
key={cloudIntegration.slug}
diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx
index 72533796d..701766a9e 100644
--- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx
+++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx
@@ -2,6 +2,7 @@ import { faArrowRight, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
+import { ProjectPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
EmptyState,
@@ -13,6 +14,7 @@ import {
Skeleton,
Tooltip
} from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { TIntegration } from "@app/hooks/api/types";
@@ -125,18 +127,26 @@ export const IntegrationsSection = ({
)}
-
-
- handlePopUpOpen("deleteConfirmation", integration)}
- ariaLabel="delete"
- colorSchema="danger"
- variant="star"
- >
-
-
-
-
+
+ {(isAllowed) => (
+
+
+ handlePopUpOpen("deleteConfirmation", integration)}
+ ariaLabel="delete"
+ isDisabled={!isAllowed}
+ colorSchema="danger"
+ variant="star"
+ >
+
+
+
+
+ )}
+
))}
diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx
index 0d6b8e7f2..f05b44f4b 100644
--- a/frontend/src/views/Org/MembersPage/MembersPage.tsx
+++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx
@@ -1,238 +1,67 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
-import { useState } from "react";
import { useTranslation } from "react-i18next";
-import { useRouter } from "next/router";
+import { motion } from "framer-motion";
-import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
-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 { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
+import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
+import { withPermission } from "@app/hoc";
+import { useGetRoles } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
-import {
- OrgIncidentContactsTable,
- OrgMembersTable,
- OrgNameChangeSection,
- OrgServiceAccountsTable
-} from "./components";
+import { OrgMembersTable } from "./components/OrgMembersTable";
+import { OrgRoleTabSection } from "./components/OrgRoleTabSection";
-export const MembersPage = () => {
- const host = window.location.origin;
- const router = useRouter();
- const { action } = router.query;
+enum TabSections {
+ Member = "members",
+ Roles = "roles"
+}
- const { t } = useTranslation();
- const { currentOrg } = useOrganization();
- const { currentWorkspace } = useWorkspace();
- const { user } = useUser();
- const { subscription } = useSubscription();
- const { createNotification } = useNotificationContext();
+export const MembersPage = withPermission(
+ () => {
+ const { t } = useTranslation();
+ const { currentOrg } = useOrganization();
- const orgId = currentOrg?._id || "";
+ const orgId = currentOrg?._id || "";
- const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
- const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
- useGetUserWorkspaceMemberships(orgId);
- const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
- const { data: incidentContact, isLoading: IsIncidentContactLoading } =
- useGetOrgIncidentContact(orgId);
+ const { data: roles, isLoading: isRolesLoading } = useGetRoles({
+ orgId
+ });
- const renameOrg = useRenameOrg();
- const removeUserOrgMembership = useDeleteOrgMembership();
- const addUserToOrg = useAddUserToOrg();
- const updateOrgUserRole = useUpdateOrgUserRole();
- const uploadWsKey = useUploadWsKey();
- const addIncidentContact = useAddIncidentContact();
- const removeIncidentContact = useDeleteIncidentContact();
-
- const [completeInviteLink, setcompleteInviteLink] = useState("");
-
- const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
-
- 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 user from org",
- type: "success"
- });
- } catch (error) {
- console.error(error);
- createNotification({
- text: "Failed to remove user from the organization",
- type: "error"
- });
- }
- };
- const onAddUserToOrg = async (email: string) => {
- if (!currentOrg?._id) return;
-
- try {
- const { data } = await addUserToOrg.mutateAsync({
- organizationId: currentOrg?._id,
- inviteeEmail: email
- });
- setcompleteInviteLink(data?.completeInviteLink);
-
- // only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
- if (!data.completeInviteLink) {
- createNotification({
- text: "Successfully invited user to the organization.",
- 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"
- });
- }
- };
-
- return (
-
-
-
- {t("section.members.org-members")}
-
-
+ return (
+
+
+
+ {t("section.members.org-members")}
+
+
+
+ Members
+ Roles
+
+
+
+ []}
+ isRolesLoading={isRolesLoading}
+ />
+
+
+
+ []}
+ isRolesLoading={isRolesLoading}
+ />
+
+
+
-
- );
-};
+ );
+ },
+ { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member }
+);
diff --git a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx
deleted file mode 100644
index cfafae765..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-import { useState } from "react";
-import { Controller, useForm } from "react-hook-form";
-import {
- faContactBook,
- 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,
- EmailServiceSetupModal,
- EmptyState,
- FormControl,
- IconButton,
- Input,
- Modal,
- ModalContent,
- Table,
- TableContainer,
- TableSkeleton,
- TBody,
- Td,
- Th,
- THead,
- Tr
-} from "@app/components/v2";
-import { usePopUp } from "@app/hooks";
-import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
-import { IncidentContact } from "@app/hooks/api/types";
-
-type Props = {
- isLoading?: boolean;
- 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,
- isLoading
-}: Props) => {
- const [searchContact, setSearchContact] = useState("");
- const { data: serverDetails } = useFetchServerStatus();
- const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
- "addContact",
- "removeContact",
- "setUpEmail"
- ] 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");
- };
-
- const filteredContacts = contacts.filter(({ email }) =>
- email.toLocaleLowerCase().includes(searchContact)
- );
-
- return (
-
-
-
- setSearchContact(e.target.value)}
- leftIcon={}
- placeholder="Search incident contact by email..."
- />
-
-
- }
- onClick={() => {
- if (serverDetails?.emailConfigured) {
- handlePopUpOpen("addContact");
- } else {
- handlePopUpOpen("setUpEmail");
- }
- }}
- >
- Add Contact
-
-
-
-
-
-
-
-
- | Email |
- |
-
-
-
- {isLoading && }
- {filteredContacts?.map(({ email }) => (
-
- | {email} |
-
- handlePopUpOpen("removeContact", { email })}
- >
-
-
- |
-
- ))}
-
-
- {filteredContacts?.length === 0 && !isLoading && (
-
- )}
-
-
- {
- handlePopUpToggle("addContact", isOpen);
- reset();
- }}
- >
-
-
-
-
- handlePopUpToggle("removeContact", isOpen)}
- onDeleteApproved={onRemoveIncidentContact}
- />
- handlePopUpToggle("setUpEmail", isOpen)}
- />
-
- );
-};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx
deleted file mode 100644
index b1df7cd68..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx
index 2955f5b51..c08a1cf1e 100644
--- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx
+++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx
@@ -1,4 +1,4 @@
-import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router";
import {
@@ -14,6 +14,11 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { OrgPermissionCan } from "@app/components/permissions";
+import {
+ decryptAssymmetric,
+ encryptAssymmetric
+} from "@app/components/utilities/cryptography/crypto";
import {
Button,
DeleteActionModal,
@@ -37,26 +42,31 @@ import {
Tr,
UpgradePlanModal
} from "@app/components/v2";
-import { useOrganization, useWorkspace } from "@app/context";
+import {
+ OrgPermissionActions,
+ OrgPermissionSubjects,
+ useOrganization,
+ useSubscription,
+ useUser,
+ useWorkspace
+} from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
-import { useGetSSOConfig } from "@app/hooks/api";
+import {
+ useAddUserToOrg,
+ useDeleteOrgMembership,
+ useGetOrgUsers,
+ useGetSSOConfig,
+ useGetUserWorkspaceMemberships,
+ useGetUserWsKey,
+ useUpdateOrgUserRole,
+ useUploadWsKey
+} from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
-import { OrgUser, Workspace } from "@app/hooks/api/types";
type Props = {
- members?: OrgUser[];
- workspaceMemberships?: Record;
- orgName: string;
- isLoading?: boolean;
- 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;
- completeInviteLink: string | undefined;
- setCompleteInviteLink: Dispatch>;
+ roles?: TRole[];
+ isRolesLoading?: boolean;
};
const addMemberFormSchema = yup.object({
@@ -65,27 +75,21 @@ const addMemberFormSchema = yup.object({
type TAddMemberForm = yup.InferType;
-export const OrgMembersTable = ({
- members = [],
- workspaceMemberships = {},
- orgName,
- isMoreUserNotAllowed,
- onRemoveMember,
- onInviteMember,
- onGrantAccess,
- onRoleChange,
- userId,
- isLoading,
- completeInviteLink,
- setCompleteInviteLink
-}: Props) => {
+export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => {
const router = useRouter();
const { createNotification } = useNotificationContext();
+
const { currentOrg } = useOrganization();
- const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? "");
+ const { workspaces, currentWorkspace } = useWorkspace();
+ const { user } = useUser();
+ const userId = user?._id || "";
+ const orgId = currentOrg?._id || "";
+ const workspaceId = currentWorkspace?._id || "";
+
+ const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus();
- const { workspaces } = useWorkspace();
+
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
@@ -93,6 +97,23 @@ export const OrgMembersTable = ({
"upgradePlan",
"setUpEmail"
] as const);
+ const { subscription } = useSubscription();
+
+ const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId);
+ const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
+ useGetUserWorkspaceMemberships(orgId);
+ const { data: wsKey } = useGetUserWsKey(workspaceId);
+
+ const removeUserOrgMembership = useDeleteOrgMembership();
+ const addUserToOrg = useAddUserToOrg();
+ const updateOrgUserRole = useUpdateOrgUserRole();
+ const uploadWsKey = useUploadWsKey();
+
+ const [completeInviteLink, setCompleteInviteLink] = useState("");
+
+ const isMoreUsersNotAllowed = subscription?.memberLimit
+ ? subscription.membersUsed >= subscription.memberLimit
+ : false;
useEffect(() => {
if (router.query.action === "invite") {
@@ -108,32 +129,100 @@ export const OrgMembersTable = ({
} = useForm({ resolver: yupResolver(addMemberFormSchema) });
const onAddMember = async ({ email }: TAddMemberForm) => {
- await onInviteMember(email);
+ if (!currentOrg?._id) return;
+
+ try {
+ const { data } = await addUserToOrg.mutateAsync({
+ organizationId: currentOrg?._id,
+ inviteeEmail: email
+ });
+ setCompleteInviteLink(data?.completeInviteLink);
+ // only show this notification when email is configured.
+ // A [completeInviteLink] will not be sent if smtp is configured
+ if (!data.completeInviteLink) {
+ createNotification({
+ text: "Successfully invited user to the organization.",
+ type: "success"
+ });
+ }
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: "Failed to invite user to org",
+ type: "error"
+ });
+ }
if (serverDetails?.emailConfigured) {
handlePopUpClose("addMember");
}
-
reset();
};
+ const onAddUserToOrg = async (email: string) => {
+ if (!currentOrg?._id) return;
+
+ try {
+ const { data } = await addUserToOrg.mutateAsync({
+ organizationId: currentOrg?._id,
+ inviteeEmail: email
+ });
+ setCompleteInviteLink(data?.completeInviteLink);
+
+ // only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
+ if (!data.completeInviteLink) {
+ createNotification({
+ text: "Successfully invited user to the organization.",
+ type: "success"
+ });
+ }
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: "Failed to invite user to org",
+ type: "error"
+ });
+ }
+ };
+
const onRemoveOrgMemberApproved = async () => {
- const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id;
- await onRemoveMember(orgMembershipId);
+ const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
+ if (!currentOrg?._id) return;
+
+ try {
+ await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
+ createNotification({
+ text: "Successfully removed user from org",
+ type: "success"
+ });
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: "Failed to remove user from the organization",
+ type: "error"
+ });
+ }
handlePopUpClose("removeMember");
};
const isIamOwner = useMemo(
- () => members.find(({ user }) => userId === user?._id)?.role === "owner",
+ () => members?.find(({ user: u }) => userId === u?._id)?.role === "owner",
[userId, members]
);
+ const findRoleFromId = useCallback(
+ (roleId: string) => {
+ return roles.find(({ _id: id }) => id === roleId);
+ },
+ [roles]
+ );
+
const filterdUser = useMemo(
() =>
- members.filter(
- ({ user, inviteEmail }) =>
- user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
- user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
- user?.email?.toLowerCase().includes(searchMemberFilter) ||
+ members?.filter(
+ ({ user: u, inviteEmail }) =>
+ u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
+ u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
+ u?.email?.toLowerCase().includes(searchMemberFilter) ||
inviteEmail?.includes(searchMemberFilter)
),
[members, searchMemberFilter]
@@ -147,11 +236,65 @@ export const OrgMembersTable = ({
return () => clearTimeout(timer);
}, [isInviteLinkCopied]);
+ const onRoleChange = 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 onGrantAccess = async (grantedUserId: 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: grantedUserId,
+ nonce,
+ encryptedKey: ciphertext,
+ workspaceId: currentWorkspace?._id || ""
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to grant access to user",
+ type: "error"
+ });
+ }
+ };
+
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(completeInviteLink as string);
setInviteLinkCopied.on();
};
+ const isLoading = isMembersLoading || IsWsMembershipLoading || isRolesLoading;
+
return (
@@ -163,27 +306,32 @@ export const OrgMembersTable = ({
placeholder="Search members..."
/>
- }
- onClick={() => {
- if (!isLoadingSSOConfig && ssoConfig && ssoConfig.isActive) {
- createNotification({
- text: "You cannot invite users when SAML SSO is configured for your organization",
- type: "error"
- });
+
+ {(isAllowed) => (
+ }
+ onClick={() => {
+ if (!isLoadingSSOConfig && ssoConfig && ssoConfig.isActive) {
+ createNotification({
+ text: "You cannot invite users when SAML SSO is configured for your organization",
+ type: "error"
+ });
- return;
- }
+ return;
+ }
- if (isMoreUserNotAllowed) {
- handlePopUpOpen("upgradePlan");
- } else {
- handlePopUpOpen("addMember");
- }
- }}
- >
- Add Member
-
+ if (isMoreUsersNotAllowed) {
+ handlePopUpOpen("upgradePlan");
+ } else {
+ handlePopUpOpen("addMember");
+ }
+ }}
+ >
+ Add Member
+
+ )}
+
@@ -200,106 +348,134 @@ export const OrgMembersTable = ({
{isLoading && }
{!isLoading &&
- 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];
+ filterdUser?.map(
+ ({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => {
+ const name = u ? `${u.firstName} ${u.lastName}` : "-";
+ const email = u?.email || inviteEmail;
+ const userWs = workspaceMemberships?.[u?._id];
- return (
-
- | {name} |
- {email} |
-
- {status === "accepted" && (
- |
+
+ {userId !== u?._id && (
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("removeMember", { id: orgMembershipId })
+ }
+ >
+
+
+ )}
+
+ )}
+ |
+
+ );
+ }
+ )}
{!isLoading && filterdUser?.length === 0 && (
@@ -315,7 +491,7 @@ export const OrgMembersTable = ({
}}
>
{!completeInviteLink && (
diff --git a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx
deleted file mode 100644
index 525b52471..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-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 (
-
- );
-};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx
deleted file mode 100644
index 4d86fcddb..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { OrgNameChangeSection } from "./OrgNameChangeSection";
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx
new file mode 100644
index 000000000..aa90c2fbf
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx
@@ -0,0 +1,255 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import {
+ faArrowLeft,
+ faCog,
+ faContactCard,
+ faMagnifyingGlass,
+ faMoneyBill,
+ faSignIn,
+ faUserCog,
+ faUsers
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2";
+import { useOrganization, useSubscription } from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useCreateRole, useUpdateRole } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+import {
+ formRolePermission2API,
+ formSchema,
+ rolePermission2Form,
+ TFormSchema
+} from "./OrgRoleModifySection.utils";
+import { SimpleLevelPermissionOption } from "./SimpleLevelPermissionOptions";
+import { WorkspacePermission } from "./WorkspacePermission";
+
+type Props = {
+ role?: TRole;
+ onGoBack: VoidFunction;
+};
+
+const SIMPLE_PERMISSION_OPTIONS = [
+ {
+ title: "Members management",
+ subtitle: "Invite, view and remove members from the organization",
+ icon: faUsers,
+ formName: "member"
+ },
+ {
+ title: "Billing & usage",
+ subtitle: "Modify organization subscription plan",
+ icon: faMoneyBill,
+ formName: "billing"
+ },
+ {
+ title: "Role management",
+ subtitle: "Create, modify and remove organization roles",
+ icon: faUserCog,
+ formName: "role"
+ },
+ {
+ title: "Incident Contacts",
+ subtitle: "Incident contacts management control",
+ icon: faContactCard,
+ formName: "incident-contact"
+ },
+ {
+ title: "Organization profile",
+ subtitle: "View & update organization metadata such as name",
+ icon: faCog,
+ formName: "settings"
+ },
+ {
+ title: "Secret Scanning",
+ subtitle: "Secret scanning management control",
+ icon: faMagnifyingGlass,
+ formName: "secret-scanning"
+ },
+ {
+ title: "SSO",
+ subtitle: "Define organization level SSO requirements",
+ icon: faSignIn,
+ formName: "sso"
+ }
+] as const;
+
+export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
+ const [searchPermission, setSearchPermission] = useState("");
+ const { subscription } = useSubscription();
+ const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["upgradePlan"] as const);
+
+ const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || "");
+ const isNewRole = !role?.slug;
+
+ const { createNotification } = useNotificationContext();
+ const { currentOrg } = useOrganization();
+ const orgId = currentOrg?._id || "";
+ const {
+ handleSubmit,
+ register,
+ formState: { isSubmitting, isDirty, errors },
+ setValue,
+ control
+ } = useForm({
+ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
+ resolver: zodResolver(formSchema)
+ });
+
+ const { mutateAsync: createRole } = useCreateRole();
+ const { mutateAsync: updateRole } = useUpdateRole();
+
+ const handleRoleUpdate = async (el: TFormSchema) => {
+ if (!role?._id) return;
+
+ try {
+ await updateRole({
+ orgId,
+ id: role?._id,
+ ...el,
+ permissions: formRolePermission2API(el.permissions)
+ });
+ createNotification({ type: "success", text: "Successfully updated role" });
+ onGoBack();
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to update role" });
+ }
+ };
+
+ const handleFormSubmit = async (el: TFormSchema) => {
+ if (subscription && !subscription?.rbac) {
+ handlePopUpOpen("upgradePlan");
+ return;
+ }
+
+ if (!isNewRole) {
+ await handleRoleUpdate(el);
+ return;
+ }
+
+ try {
+ await createRole({
+ orgId,
+ ...el,
+ permissions: formRolePermission2API(el.permissions)
+ });
+ createNotification({ type: "success", text: "Created new role" });
+ onGoBack();
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to create role" });
+ }
+ };
+
+ return (
+
+
+ {subscription && (
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text={
+ subscription.slug === null
+ ? "You can use RBAC under an Enterprise license"
+ : "You can use RBAC if you switch to Infisical's Team Plan."
+ }
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts
new file mode 100644
index 000000000..92353d653
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts
@@ -0,0 +1,66 @@
+/* eslint-disable no-param-reassign */
+import { z } from "zod";
+
+import { TPermission } from "@app/hooks/api/roles/types";
+
+const generalPermissionSchema = z
+ .object({
+ read: z.boolean().optional(),
+ edit: z.boolean().optional(),
+ delete: z.boolean().optional(),
+ create: z.boolean().optional()
+ })
+ .optional();
+
+export const formSchema = z.object({
+ name: z.string().trim(),
+ description: z.string().trim().optional(),
+ slug: z.string().trim(),
+ permissions: z
+ .object({
+ workspace: z
+ .object({
+ read: z.boolean().optional(),
+ create: z.boolean().optional()
+ })
+ .optional(),
+ member: generalPermissionSchema,
+ role: generalPermissionSchema,
+ settings: generalPermissionSchema,
+ "service-account": generalPermissionSchema,
+ "incident-contact": generalPermissionSchema,
+ "secret-scanning": generalPermissionSchema,
+ sso: generalPermissionSchema,
+ billing: generalPermissionSchema
+ })
+ .optional()
+});
+
+export type TFormSchema = z.infer;
+
+// convert role permission to form compatiable data structure
+export const rolePermission2Form = (permissions: TPermission[] = []) => {
+ // any because if it set it as form type due to the discriminated union type of ts
+ // i would have to write a if loop with both conditions same
+ const formVal: Record = {};
+ permissions.forEach((permission) => {
+ const { subject, action } = permission;
+ if (!formVal?.[subject]) formVal[subject] = {};
+ formVal[subject][action] = true;
+ });
+
+ return formVal;
+};
+
+export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
+ const permissions: TPermission[] = [];
+ Object.entries(formVal || {}).forEach(([rule, actions]) => {
+ Object.entries(actions).forEach(([action, isAllowed]) => {
+ if (isAllowed) {
+ permissions.push({ subject: rule, action });
+ }
+ });
+ });
+
+ return permissions;
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx
new file mode 100644
index 000000000..1eba6f9fe
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx
@@ -0,0 +1,204 @@
+import { useEffect, useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { IconProp } from "@fortawesome/fontawesome-svg-core";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { Checkbox, Select, SelectItem } from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+
+import { TFormSchema } from "./OrgRoleModifySection.utils";
+
+type Props = {
+ formName: keyof Omit, "workspace">;
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+ title: string;
+ subtitle: string;
+ icon: IconProp;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ ReadOnly = "read-only",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+const PERMISSIONS = [
+ { action: "read", label: "View" },
+ { action: "create", label: "Create" },
+ { action: "edit", label: "Modify" },
+ { action: "delete", label: "Remove" }
+] as const;
+
+const SECRET_SCANNING_PERMISSIONS = [
+ { action: "read", label: "View risks" },
+ { action: "create", label: "Add integrations" },
+ { action: "edit", label: "Edit risk status" },
+ { action: "delete", label: "Remove integrations" }
+] as const;
+
+const INCIDENT_CONTACTS_PERMISSIONS = [
+ { action: "read", label: "View contacts" },
+ { action: "create", label: "Add new contacts" },
+ { action: "edit", label: "Edit contacts" },
+ { action: "delete", label: "Remove contacts" }
+] as const;
+
+const MEMBERS_PERMISSIONS = [
+ { action: "read", label: "View all members" },
+ { action: "create", label: "Invite members" },
+ { action: "edit", label: "Edit members" },
+ { action: "delete", label: "Remove members" }
+] as const;
+
+const BILLING_PERMISSIONS = [
+ { action: "read", label: "View bills" },
+ { action: "create", label: "Add payment methods" },
+ { action: "edit", label: "Edit payments" },
+ { action: "delete", label: "Remove payments" }
+] as const;
+
+const getPermissionList = (option: Props["formName"]) => {
+ switch (option) {
+ case "secret-scanning":
+ return SECRET_SCANNING_PERMISSIONS;
+ case "billing":
+ return BILLING_PERMISSIONS;
+ case "incident-contact":
+ return INCIDENT_CONTACTS_PERMISSIONS;
+ case "member":
+ return MEMBERS_PERMISSIONS
+ default:
+ return PERMISSIONS;
+ }
+};
+
+export const SimpleLevelPermissionOption = ({
+ isNonEditable,
+ setValue,
+ control,
+ formName,
+ subtitle,
+ title,
+ icon
+}: Props) => {
+ const rule = useWatch({
+ control,
+ name: `permissions.${formName}`
+ });
+ const [isCustom, setIsCustom] = useToggle();
+
+ const selectedPermissionCategory = useMemo(() => {
+ const actions = Object.keys(rule || {}) as Array;
+ const totalActions = PERMISSIONS.length;
+ const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
+
+ if (isCustom) return Permission.Custom;
+ if (score === 0) return Permission.NoAccess;
+ if (score === totalActions) return Permission.FullAccess;
+ if (score === 1 && rule?.read) return Permission.ReadOnly;
+
+ return Permission.Custom;
+ }, [rule, isCustom]);
+
+ useEffect(() => {
+ if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+ }, [selectedPermissionCategory]);
+
+ const handlePermissionChange = (val: Permission) => {
+ if (val === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+
+ switch (val) {
+ case Permission.NoAccess:
+ setValue(
+ `permissions.${formName}`,
+ { read: false, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.FullAccess:
+ setValue(
+ `permissions.${formName}`,
+ { read: true, edit: true, create: true, delete: true },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.ReadOnly:
+ setValue(
+ `permissions.${formName}`,
+ { read: true, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ default:
+ setValue(
+ `permissions.${formName}`,
+ { read: false, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ No Access
+ Read Only
+ Full Access
+ Custom
+
+
+
+
+ {isCustom &&
+ getPermissionList(formName).map(({ action, label }) => (
+ (
+
+ {label}
+
+ )}
+ />
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx
new file mode 100644
index 000000000..915fa12c5
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { faMoneyBill } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { Checkbox, Select, SelectItem } from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+
+import { TFormSchema } from "./OrgRoleModifySection.utils";
+
+type Props = {
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ ReadOnly = "read-only",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+const PERMISSIONS = [
+ { action: "read", label: "View projects" },
+ { action: "create", label: "Create new projects" }
+] as const;
+
+export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => {
+ const rule = useWatch({
+ control,
+ name: "permissions.workspace"
+ });
+ const [isCustom, setIsCustom] = useToggle();
+
+ const selectedPermissionCategory = useMemo(() => {
+ const actions = Object.keys(rule || {}) as Array;
+ const totalActions = PERMISSIONS.length;
+ const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
+
+ if (isCustom) return Permission.Custom;
+ if (score === 0) return Permission.NoAccess;
+ if (score === totalActions) return Permission.FullAccess;
+ if (score === 1 && rule?.read) return Permission.ReadOnly;
+
+ return Permission.Custom;
+ }, [rule, isCustom]);
+
+ useEffect(() => {
+ if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+ }, [selectedPermissionCategory]);
+
+ const handlePermissionChange = (val: Permission) => {
+ if (val === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+
+ switch (val) {
+ case Permission.NoAccess:
+ setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true });
+ break;
+ case Permission.FullAccess:
+ setValue("permissions.workspace", { read: true, create: true }, { shouldDirty: true });
+ break;
+ case Permission.ReadOnly:
+ setValue("permissions.workspace", { read: true, create: false }, { shouldDirty: true });
+ break;
+ default:
+ setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true });
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Project
+
+ View and create new projects in this organization
+
+
+
+
+ No Access
+ Read Only
+ Full Access
+ Custom
+
+
+
+
+ {isCustom &&
+ PERMISSIONS.map(({ action, label }) => (
+ (
+
+ {label}
+
+ )}
+ />
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx
new file mode 100644
index 000000000..86de1647a
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx
@@ -0,0 +1 @@
+export { OrgRoleModifySection } from "./OrgRoleModifySection";
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx
new file mode 100644
index 000000000..70b0813f3
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx
@@ -0,0 +1,45 @@
+import { motion } from "framer-motion";
+
+import { usePopUp } from "@app/hooks";
+import { TRole } from "@app/hooks/api/roles/types";
+
+import { OrgRoleModifySection } from "./OrgRoleModifySection";
+import { OrgRoleTable } from "./OrgRoleTable";
+
+type Props = {
+ roles?: TRole[];
+ isRolesLoading?: boolean;
+};
+
+export const OrgRoleTabSection = ({ roles = [], isRolesLoading }: Props) => {
+ const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
+
+ return popUp.editRole.isOpen ? (
+
+ }
+ onGoBack={() => handlePopUpClose("editRole")}
+ />
+
+ ) : (
+
+ handlePopUpOpen("editRole", role)}
+ />
+
+ );
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx
new file mode 100644
index 000000000..dbd4331bf
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { OrgPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ DeleteActionModal,
+ IconButton,
+ Input,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useDeleteRole } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+type Props = {
+ isRolesLoading?: boolean;
+ roles?: TRole[];
+ onSelectRole: (role?: TRole) => void;
+};
+
+export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
+ const [searchRoles, setSearchRoles] = useState("");
+ const { currentOrg } = useOrganization();
+ const orgId = currentOrg?._id || "";
+ const { createNotification } = useNotificationContext();
+ const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
+
+ const { mutateAsync: deleteRole } = useDeleteRole();
+
+ const handleRoleDelete = async () => {
+ const { _id: id } = popUp?.deleteRole?.data as TRole;
+ try {
+ await deleteRole({
+ orgId,
+ id
+ });
+ createNotification({ type: "success", text: "Successfully removed the role" });
+ handlePopUpClose("deleteRole");
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to create role" });
+ }
+ };
+
+ return (
+
+
+
+ setSearchRoles(e.target.value)}
+ leftIcon={}
+ placeholder="Search roles..."
+ />
+
+
+ {(isAllowed) => (
+ }
+ onClick={() => onSelectRole()}
+ >
+ Add Role
+
+ )}
+
+
+
+
+
+
+
+ | Name |
+ Slug |
+ |
+
+
+
+ {isRolesLoading && }
+ {roles?.map((role) => {
+ const { _id: id, name, slug } = role;
+ const isNonMutatable = ["owner", "admin", "member"].includes(slug);
+
+ return (
+
+ | {name} |
+ {slug} |
+
+
+
+ {(isAllowed) => (
+ onSelectRole(role)}
+ variant="plain"
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ handlePopUpOpen("deleteRole", role)}
+ variant="plain"
+ isDisabled={isNonMutatable || !isAllowed}
+ >
+
+
+ )}
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ )?.name || " "
+ } role?`}
+ deleteKey={(popUp?.deleteRole?.data as TRole)?.slug || ""}
+ onClose={() => handlePopUpClose("deleteRole")}
+ onDeleteApproved={handleRoleDelete}
+ />
+
+ );
+};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx
new file mode 100644
index 000000000..a78c88bcb
--- /dev/null
+++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx
@@ -0,0 +1 @@
+export { OrgRoleTabSection } from "./OrgRoleTabSection";
diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx
deleted file mode 100644
index bd9f601c2..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx
+++ /dev/null
@@ -1,367 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { Controller, useForm } from "react-hook-form";
-import { useRouter } from "next/router";
-import {
- faCheck,
- faCopy,
- faMagnifyingGlass,
- faPencil,
- faPlus,
- faServer,
- 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 { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
-import {
- Button,
- DeleteActionModal,
- EmptyState,
- FormControl,
- IconButton,
- Input,
- Modal,
- ModalContent,
- Select,
- SelectItem,
- Table,
- TableContainer,
- TableSkeleton,
- TBody,
- Td,
- Th,
- THead,
- Tr
-} from "@app/components/v2";
-import { useOrganization, useWorkspace } from "@app/context";
-import { usePopUp, useToggle } from "@app/hooks";
-import {
- useCreateServiceAccount,
- useDeleteServiceAccount,
- useGetServiceAccounts
-} from "@app/hooks/api";
-
-const serviceAccountExpiration = [
- { label: "1 Day", value: 86400 },
- { label: "7 Days", value: 604800 },
- { label: "1 Month", value: 2592000 },
- { label: "6 months", value: 15552000 },
- { label: "12 months", value: 31104000 },
- { label: "Never", value: -1 }
-];
-
-const addServiceAccountFormSchema = yup.object({
- name: yup.string().required().label("Name").trim(),
- expiresIn: yup.string().required().label("Service Account Expiration")
-});
-
-type TAddServiceAccountForm = yup.InferType;
-
-export const OrgServiceAccountsTable = () => {
- const router = useRouter();
- const { currentOrg } = useOrganization();
- const { currentWorkspace } = useWorkspace();
-
- const orgId = currentOrg?._id || "";
- const [step, setStep] = useState(0);
- const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
- const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
- const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
- const [accessKey, setAccessKey] = useState("");
- const [publicKey, setPublicKey] = useState("");
- const [privateKey, setPrivateKey] = useState("");
- const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
- const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
- "addServiceAccount",
- "removeServiceAccount"
- ] as const);
-
- const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
- useGetServiceAccounts(orgId);
-
- const createServiceAccount = useCreateServiceAccount();
- const removeServiceAccount = useDeleteServiceAccount();
-
- useEffect(() => {
- let timer: NodeJS.Timeout;
- if (isAccessKeyCopied) {
- timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
- }
-
- if (isPublicKeyCopied) {
- timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
- }
-
- if (isPrivateKeyCopied) {
- timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
- }
-
- return () => clearTimeout(timer);
- }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
-
- const {
- control,
- handleSubmit,
- reset,
- formState: { isSubmitting }
- } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) });
-
- const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
- if (!currentOrg?._id) return;
-
- const keyPair = generateKeyPair();
- setPublicKey(keyPair.publicKey);
- setPrivateKey(keyPair.privateKey);
-
- const serviceAccountDetails = await createServiceAccount.mutateAsync({
- name,
- organizationId: currentOrg?._id,
- publicKey: keyPair.publicKey,
- expiresIn: Number(expiresIn)
- });
-
- setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
-
- setStep(1);
- reset();
- };
-
- const onRemoveServiceAccount = async () => {
- const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
- await removeServiceAccount.mutateAsync(serviceAccountId);
- handlePopUpClose("removeServiceAccount");
- };
-
- const filteredServiceAccounts = useMemo(
- () =>
- serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
- [serviceAccounts, searchServiceAccountFilter]
- );
-
- const renderStep = (stepToRender: number) => {
- switch (stepToRender) {
- case 0:
- return (
-
- );
- case 1:
- return (
- <>
- Access Key
-
- {accessKey}
- {
- navigator.clipboard.writeText(accessKey);
- setIsAccessKeyCopied.on();
- }}
- >
-
-
- Copy
-
-
-
- Public Key
-
- {publicKey}
- {
- navigator.clipboard.writeText(publicKey);
- setIsPublicKeyCopied.on();
- }}
- >
-
-
- Copy
-
-
-
- Private Key
-
- {privateKey}
- {
- navigator.clipboard.writeText(privateKey);
- setIsPrivateKeyCopied.on();
- }}
- >
-
-
- Copy
-
-
-
- >
- );
- default:
- return ;
- }
- };
-
- return (
-
-
-
- setSearchServiceAccountFilter(e.target.value)}
- leftIcon={}
- placeholder="Search service accounts..."
- />
-
- }
- onClick={() => {
- setStep(0);
- reset();
- handlePopUpOpen("addServiceAccount");
- }}
- >
- Add Service Account
-
-
-
-
-
- | Name |
- Valid Until |
- |
-
-
- {isServiceAccountsLoading && (
-
- )}
- {!isServiceAccountsLoading &&
- filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
- return (
-
- | {name} |
- {new Date(expiresAt).toUTCString()} |
-
-
- {
- if (currentWorkspace?._id) {
- router.push(
- `/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
- );
- }
- }}
- className="mr-2"
- >
-
-
-
- handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
- }
- >
-
-
-
- |
-
- );
- })}
-
-
- {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
-
- )}
-
- {
- handlePopUpToggle("addServiceAccount", isOpen);
- reset();
- }}
- >
-
- {renderStep(step)}
-
-
- handlePopUpToggle("removeServiceAccount", isOpen)}
- onDeleteApproved={onRemoveServiceAccount}
- />
-
- );
-};
diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx
deleted file mode 100644
index 8e900f6fa..000000000
--- a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
\ No newline at end of file
diff --git a/frontend/src/views/Org/MembersPage/components/index.tsx b/frontend/src/views/Org/MembersPage/components/index.tsx
deleted file mode 100644
index 796bb6aa3..000000000
--- a/frontend/src/views/Org/MembersPage/components/index.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
-export { OrgMembersTable } from "./OrgMembersTable";
-export { OrgNameChangeSection } from "./OrgNameChangeSection";
-export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
-
diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx
index 0a192110b..ce3ce4ece 100644
--- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx
+++ b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx
@@ -1,17 +1,21 @@
-import {
- LogsSection
-} from "./components";
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { withProjectPermission } from "@app/hoc";
-export const AuditLogsPage = () => {
+import { LogsSection } from "./components";
+
+export const AuditLogsPage = withProjectPermission(
+ () => {
return (
-
+
);
-}
\ No newline at end of file
+ },
+ { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
+);
diff --git a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx
index 9940d6bae..64eec6a2f 100644
--- a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx
+++ b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx
@@ -1,15 +1,21 @@
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { withProjectPermission } from "@app/hoc";
+
import { IPAllowlistSection } from "./components";
-export const IPAllowlistPage = () => {
+export const IPAllowlistPage = withProjectPermission(
+ () => {
return (
-
+
);
-}
\ No newline at end of file
+ },
+ { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.IpAllowList }
+);
diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx
index 729ab549e..3fa46051e 100644
--- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx
+++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx
@@ -2,104 +2,111 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
-import {
- Button,
- DeleteActionModal,
- UpgradePlanModal
-} from "@app/components/v2";
-import { useSubscription,useWorkspace } from "@app/context";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2";
import {
- useDeleteTrustedIp
-} from "@app/hooks/api";
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useSubscription,
+ useWorkspace
+} from "@app/context";
+import { useDeleteTrustedIp } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IPAllowlistModal } from "./IPAllowlistModal";
import { IPAllowlistTable } from "./IPAllowlistTable";
export const IPAllowlistSection = () => {
- const { createNotification } = useNotificationContext();
- const { mutateAsync } = useDeleteTrustedIp();
- const { subscription } = useSubscription();
- const { currentWorkspace } = useWorkspace();
-
- const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
- "trustedIp",
- "deleteTrustedIp",
- "upgradePlan"
- ] as const);
-
- const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
- try {
-
- if (!currentWorkspace?._id) return;
+ const { createNotification } = useNotificationContext();
+ const { mutateAsync } = useDeleteTrustedIp();
+ const { subscription } = useSubscription();
+ const { currentWorkspace } = useWorkspace();
- await mutateAsync({
- workspaceId: currentWorkspace._id,
- trustedIpId
- });
+ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
+ "trustedIp",
+ "deleteTrustedIp",
+ "upgradePlan"
+ ] as const);
- createNotification({
- text: "Successfully deleted IP access range",
- type: "success"
- });
+ const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
+ try {
+ if (!currentWorkspace?._id) return;
- handlePopUpClose("deleteTrustedIp");
- } catch (err) {
- console.log(err);
- createNotification({
- text: "Failed to delete IP access range",
- type: "error"
- });
- }
+ await mutateAsync({
+ workspaceId: currentWorkspace._id,
+ trustedIpId
+ });
+
+ createNotification({
+ text: "Successfully deleted IP access range",
+ type: "success"
+ });
+
+ handlePopUpClose("deleteTrustedIp");
+ } catch (err) {
+ console.log(err);
+ createNotification({
+ text: "Failed to delete IP access range",
+ type: "error"
+ });
}
-
- return (
-
-
-
- IP Allowlist
-
-
-
-
-
- handlePopUpToggle("deleteTrustedIp", isOpen)}
- deleteKey="confirm"
- onDeleteApproved={() =>
- onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId)
+ };
+
+ return (
+
+
+ IP Allowlist
+
+ {(isAllowed) => (
+
- );
-}
\ No newline at end of file
+ }}
+ colorSchema="secondary"
+ isLoading={false}
+ isDisabled={!isAllowed}
+ leftIcon={ }
+ >
+ Add IP
+
+ )}
+
+
+
+
+ handlePopUpToggle("deleteTrustedIp", isOpen)}
+ deleteKey="confirm"
+ onDeleteApproved={() =>
+ onDeleteTrustedIpSubmit(
+ (popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId
+ )
+ }
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text="You can use IP allowlisting if you switch to Infisical's Pro plan."
+ />
+
+ );
+};
diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx
index 05f65e3ad..a9a84a608 100644
--- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx
+++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx
@@ -1,92 +1,81 @@
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { ProjectPermissionCan } from "@app/components/permissions";
import {
- EmptyState,
- IconButton,
- Table,
- TableContainer,
- TableSkeleton,
- TBody,
- Td,
- Th,
- THead,
- Tr,
- UpgradePlanModal
+ EmptyState,
+ IconButton,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr,
+ UpgradePlanModal
} from "@app/components/v2";
-import { useSubscription, useWorkspace } from "@app/context";
import {
- useGetTrustedIps
-} from "@app/hooks/api";
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useSubscription,
+ useWorkspace
+} from "@app/context";
+import { useGetTrustedIps } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
- popUp: UsePopUpState<["upgradePlan"]>;
- handlePopUpOpen: (
- popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
- data?: {
- trustedIpId: string;
- ipAddress?: string;
- comment?: string;
- isActive?: boolean;
- prefix?: number;
- },
- ) => void;
- handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
+ popUp: UsePopUpState<["upgradePlan"]>;
+ handlePopUpOpen: (
+ popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
+ data?: {
+ trustedIpId: string;
+ ipAddress?: string;
+ comment?: string;
+ isActive?: boolean;
+ prefix?: number;
+ }
+ ) => void;
+ handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
};
-export const IPAllowlistTable = ({
- popUp,
- handlePopUpOpen,
- handlePopUpToggle
-}: Props) => {
- const { subscription } = useSubscription();
- const { currentWorkspace } = useWorkspace();
- const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? "");
-
- const formatType = (type: string, prefix?: number) => {
- return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`;
- }
-
- return (
-
-
-
-
-
- | IP Address / Range |
- Format |
- Comment |
- {/* Status | */}
- |
-
-
-
- {!isLoading && data && data?.length > 0 && data
- .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
- .map(({
- _id,
- ipAddress,
- comment,
- type,
- prefix,
- isActive
- }) => {
- return (
-
- |
- {`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`}
- |
-
- {formatType(type, prefix)}
- |
-
- {comment}
- |
- {/*
+export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
+ const { subscription } = useSubscription();
+ const { currentWorkspace } = useWorkspace();
+ const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? "");
+
+ const formatType = (type: string, prefix?: number) => {
+ return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${
+ prefix !== undefined ? "CIDR" : ""
+ }`;
+ };
+
+ return (
+
+
+
+
+
+ | IP Address / Range |
+ Format |
+ Comment |
+ {/* Status | */}
+ |
+
+
+
+ {!isLoading &&
+ data &&
+ data?.length > 0 &&
+ data
+ .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
+ .map(({ _id, ipAddress, comment, type, prefix, isActive }) => {
+ return (
+
+ | {`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`} |
+ {formatType(type, prefix)} |
+ {comment} |
+ {/*
Active
| */}
-
- {
- if (subscription?.ipAllowlisting) {
- handlePopUpOpen("trustedIp", {
- trustedIpId: _id,
- ipAddress,
- comment,
- prefix,
- isActive
- });
- } else {
- handlePopUpOpen("upgradePlan");
- }
- }}
- colorSchema="primary"
- variant="plain"
- ariaLabel="update"
- >
-
-
- {
- if (subscription?.ipAllowlisting) {
- handlePopUpOpen("deleteTrustedIp", {
- trustedIpId: _id
- });
- } else {
- handlePopUpOpen("upgradePlan");
- }
- }}
- size="lg"
- colorSchema="danger"
- variant="plain"
- ariaLabel="update"
- >
-
-
- |
-
- );
- })}
- {isLoading && }
- {!isLoading && data && data?.length === 0 && (
-
- |
-
- |
-
- )}
-
-
-
- handlePopUpToggle("upgradePlan", isOpen)}
- text="You can use IP allowlisting if you switch to Infisical's Pro plan."
- />
-
- );
-}
\ No newline at end of file
+ |
+
+ {(isAllowed) => (
+ {
+ if (subscription?.ipAllowlisting) {
+ handlePopUpOpen("trustedIp", {
+ trustedIpId: _id,
+ ipAddress,
+ comment,
+ prefix,
+ isActive
+ });
+ } else {
+ handlePopUpOpen("upgradePlan");
+ }
+ }}
+ colorSchema="primary"
+ variant="plain"
+ ariaLabel="update"
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ {
+ if (subscription?.ipAllowlisting) {
+ handlePopUpOpen("deleteTrustedIp", {
+ trustedIpId: _id
+ });
+ } else {
+ handlePopUpOpen("upgradePlan");
+ }
+ }}
+ size="lg"
+ colorSchema="danger"
+ variant="plain"
+ ariaLabel="update"
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
+ |
+
+ );
+ })}
+ {isLoading && (
+
+ )}
+ {!isLoading && data && data?.length === 0 && (
+
+ |
+
+ |
+
+ )}
+
+
+
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text="You can use IP allowlisting if you switch to Infisical's Pro plan."
+ />
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx
new file mode 100644
index 000000000..4c83e087f
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx
@@ -0,0 +1,65 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import { useTranslation } from "react-i18next";
+import { motion } from "framer-motion";
+
+import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
+import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
+import { withProjectPermission } from "@app/hoc";
+import { useGetRoles } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+import { MemberListTab } from "./components/MemberListTab";
+import { ProjectRoleListTab } from "./components/ProjectRoleListTab";
+
+enum TabSections {
+ Member = "members",
+ Roles = "roles"
+}
+
+export const MembersPage = withProjectPermission(
+ () => {
+ const { t } = useTranslation();
+ const { currentWorkspace } = useWorkspace();
+ const workspaceId = currentWorkspace?._id || "";
+ const orgId = currentWorkspace?.organization || "";
+
+ const { data: roles, isLoading: isRolesLoading } = useGetRoles({
+ orgId,
+ workspaceId
+ });
+
+ return (
+
+
+
+ {t("settings.members.title")}
+
+
+
+ Members
+ Roles
+
+
+
+ []} isRolesLoading={isRolesLoading} />
+
+
+
+ []}
+ isRolesLoading={isRolesLoading}
+ />
+
+
+
+
+ );
+ },
+ { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Member }
+);
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx
new file mode 100644
index 000000000..3dddd4ef8
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx
@@ -0,0 +1,420 @@
+import { useCallback, useMemo, useState } from "react";
+import { Controller, useForm } from "react-hook-form";
+import { useTranslation } from "react-i18next";
+import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ decryptAssymmetric,
+ encryptAssymmetric
+} from "@app/components/utilities/cryptography/crypto";
+import {
+ Button,
+ DeleteActionModal,
+ EmptyState,
+ FormControl,
+ IconButton,
+ Input,
+ Modal,
+ ModalContent,
+ Select,
+ SelectItem,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr,
+ UpgradePlanModal
+} from "@app/components/v2";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useOrganization,
+ useUser,
+ useWorkspace
+} from "@app/context";
+import { usePopUp } from "@app/hooks";
+import {
+ useAddUserToWs,
+ useDeleteUserFromWorkspace,
+ useGetOrgUsers,
+ useGetUserWsKey,
+ useGetWorkspaceUsers,
+ useUpdateUserWorkspaceRole,
+ useUploadWsKey
+} from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+type Props = {
+ roles?: TRole[];
+ isRolesLoading?: boolean;
+};
+
+const addMemberFormSchema = z.object({
+ email: z.string().email().trim()
+});
+
+type TAddMemberForm = z.infer;
+
+export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => {
+ const { createNotification } = useNotificationContext();
+ const { t } = useTranslation();
+
+ const { currentOrg } = useOrganization();
+ const { currentWorkspace } = useWorkspace();
+ const { user } = useUser();
+
+ const userId = user?._id || "";
+ const orgId = currentOrg?._id || "";
+ const workspaceId = currentWorkspace?._id || "";
+
+ const { data: wsKey } = useGetUserWsKey(workspaceId);
+ const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
+ const { data: orgUsers } = useGetOrgUsers(orgId);
+
+ const [searchMemberFilter, setSearchMemberFilter] = useState("");
+
+ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
+ "addMember",
+ "removeMember",
+ "upgradePlan"
+ ] as const);
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ formState: { isSubmitting }
+ } = useForm({ resolver: zodResolver(addMemberFormSchema) });
+
+ const { mutateAsync: addUserToWorkspace } = useAddUserToWs();
+ const { mutateAsync: uploadWsKey } = useUploadWsKey();
+ const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
+ const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
+
+ const onAddMember = async ({ email }: TAddMemberForm) => {
+ if (!currentOrg?._id) return;
+
+ try {
+ await addUserToWorkspace({
+ email,
+ workspaceId
+ });
+ createNotification({
+ text: "Successfully invited user to the organization.",
+ type: "success"
+ });
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: "Failed to invite user to org",
+ type: "error"
+ });
+ }
+ handlePopUpClose("addMember");
+ reset();
+ };
+
+ const handleRemoveUser = async () => {
+ const membershipId = (popUp?.removeMember?.data as { id: string })?.id;
+ if (!currentOrg?._id) return;
+
+ try {
+ await removeUserFromWorkspace(membershipId);
+ createNotification({
+ text: "Successfully removed user from workspace",
+ type: "success"
+ });
+ } catch (error) {
+ console.error(error);
+ createNotification({
+ text: "Failed to remove user from the organization",
+ type: "error"
+ });
+ }
+ handlePopUpClose("removeMember");
+ };
+
+ const isIamOwner = useMemo(
+ () => members?.find(({ user: u }) => userId === u?._id)?.role === "owner",
+ [userId, members]
+ );
+
+ const findRoleFromId = useCallback(
+ (roleId: string) => {
+ return roles.find(({ _id: id }) => id === roleId);
+ },
+ [roles]
+ );
+
+ const onRoleChange = async (membershipId: string, role: string) => {
+ if (!currentOrg?._id) return;
+
+ try {
+ await updateUserWorkspaceRole({ 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 filterdUsers = useMemo(
+ () =>
+ members?.filter(
+ ({ user: u, inviteEmail }) =>
+ u?.firstName?.toLowerCase().includes(searchMemberFilter) ||
+ u?.lastName?.toLowerCase().includes(searchMemberFilter) ||
+ u?.email?.toLowerCase().includes(searchMemberFilter) ||
+ inviteEmail?.includes(searchMemberFilter)
+ ),
+ [members, searchMemberFilter]
+ );
+
+ const filteredOrgUsers = useMemo(() => {
+ const wsUserEmails = new Map();
+ members?.forEach((member) => {
+ wsUserEmails.set(member.user.email, true);
+ });
+ return (orgUsers || []).filter(
+ ({ status, user: u }) => status === "accepted" && !wsUserEmails.has(u.email)
+ );
+ }, [orgUsers, members]);
+
+ const onGrantAccess = async (grantedUserId: 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({
+ userId: grantedUserId,
+ nonce,
+ encryptedKey: ciphertext,
+ workspaceId: currentWorkspace?._id || ""
+ });
+ } catch (err) {
+ console.error(err);
+ createNotification({
+ text: "Failed to grant access to user",
+ type: "error"
+ });
+ }
+ };
+
+ const isLoading = isMembersLoading || isRolesLoading;
+
+ return (
+
+
+
+ setSearchMemberFilter(e.target.value)}
+ leftIcon={}
+ placeholder="Search members..."
+ />
+
+
+ {(isAllowed) => (
+ }
+ onClick={() => handlePopUpOpen("addMember")}
+ >
+ Add Member
+
+ )}
+
+
+
+
+
+
+
+ | Name |
+ Email |
+ Role |
+ |
+
+
+
+ {isLoading && }
+ {!isLoading &&
+ filterdUsers?.map(
+ ({ user: u, inviteEmail, _id: membershipId, status, customRole, role }) => {
+ const name = u ? `${u.firstName} ${u.lastName}` : "-";
+ const email = u?.email || inviteEmail;
+
+ return (
+
+ | {name} |
+ {email} |
+
+
+ {(isAllowed) => (
+ <>
+
+ onRoleChange(membershipId, selectedRole)
+ }
+ >
+ {roles
+ .filter(({ slug }) =>
+ slug === "owner" ? isIamOwner || role === "owner" : true
+ )
+ .map(({ slug, name: roleName }) => (
+
+ {roleName}
+
+ ))}
+
+ {status === "completed" && user.email !== email && (
+
+
+
+ )}
+ >
+ )}
+
+ |
+
+ {userId !== u?._id && (
+
+ {(isAllowed) => (
+
+ handlePopUpOpen("removeMember", { id: membershipId })
+ }
+ >
+
+
+ )}
+
+ )}
+ |
+
+ );
+ }
+ )}
+
+
+ {!isLoading && filterdUsers?.length === 0 && (
+
+ )}
+
+
+ handlePopUpToggle("addMember", isOpen)}
+ >
+
+
+
+
+ handlePopUpToggle("removeMember", isOpen)}
+ onDeleteApproved={handleRemoveUser}
+ />
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text="You can add custom environments if you switch to Infisical's Team plan."
+ />
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx
new file mode 100644
index 000000000..695e806c5
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx
@@ -0,0 +1 @@
+export { MemberListTab } from "./MemberListTab";
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx
new file mode 100644
index 000000000..b433ed7e4
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx
@@ -0,0 +1,50 @@
+import { motion } from "framer-motion";
+
+import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
+import { withProjectPermission } from "@app/hoc";
+import { usePopUp } from "@app/hooks";
+import { TRole } from "@app/hooks/api/roles/types";
+
+import { ProjectRoleList } from "./components/ProjectRoleList";
+import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection";
+
+type Props = {
+ roles?: TRole[];
+ isRolesLoading?: boolean;
+};
+
+export const ProjectRoleListTab = withProjectPermission(
+ ({ roles = [], isRolesLoading }: Props) => {
+ const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
+
+ return popUp.editRole.isOpen ? (
+
+ }
+ onGoBack={() => handlePopUpClose("editRole")}
+ />
+
+ ) : (
+
+ handlePopUpOpen("editRole", role)}
+ />
+
+ );
+ },
+ { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
+);
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx
new file mode 100644
index 000000000..ce0b42385
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx
@@ -0,0 +1,166 @@
+import { useState } from "react";
+import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ DeleteActionModal,
+ IconButton,
+ Input,
+ Table,
+ TableContainer,
+ TableSkeleton,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useOrganization,
+ useWorkspace
+} from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useDeleteRole } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+type Props = {
+ isRolesLoading?: boolean;
+ roles?: TRole[];
+ onSelectRole: (role?: TRole) => void;
+};
+
+export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Props) => {
+ const [searchRoles, setSearchRoles] = useState("");
+ const { currentOrg } = useOrganization();
+ const { currentWorkspace } = useWorkspace();
+ const orgId = currentOrg?._id || "";
+ const workspaceId = currentWorkspace?._id || "";
+
+ const { createNotification } = useNotificationContext();
+ const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
+
+ const { mutateAsync: deleteRole } = useDeleteRole();
+
+ const handleRoleDelete = async () => {
+ const { _id: id } = popUp?.deleteRole?.data as TRole;
+ try {
+ await deleteRole({
+ orgId,
+ workspaceId,
+ id
+ });
+ createNotification({ type: "success", text: "Successfully removed the role" });
+ handlePopUpClose("deleteRole");
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to create role" });
+ }
+ };
+
+ return (
+
+
+
+ setSearchRoles(e.target.value)}
+ leftIcon={}
+ placeholder="Search roles..."
+ />
+
+
+ {(isAllowed) => (
+ }
+ onClick={() => onSelectRole()}
+ isDisabled={!isAllowed}
+ >
+ Add Role
+
+ )}
+
+
+
+
+
+
+
+ | Name |
+ Slug |
+ |
+
+
+
+ {isRolesLoading && }
+ {roles?.map((role) => {
+ const { _id: id, name, slug } = role;
+ const isNonMutatable = ["admin", "member", "viewer"].includes(slug);
+
+ return (
+
+ | {name} |
+ {slug} |
+
+
+
+ {(isAllowed) => (
+ onSelectRole(role)}
+ variant="plain"
+ >
+
+
+ )}
+
+
+ {(isAllowed) => (
+ handlePopUpOpen("deleteRole", role)}
+ variant="plain"
+ isDisabled={isNonMutatable || !isAllowed}
+ >
+
+
+ )}
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ )?.name || " "
+ } role?`}
+ deleteKey={(popUp?.deleteRole?.data as TRole)?.slug || ""}
+ onClose={() => handlePopUpClose("deleteRole")}
+ onDeleteApproved={handleRoleDelete}
+ />
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx
new file mode 100644
index 000000000..9f8e88a82
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx
@@ -0,0 +1 @@
+export { ProjectRoleList } from "./ProjectRoleList";
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx
new file mode 100644
index 000000000..137a059ba
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx
@@ -0,0 +1,244 @@
+import { useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { IconProp } from "@fortawesome/fontawesome-svg-core";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import {
+ Checkbox,
+ FormControl,
+ Input,
+ Select,
+ SelectItem,
+ Table,
+ TableContainer,
+ TBody,
+ Td,
+ Th,
+ THead,
+ Tr
+} from "@app/components/v2";
+import { useWorkspace } from "@app/context";
+
+import { TFormSchema } from "./ProjectRoleModifySection.utils";
+
+type Props = {
+ formName: "secrets" | "folders" | "secret-imports";
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+ title: string;
+ subtitle: string;
+ icon: IconProp;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ ReadOnly = "read-only",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+export const MultiEnvProjectPermission = ({
+ isNonEditable,
+ setValue,
+ control,
+ formName,
+ title,
+ subtitle,
+ icon
+}: Props) => {
+ const { currentWorkspace } = useWorkspace();
+
+ const environments = currentWorkspace?.environments || [];
+ const customRule = useWatch({
+ control,
+ name: `permissions.${formName}.custom`
+ });
+ const isCustom = Boolean(customRule);
+ const allRule = useWatch({ control, name: `permissions.${formName}.all` });
+
+ const selectedPermissionCategory = useMemo(() => {
+ const { read, delete: del, edit, create } = allRule || {};
+ if (read && del && edit && create) return Permission.FullAccess;
+ if (read) return Permission.ReadOnly;
+ return Permission.NoAccess;
+ }, [allRule]);
+
+ const handlePermissionChange = (val: Permission) => {
+ switch (val) {
+ case Permission.NoAccess:
+ setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
+ break;
+ case Permission.FullAccess:
+ setValue(
+ `permissions.${formName}`,
+ { all: { read: true, edit: true, create: true, delete: true } },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.ReadOnly:
+ setValue(
+ `permissions.${formName}`,
+ { all: { read: true, edit: false, create: false, delete: false } },
+ { shouldDirty: true }
+ );
+ break;
+ default:
+ setValue(
+ `permissions.${formName}`,
+ { custom: { read: false, edit: false, create: false, delete: false } },
+ { shouldDirty: true }
+ );
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ No Access
+ Read Only
+ Full Access
+ Custom
+
+
+
+
+
+
+
+
+ |
+ Secret Path |
+ View |
+ Create |
+ Modify |
+ Delete |
+
+
+
+ {isCustom &&
+ environments.map(({ name, slug }) => (
+
+ | {name} |
+
+ (
+ /* eslint-disable-next-line no-template-curly-in-string */
+
+
+
+ )}
+ />
+ |
+
+ (
+
+
+
+ )}
+ />
+ |
+
+ (
+
+
+
+ )}
+ />
+ |
+
+ (
+
+
+
+ )}
+ />
+ |
+
+ (
+
+
+
+ )}
+ />
+ |
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx
new file mode 100644
index 000000000..e0d76494b
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx
@@ -0,0 +1,327 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { faElementor } from "@fortawesome/free-brands-svg-icons";
+import {
+ faAnchorLock,
+ faArrowLeft,
+ faBook,
+ faCog,
+ faFolder,
+ faKey,
+ faLink,
+ faLock,
+ faMagnifyingGlass,
+ faNetworkWired,
+ faPuzzlePiece,
+ faTags,
+ faUser,
+ faUsers
+} from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
+import { Button, FormControl, Input, UpgradePlanModal } from "@app/components/v2";
+import { useOrganization, useSubscription, useWorkspace } from "@app/context";
+import { usePopUp } from "@app/hooks";
+import { useCreateRole, useUpdateRole } from "@app/hooks/api";
+import { TRole } from "@app/hooks/api/roles/types";
+
+import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission";
+import {
+ formRolePermission2API,
+ formSchema,
+ rolePermission2Form,
+ TFormSchema
+} from "./ProjectRoleModifySection.utils";
+import { SecretRollbackPermission } from "./SecretRollbackPermission";
+import { SingleProjectPermission } from "./SingleProjectPermission";
+import { WsProjectPermission } from "./WsProjectPermission";
+
+const SINGLE_PERMISSION_LIST = [
+ {
+ title: "Integrations",
+ subtitle: "Integration management control",
+ icon: faPuzzlePiece,
+ formName: "integrations"
+ },
+ {
+ title: "Roles",
+ subtitle: "Role management control",
+ icon: faUsers,
+ formName: "role"
+ },
+ {
+ title: "Project Members",
+ subtitle: "Project members management control",
+ icon: faUser,
+ formName: "member"
+ },
+ {
+ title: "Webhooks",
+ subtitle: "Webhook management control",
+ icon: faAnchorLock,
+ formName: "webhooks"
+ },
+ {
+ title: "Service Tokens",
+ subtitle: "Token management control",
+ icon: faKey,
+ formName: "service-tokens"
+ },
+ {
+ title: "Settings",
+ subtitle: "Settings control",
+ icon: faCog,
+ formName: "settings"
+ },
+ {
+ title: "Environments",
+ subtitle: "Environment management control",
+ icon: faElementor,
+ formName: "environments"
+ },
+ {
+ title: "Tags",
+ subtitle: "Tag management control",
+ icon: faTags,
+ formName: "tags"
+ },
+ {
+ title: "Audit Logs",
+ subtitle: "Audit log management control",
+ icon: faBook,
+ formName: "audit-logs"
+ },
+ {
+ title: "IP Allowlist",
+ subtitle: "IP allowlist management control",
+ icon: faNetworkWired,
+ formName: "ip-allowlist"
+ }
+] as const;
+
+type Props = {
+ role?: TRole;
+ onGoBack: VoidFunction;
+};
+
+export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
+ const [searchPermission, setSearchPermission] = useState("");
+
+ const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["upgradePlan"] as const);
+
+ const isNonEditable = ["admin", "member", "viewer"].includes(role?.slug || "");
+ const isNewRole = !role?.slug;
+
+ const { createNotification } = useNotificationContext();
+ const { currentOrg } = useOrganization();
+ const orgId = currentOrg?._id || "";
+ const { currentWorkspace } = useWorkspace();
+ const { subscription } = useSubscription();
+ const workspaceId = currentWorkspace?._id || "";
+
+ const {
+ handleSubmit,
+ register,
+ formState: { isSubmitting, isDirty, errors },
+ setValue,
+ control
+ } = useForm({
+ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
+ resolver: zodResolver(formSchema)
+ });
+ const { mutateAsync: createRole } = useCreateRole();
+ const { mutateAsync: updateRole } = useUpdateRole();
+
+ const handleRoleUpdate = async (el: TFormSchema) => {
+ if (!role?._id) return;
+
+ try {
+ await updateRole({
+ orgId,
+ id: role?._id,
+ workspaceId,
+ ...el,
+ permissions: formRolePermission2API(el.permissions)
+ });
+ createNotification({ type: "success", text: "Successfully updated role" });
+ onGoBack();
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to update role" });
+ }
+ };
+
+ const handleFormSubmit = async (el: TFormSchema) => {
+ if (subscription && !subscription?.rbac) {
+ handlePopUpOpen("upgradePlan");
+ return;
+ }
+
+ if (!isNewRole) {
+ await handleRoleUpdate(el);
+ return;
+ }
+
+ try {
+ await createRole({
+ orgId,
+ workspaceId,
+ ...el,
+ permissions: formRolePermission2API(el.permissions)
+ });
+ createNotification({ type: "success", text: "Created new role" });
+ onGoBack();
+ } catch (err) {
+ console.log(err);
+ createNotification({ type: "error", text: "Failed to create role" });
+ }
+ };
+
+ return (
+
+
+ {subscription && (
+ handlePopUpToggle("upgradePlan", isOpen)}
+ text={
+ subscription.slug === null
+ ? "You can use RBAC under an Enterprise license"
+ : "You can use RBAC if you switch to Infisical's Team Plan."
+ }
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts
new file mode 100644
index 000000000..7ff043ab7
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts
@@ -0,0 +1,165 @@
+/* eslint-disable no-param-reassign */
+import { z } from "zod";
+
+import { TProjectPermission } from "@app/hooks/api/roles/types";
+
+const generalPermissionSchema = z
+ .object({
+ read: z.boolean().optional(),
+ edit: z.boolean().optional(),
+ delete: z.boolean().optional(),
+ create: z.boolean().optional()
+ })
+ .optional();
+
+const multiEnvPermissionSchema = z
+ .object({
+ secretPath: z.string().trim().optional(),
+ read: z.boolean().optional(),
+ edit: z.boolean().optional(),
+ delete: z.boolean().optional(),
+ create: z.boolean().optional()
+ })
+ .optional();
+
+const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
+const MULTI_ENV_KEY = ["secrets", "folders", "secret-imports"] as const;
+
+export const formSchema = z.object({
+ name: z.string().trim(),
+ description: z.string().trim().optional(),
+ slug: z.string().trim(),
+ permissions: z
+ .object({
+ secrets: z.record(multiEnvPermissionSchema).optional(),
+ folders: z.record(multiEnvPermissionSchema).optional(),
+ "secret-imports": z.record(multiEnvPermissionSchema).optional(),
+ member: generalPermissionSchema,
+ role: generalPermissionSchema,
+ integrations: generalPermissionSchema,
+ webhooks: generalPermissionSchema,
+ "service-tokens": generalPermissionSchema,
+ settings: generalPermissionSchema,
+ environments: generalPermissionSchema,
+ tags: generalPermissionSchema,
+ "audit-logs": generalPermissionSchema,
+ "ip-allowlist": generalPermissionSchema,
+ workspace: z
+ .object({
+ edit: z.boolean().optional(),
+ delete: z.boolean().optional()
+ })
+ .optional(),
+ "secret-rollback": z
+ .object({
+ read: z.boolean().optional(),
+ create: z.boolean().optional()
+ })
+ .optional()
+ })
+ .optional()
+});
+
+export type TFormSchema = z.infer;
+
+const multiEnvApi2Form = (
+ formVal: Record,
+ permission: TProjectPermission
+) => {
+ const isCustomRule = Boolean(permission?.conditions?.environment);
+ // full access
+ if (isCustomRule && formVal && !formVal?.custom) {
+ formVal.custom = { read: true, edit: true, delete: true, create: true };
+ }
+
+ const secretEnv = permission?.conditions?.environment || "all";
+ const secretPath = permission?.conditions?.secretPath?.$glob;
+ // initialize
+ if (formVal && !formVal?.[secretEnv]) {
+ formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
+ }
+
+ formVal[secretEnv][permission.action] = true;
+};
+
+// convert role permission to form compatiable data structure
+export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
+ // any because if it set it as form type due to the discriminated union type of ts
+ // i would have to write a if loop with both conditions same
+ const formVal: Record = {};
+
+ permissions.forEach((permission) => {
+ const { subject, action } = permission;
+ if (!formVal?.[subject]) formVal[subject] = {};
+
+ if (["secrets", "folders", "secret-imports"].includes(subject)) {
+ multiEnvApi2Form(formVal[subject], permission);
+ } else {
+ // everything else follows same pattern
+ // formVal[settings][read | write] = true
+ formVal[subject][action] = true;
+ }
+ });
+
+ return formVal;
+};
+
+const multiEnvForm2Api = (
+ permissions: TProjectPermission[],
+ formVal: Record,
+ subject: (typeof MULTI_ENV_KEY)[number]
+) => {
+ if (!formVal) return;
+
+ const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]);
+ // if any of them is set in all push it without any condition
+ PERMISSION_ACTIONS.forEach((action) => {
+ if (formVal?.all?.[action]) permissions.push({ action, subject });
+ });
+
+ if (!isFullAccess) {
+ Object.keys(formVal || {})
+ .filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter
+ .forEach((slug) => {
+ const actions = Object.keys(formVal?.[slug] || {}) as [
+ "read",
+ "edit",
+ "create",
+ "delete",
+ "secretPath"
+ ];
+ actions.forEach((action) => {
+ // if not full access for an action
+ if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
+ const conditions: Record = { environment: slug };
+ if (formVal[slug]?.secretPath)
+ conditions.secretPath = { $glob: formVal?.[slug]?.secretPath };
+
+ permissions.push({ action, subject, conditions });
+ }
+ });
+ });
+ }
+};
+
+export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
+ const permissions: TProjectPermission[] = [];
+ // other than workspace everything else follows same
+ // if in future there is a different follow the above on how workspace is done
+ Object.entries(formVal || {}).forEach(([rule, actions]) => {
+ if (MULTI_ENV_KEY.includes(rule as (typeof MULTI_ENV_KEY)[number])) {
+ multiEnvForm2Api(
+ permissions,
+ JSON.parse(JSON.stringify(actions || {})),
+ rule as (typeof MULTI_ENV_KEY)[number]
+ );
+ } else {
+ Object.entries(actions).forEach(([action, isAllowed]) => {
+ if (isAllowed) {
+ permissions.push({ subject: rule, action });
+ }
+ });
+ }
+ });
+ return permissions;
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx
new file mode 100644
index 000000000..9d36b58da
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx
@@ -0,0 +1,146 @@
+import { useEffect, useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { Checkbox, Select, SelectItem } from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+
+import { TFormSchema } from "./ProjectRoleModifySection.utils";
+
+type Props = {
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ ReadOnly = "read-only",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+const PERMISSIONS = [
+ { action: "create", label: "Perform Rollback" },
+ { action: "read", label: "View" }
+] as const;
+
+export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: Props) => {
+ const rule = useWatch({
+ control,
+ name: "permissions.secret-rollback"
+ });
+ const [isCustom, setIsCustom] = useToggle();
+
+ const selectedPermissionCategory = useMemo(() => {
+ const actions = Object.keys(rule || {}) as Array;
+ const totalActions = PERMISSIONS.length;
+ const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
+
+ if (isCustom) return Permission.Custom;
+ if (score === 0) return Permission.NoAccess;
+ if (score === totalActions) return Permission.FullAccess;
+
+ return Permission.Custom;
+ }, [rule, isCustom]);
+
+ useEffect(() => {
+ if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+ }, [selectedPermissionCategory]);
+
+ const handlePermissionChange = (val: Permission) => {
+ if (val === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+
+ switch (val) {
+ case Permission.NoAccess:
+ setValue(
+ "permissions.secret-rollback",
+ { read: false, create: false },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.FullAccess:
+ setValue(
+ "permissions.secret-rollback",
+ { read: true, create: true },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.ReadOnly:
+ setValue(
+ "permissions.secret-rollback",
+ { read: true, create: false },
+ { shouldDirty: true }
+ );
+ break;
+ default:
+ setValue(
+ "permissions.secret-rollback",
+ { read: false, create: false },
+ { shouldDirty: true }
+ );
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Secret Rollback
+ Secret rollback control actions
+
+
+
+ No Access
+ Read Only
+ Full Access
+ Custom
+
+
+
+
+ {isCustom &&
+ PERMISSIONS.map(({ action, label }) => (
+ (
+
+ {label}
+
+ )}
+ />
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx
new file mode 100644
index 000000000..0adc5974a
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx
@@ -0,0 +1,171 @@
+import { useEffect, useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { IconProp } from "@fortawesome/fontawesome-svg-core";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { Checkbox, Select, SelectItem } from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+
+import { TFormSchema } from "./ProjectRoleModifySection.utils";
+
+type Props = {
+ formName:
+ | "role"
+ | "member"
+ | "integrations"
+ | "webhooks"
+ | "service-tokens"
+ | "settings"
+ | "environments"
+ | "tags"
+ | "audit-logs"
+ | "ip-allowlist";
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+ title: string;
+ subtitle: string;
+ icon: IconProp;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ ReadOnly = "read-only",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+const PERMISSIONS = [
+ { action: "read", label: "View" },
+ { action: "create", label: "Create" },
+ { action: "edit", label: "Modify" },
+ { action: "delete", label: "Remove" }
+] as const;
+
+export const SingleProjectPermission = ({
+ isNonEditable,
+ setValue,
+ control,
+ formName,
+ subtitle,
+ title,
+ icon
+}: Props) => {
+ const rule = useWatch({
+ control,
+ name: `permissions.${formName}`
+ });
+ const [isCustom, setIsCustom] = useToggle();
+
+ const selectedPermissionCategory = useMemo(() => {
+ const actions = Object.keys(rule || {}) as Array;
+ const totalActions = PERMISSIONS.length;
+ const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
+
+ if (isCustom) return Permission.Custom;
+ if (score === 0) return Permission.NoAccess;
+ if (score === totalActions) return Permission.FullAccess;
+ if (score === 1 && rule?.read) return Permission.ReadOnly;
+
+ return Permission.Custom;
+ }, [rule, isCustom]);
+
+ useEffect(() => {
+ if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+ }, [selectedPermissionCategory]);
+
+ const handlePermissionChange = (val: Permission) => {
+ if (val === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+
+ switch (val) {
+ case Permission.NoAccess:
+ setValue(
+ `permissions.${formName}`,
+ { read: false, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.FullAccess:
+ setValue(
+ `permissions.${formName}`,
+ { read: true, edit: true, create: true, delete: true },
+ { shouldDirty: true }
+ );
+ break;
+ case Permission.ReadOnly:
+ setValue(
+ `permissions.${formName}`,
+ { read: true, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ default:
+ setValue(
+ `permissions.${formName}`,
+ { read: false, edit: false, create: false, delete: false },
+ { shouldDirty: true }
+ );
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ No Access
+ Read Only
+ Full Access
+ Custom
+
+
+
+
+ {isCustom &&
+ PERMISSIONS.map(({ action, label }) => (
+ (
+
+ {label}
+
+ )}
+ />
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx
new file mode 100644
index 000000000..c08dbdb9b
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx
@@ -0,0 +1,125 @@
+import { useEffect, useMemo } from "react";
+import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
+import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { motion } from "framer-motion";
+import { twMerge } from "tailwind-merge";
+
+import { Checkbox, Select, SelectItem } from "@app/components/v2";
+import { useToggle } from "@app/hooks";
+
+import { TFormSchema } from "./ProjectRoleModifySection.utils";
+
+type Props = {
+ isNonEditable?: boolean;
+ setValue: UseFormSetValue;
+ control: Control;
+};
+
+enum Permission {
+ NoAccess = "no-access",
+ FullAccess = "full-acess",
+ Custom = "custom"
+}
+
+const PERMISSIONS = [
+ { action: "edit", label: "Update workspace details" },
+ { action: "delete", label: "Delete workspace" }
+] as const;
+
+export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
+ const rule = useWatch({
+ control,
+ name: "permissions.workspace"
+ });
+ const [isCustom, setIsCustom] = useToggle();
+
+ const selectedPermissionCategory = useMemo(() => {
+ const actions = Object.keys(rule || {}) as Array;
+ const totalActions = PERMISSIONS.length;
+ const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
+
+ if (isCustom) return Permission.Custom;
+ if (score === 0) return Permission.NoAccess;
+ if (score === totalActions) return Permission.FullAccess;
+
+ return Permission.Custom;
+ }, [rule, isCustom]);
+
+ useEffect(() => {
+ if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+ }, [selectedPermissionCategory]);
+
+ const handlePermissionChange = (val: Permission) => {
+ if (val === Permission.Custom) setIsCustom.on();
+ else setIsCustom.off();
+
+ switch (val) {
+ case Permission.NoAccess:
+ setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
+ break;
+ case Permission.FullAccess:
+ setValue("permissions.workspace", { edit: true, delete: true }, { shouldDirty: true });
+ break;
+ default:
+ setValue("permissions.workspace", { edit: false, delete: false }, { shouldDirty: true });
+ break;
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Project
+ Project control actions
+
+
+
+ No Access
+ Full Access
+ Custom
+
+
+
+
+ {isCustom &&
+ PERMISSIONS.map(({ action, label }) => (
+ (
+
+ {label}
+
+ )}
+ />
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx
new file mode 100644
index 000000000..b664a1d9b
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx
@@ -0,0 +1 @@
+export { ProjectRoleModifySection } from "./ProjectRoleModifySection";
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx
new file mode 100644
index 000000000..5dc87a2d7
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx
@@ -0,0 +1 @@
+export { ProjectRoleListTab } from "./ProjectRoleListTab";
diff --git a/frontend/src/views/Project/MembersPage/index.tsx b/frontend/src/views/Project/MembersPage/index.tsx
new file mode 100644
index 000000000..93d51dccd
--- /dev/null
+++ b/frontend/src/views/Project/MembersPage/index.tsx
@@ -0,0 +1 @@
+export { MembersPage } from "./MembersPage";
diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx
index 4d0c4e494..b5fa1dc1b 100644
--- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx
+++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx
@@ -2,11 +2,18 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
-import { faArrowDown, faArrowUp, faFolderBlank, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
+import { subject } from "@casl/ability";
+import {
+ faArrowDown,
+ faArrowUp,
+ faFolderBlank,
+ faMagnifyingGlass
+} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader";
+import { PermissionDeniedBanner } from "@app/components/permissions";
import {
Button,
EmptyState,
@@ -23,13 +30,18 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
-import { useOrganization, useWorkspace } from "@app/context";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useOrganization,
+ useProjectPermission,
+ useWorkspace
+} from "@app/context";
import {
useCreateSecretV3,
useDeleteSecretV3,
useGetFoldersByEnv,
useGetProjectSecretsAllEnv,
- useGetUserWsEnvironments,
useGetUserWsKey,
useUpdateSecretV3
} from "@app/hooks/api";
@@ -69,7 +81,8 @@ export const SecretOverviewPage = () => {
const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState("");
- const secretPath = router.query?.secretPath as string;
+ const secretPath = (router.query?.secretPath as string) || "/";
+ const permission = useProjectPermission();
useEffect(() => {
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
@@ -77,11 +90,22 @@ export const SecretOverviewPage = () => {
}
}, [isWorkspaceLoading, workspaceId, router.isReady]);
- const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
- workspaceId
- });
-
- const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied) || [];
+ const userAvailableEnvs =
+ currentWorkspace?.environments?.filter(
+ ({ slug }) =>
+ permission.can(
+ ProjectPermissionActions.Read,
+ subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath })
+ ) ||
+ permission.can(
+ ProjectPermissionActions.Read,
+ subject(ProjectPermissionSub.Folders, { environment: slug, secretPath })
+ ) ||
+ permission.can(
+ ProjectPermissionActions.Read,
+ subject(ProjectPermissionSub.SecretImports, { environment: slug, secretPath })
+ )
+ ) || [];
const {
data: secrets,
@@ -207,7 +231,7 @@ export const SecretOverviewPage = () => {
}
};
- if (isEnvListLoading) {
+ if (isWorkspaceLoading) {
return (

@@ -219,9 +243,11 @@ export const SecretOverviewPage = () => {
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
);
- const filteredSecretNames = secKeys?.filter((name) =>
- name.toUpperCase().includes(searchFilter.toUpperCase())
- ).sort((a, b) => sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a));
+ const canViewOverviewPage = Boolean(userAvailableEnvs.length);
+
+ const filteredSecretNames = secKeys
+ ?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase()))
+ .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const filteredFolderNames = folderNames?.filter((name) =>
name.toLowerCase().includes(searchFilter.toLowerCase())
);
@@ -280,7 +306,12 @@ export const SecretOverviewPage = () => {
Name
- setSortDir(prev => prev === "asc" ? "desc" : "asc")}>
+ setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
+ >
@@ -318,7 +349,7 @@ export const SecretOverviewPage = () => {
| |