diff --git a/frontend/src/helpers/roles.ts b/frontend/src/helpers/roles.ts
index afb9e1274..e1c3556cc 100644
--- a/frontend/src/helpers/roles.ts
+++ b/frontend/src/helpers/roles.ts
@@ -4,5 +4,17 @@ enum OrgMembershipRole {
NoAccess = "no-access"
}
+enum ProjectMemberRole {
+ Admin = "admin",
+ Member = "member",
+ Viewer = "viewer",
+ NoAccess = "no-access"
+}
+
export const isCustomOrgRole = (slug: string) =>
!Object.values(OrgMembershipRole).includes(slug as OrgMembershipRole);
+
+export const formatProjectRoleName = (name: string) => {
+ if (name === ProjectMemberRole.Member) return "developer";
+ return name;
+};
diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx
index af6524fe0..391b2ab6a 100644
--- a/frontend/src/hooks/api/workspace/queries.tsx
+++ b/frontend/src/hooks/api/workspace/queries.tsx
@@ -420,8 +420,11 @@ export const useUpdateUserWorkspaceRole = () => {
);
return membership;
},
- onSuccess: (_, { workspaceId }) => {
+ onSuccess: (_, { workspaceId, membershipId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
+ queryClient.invalidateQueries(
+ workspaceKeys.getWorkspaceUserDetails(workspaceId, membershipId)
+ );
}
});
};
diff --git a/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx b/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx
index 509b34e0f..258988d71 100644
--- a/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx
+++ b/frontend/src/views/Project/MemberDetailsPage/MemberDetailPage.tsx
@@ -40,7 +40,7 @@ export const MemberDetailsPage = withProjectPermission(
Project User Management
diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx
index d4f488dcf..3f498aa8b 100644
--- a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx
+++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleDetailsSection.tsx
@@ -1,4 +1,5 @@
import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
IconButton,
@@ -11,20 +12,27 @@ import {
TableSkeleton,
EmptyState,
TBody,
- Tooltip
+ Tooltip,
+ Modal,
+ ModalContent,
+ Tag
} from "@app/components/v2";
-import { useUser, useWorkspace } from "@app/context";
-import { usePopUp } from "@app/hooks";
import {
- useDeleteProjectRole,
- useGetWorkspaceUserDetails,
- useUpdateUserWorkspaceRole
-} from "@app/hooks/api";
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useUser,
+ useWorkspace
+} from "@app/context";
+import { formatProjectRoleName } from "@app/helpers/roles";
+import { usePopUp } from "@app/hooks";
+import { useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
-import { faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { useRouter } from "next/router";
+import { format, formatDistance } from "date-fns";
+import { twMerge } from "tailwind-merge";
+import { MemberRoleModify } from "./MemberRoleModify";
type Props = {
membershipDetails: TWorkspaceUser;
@@ -35,11 +43,11 @@ export const MemberRoleDetailsSection = ({
membershipDetails,
isMembershipDetailsLoading
}: Props) => {
- const router = useRouter();
const { user } = useUser();
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
- "deleteRole"
+ "deleteRole",
+ "modifyRole"
] as const);
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
@@ -67,14 +75,26 @@ export const MemberRoleDetailsSection = ({
Project Roles
{userId !== membershipDetails?.user?.id && membershipDetails?.status !== "invited" && (
-
{}}
+
-
-
+ {(isAllowed) => (
+
{
+ handlePopUpOpen("modifyRole");
+ }}
+ isDisabled={!isAllowed}
+ >
+
+
+ )}
+
)}
@@ -93,33 +113,76 @@ export const MemberRoleDetailsSection = ({
)}
{!isMembershipDetailsLoading &&
membershipDetails?.roles?.map((roleDetails) => {
+ const isTemporary = roleDetails?.isTemporary;
+ const isExpired =
+ roleDetails.isTemporary &&
+ new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
+
+ let text = "Permanent";
+ let toolTipText = "Non expiry access";
+ if (roleDetails.isTemporary) {
+ if (isExpired) {
+ text = "Access Expired";
+ toolTipText = "Timed Access Expired";
+ } else {
+ text = formatDistance(
+ new Date(roleDetails.temporaryAccessEndTime || ""),
+ new Date()
+ );
+ toolTipText = `Until ${format(
+ new Date(roleDetails.temporaryAccessEndTime || ""),
+ "yyyy-MM-dd hh:mm:ss aaa"
+ )}`;
+ }
+ }
+
return (
- |
+ |
{roleDetails.role === "custom"
? roleDetails.customRoleName
- : roleDetails.role}
+ : formatProjectRoleName(roleDetails.role)}
+ |
+
+
+
+ {text}
+
+
|
- {!roleDetails.isTemporary ? "permanent" : "temporary"} |
-
- {
- e.stopPropagation();
- handlePopUpOpen("deleteRole", {
- id: roleDetails?.id,
- slug: roleDetails?.customRoleSlug || roleDetails?.role
- });
- }}
- >
-
-
-
+
+ {(isAllowed) => (
+ {
+ e.stopPropagation();
+ handlePopUpOpen("deleteRole", {
+ id: roleDetails?.id,
+ slug: roleDetails?.customRoleName || roleDetails?.role
+ });
+ }}
+ >
+
+
+ )}
+
|
@@ -139,6 +202,14 @@ export const MemberRoleDetailsSection = ({
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
onDeleteApproved={() => handleRoleDelete()}
/>
+
handlePopUpToggle("modifyRole", isOpen)}
+ >
+
+ {}} />
+
+
);
};
diff --git a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx
index e69de29bb..973e2ac13 100644
--- a/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx
+++ b/frontend/src/views/Project/MemberDetailsPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx
@@ -0,0 +1,346 @@
+/* eslint-disable no-nested-ternary */
+import { Controller, useFieldArray, useForm } from "react-hook-form";
+import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { format, formatDistance } from "date-fns";
+import ms from "ms";
+import { twMerge } from "tailwind-merge";
+import { z } from "zod";
+
+import { TtlFormLabel } from "@app/components/features";
+import { createNotification } from "@app/components/notifications";
+import { ProjectPermissionCan } from "@app/components/permissions";
+import {
+ Button,
+ FormControl,
+ IconButton,
+ Input,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ Select,
+ SelectItem,
+ Spinner,
+ Tag,
+ Tooltip
+} from "@app/components/v2";
+import {
+ ProjectPermissionActions,
+ ProjectPermissionSub,
+ useProjectPermission,
+ useSubscription,
+ useWorkspace
+} from "@app/context";
+import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
+import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
+import { TWorkspaceUser } from "@app/hooks/api/types";
+import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
+
+const roleFormSchema = z.object({
+ roles: z
+ .object({
+ slug: z.string(),
+ temporaryAccess: z.discriminatedUnion("isTemporary", [
+ z.object({
+ isTemporary: z.literal(true),
+ temporaryRange: z.string().min(1),
+ temporaryAccessStartTime: z.string().datetime(),
+ temporaryAccessEndTime: z.string().datetime().nullable().optional()
+ }),
+ z.object({
+ isTemporary: z.literal(false)
+ })
+ ])
+ })
+ .array()
+});
+type TRoleForm = z.infer;
+
+type Props = {
+ projectMember: TWorkspaceUser;
+ onOpenUpgradeModal: (title: string) => void;
+};
+
+export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => {
+ const { subscription } = useSubscription();
+ const { currentWorkspace } = useWorkspace();
+ const workspaceId = currentWorkspace?.id || "";
+ const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
+ const { permission } = useProjectPermission();
+ const isMemberEditDisabled = permission.cannot(
+ ProjectPermissionActions.Edit,
+ ProjectPermissionSub.Member
+ );
+
+ const roleForm = useForm({
+ resolver: zodResolver(roleFormSchema),
+ values: {
+ roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
+ slug: customRoleSlug || role,
+ temporaryAccess: dto.isTemporary
+ ? {
+ isTemporary: true,
+ temporaryRange: dto.temporaryRange,
+ temporaryAccessEndTime: dto.temporaryAccessEndTime,
+ temporaryAccessStartTime: dto.temporaryAccessStartTime
+ }
+ : {
+ isTemporary: dto.isTemporary
+ }
+ }))
+ }
+ });
+ const selectedRoleList = useFieldArray({
+ name: "roles",
+ control: roleForm.control
+ });
+
+ const formRoleField = roleForm.watch("roles");
+
+ const updateMembershipRole = useUpdateUserWorkspaceRole();
+
+ const handleRoleUpdate = async (data: TRoleForm) => {
+ if (updateMembershipRole.isLoading) return;
+
+ const sanitizedRoles = data.roles.map((el) => {
+ const { isTemporary } = el.temporaryAccess;
+ if (!isTemporary) {
+ return { role: el.slug, isTemporary: false as const };
+ }
+ return {
+ role: el.slug,
+ isTemporary: true as const,
+ temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
+ temporaryRange: el.temporaryAccess.temporaryRange,
+ temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
+ };
+ });
+
+ const hasCustomRoleSelected = sanitizedRoles.some(
+ (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
+ );
+
+ if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
+ onOpenUpgradeModal(
+ "You can assign custom roles to members if you upgrade your Infisical plan."
+ );
+ return;
+ }
+
+ try {
+ await updateMembershipRole.mutateAsync({
+ workspaceId,
+ membershipId: projectMember.id,
+ roles: sanitizedRoles
+ });
+ createNotification({ text: "Successfully updated roles", type: "success" });
+ } catch (err) {
+ createNotification({ text: "Failed to update role", type: "error" });
+ }
+ };
+
+ if (isRolesLoading)
+ return (
+
+
+
+ );
+
+ return (
+
+ );
+};