mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(ui): completed ui user multi role with temporary access
This commit is contained in:
@@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root;
|
|||||||
|
|
||||||
export type PopoverContentProps = {
|
export type PopoverContentProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
arrowClassName?: string;
|
||||||
hideCloseBtn?: boolean;
|
hideCloseBtn?: boolean;
|
||||||
} & PopoverPrimitive.PopoverContentProps;
|
} & PopoverPrimitive.PopoverContentProps;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export const PopoverContent = ({
|
|||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
hideCloseBtn,
|
hideCloseBtn,
|
||||||
|
arrowClassName,
|
||||||
...props
|
...props
|
||||||
}: PopoverContentProps) => (
|
}: PopoverContentProps) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Portal>
|
||||||
@@ -48,7 +50,7 @@ export const PopoverContent = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</PopoverPrimitive.Close>
|
</PopoverPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
<PopoverPrimitive.Arrow className="fill-inherit" />
|
<PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
|
||||||
</PopoverPrimitive.Content>
|
</PopoverPrimitive.Content>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,7 +64,32 @@ export type TProjectMembership = {
|
|||||||
roleId: string;
|
roleId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWorkspaceUser = OrgUser;
|
export type TWorkspaceUser = {
|
||||||
|
id: string;
|
||||||
|
user: {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
id: string;
|
||||||
|
publicKey: string;
|
||||||
|
};
|
||||||
|
inviteEmail: string;
|
||||||
|
organization: string;
|
||||||
|
roles: {
|
||||||
|
id: string;
|
||||||
|
role: "owner" | "admin" | "member" | "no-access" | "custom";
|
||||||
|
customRoleId: string;
|
||||||
|
customRoleName: string;
|
||||||
|
customRoleSlug: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
temporaryMode: string | null;
|
||||||
|
temporaryRange: string | null;
|
||||||
|
temporaryAccessStartTime: string | null;
|
||||||
|
temporaryAccessEndTime: string | null;
|
||||||
|
}[];
|
||||||
|
status: "invited" | "accepted" | "verified" | "completed";
|
||||||
|
deniedPermissions: any[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AddUserToWsDTOE2EE = {
|
export type AddUserToWsDTOE2EE = {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
RenameWorkspaceDTO,
|
RenameWorkspaceDTO,
|
||||||
TGetUpgradeProjectStatusDTO,
|
TGetUpgradeProjectStatusDTO,
|
||||||
ToggleAutoCapitalizationDTO,
|
ToggleAutoCapitalizationDTO,
|
||||||
|
TUpdateWorkspaceUserRoleDTO,
|
||||||
UpdateEnvironmentDTO,
|
UpdateEnvironmentDTO,
|
||||||
Workspace
|
Workspace
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -340,27 +341,19 @@ export const useDeleteUserFromWorkspace = () => {
|
|||||||
export const useUpdateUserWorkspaceRole = () => {
|
export const useUpdateUserWorkspaceRole = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => {
|
||||||
membershipId,
|
|
||||||
role,
|
|
||||||
workspaceId
|
|
||||||
}: {
|
|
||||||
membershipId: string;
|
|
||||||
role: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}) => {
|
|
||||||
const {
|
const {
|
||||||
data: { membership }
|
data: { membership }
|
||||||
} = await apiRequest.patch<{ membership: { projectId: string } }>(
|
} = await apiRequest.patch<{ membership: { projectId: string } }>(
|
||||||
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
|
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
|
||||||
{
|
{
|
||||||
role
|
roles
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return membership;
|
return membership;
|
||||||
},
|
},
|
||||||
onSuccess: (res) => {
|
onSuccess: (_, { workspaceId }) => {
|
||||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId));
|
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export enum ProjectVersion {
|
|||||||
V2 = 2
|
V2 = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectUserMembershipTemporaryMode {
|
||||||
|
Relative = "relative"
|
||||||
|
}
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
__v: number;
|
__v: number;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -72,3 +76,21 @@ export type UpdateEnvironmentDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
|
export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
|
||||||
|
|
||||||
|
export type TUpdateWorkspaceUserRoleDTO = {
|
||||||
|
membershipId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
roles: (
|
||||||
|
| {
|
||||||
|
role: string;
|
||||||
|
isTemporary?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
role: string;
|
||||||
|
isTemporary: true;
|
||||||
|
temporaryMode: ProjectUserMembershipTemporaryMode;
|
||||||
|
temporaryRange: string;
|
||||||
|
temporaryAccessStartTime: string;
|
||||||
|
}
|
||||||
|
)[];
|
||||||
|
};
|
||||||
|
|||||||
15
frontend/src/lib/fn/array.ts
Normal file
15
frontend/src/lib/fn/array.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Sorts an array of items into groups. The return value is a map where the keys are
|
||||||
|
* the group ids the given getGroupId function produced and the value is an array of
|
||||||
|
* each item in that group.
|
||||||
|
*/
|
||||||
|
export const groupBy = <T, Key extends string | number | symbol>(
|
||||||
|
array: readonly T[],
|
||||||
|
getGroupId: (item: T) => Key
|
||||||
|
): Record<Key, T[]> =>
|
||||||
|
array.reduce((acc, item) => {
|
||||||
|
const groupId = getGroupId(item);
|
||||||
|
if (!acc[groupId]) acc[groupId] = [];
|
||||||
|
acc[groupId].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<Key, T[]>);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useOrganization,
|
useOrganization,
|
||||||
useSubscription,
|
|
||||||
useUser,
|
useUser,
|
||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
@@ -44,14 +43,13 @@ import {
|
|||||||
useAddUserToWsNonE2EE,
|
useAddUserToWsNonE2EE,
|
||||||
useDeleteUserFromWorkspace,
|
useDeleteUserFromWorkspace,
|
||||||
useGetOrgUsers,
|
useGetOrgUsers,
|
||||||
useGetProjectRoles,
|
|
||||||
useGetUserWsKey,
|
useGetUserWsKey,
|
||||||
useGetWorkspaceUsers,
|
useGetWorkspaceUsers
|
||||||
useUpdateUserWorkspaceRole
|
|
||||||
} from "@app/hooks/api";
|
} from "@app/hooks/api";
|
||||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
|
||||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
import { MemberRoles } from "./MemberRoles";
|
||||||
|
|
||||||
const addMemberFormSchema = z.object({
|
const addMemberFormSchema = z.object({
|
||||||
orgMembershipId: z.string().trim()
|
orgMembershipId: z.string().trim()
|
||||||
});
|
});
|
||||||
@@ -60,7 +58,6 @@ type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
|||||||
|
|
||||||
export const MemberListTab = () => {
|
export const MemberListTab = () => {
|
||||||
const { createNotification } = useNotificationContext();
|
const { createNotification } = useNotificationContext();
|
||||||
const { subscription } = useSubscription();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
@@ -71,8 +68,6 @@ export const MemberListTab = () => {
|
|||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
|
||||||
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
|
||||||
|
|
||||||
const { data: wsKey } = useGetUserWsKey(workspaceId);
|
const { data: wsKey } = useGetUserWsKey(workspaceId);
|
||||||
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
||||||
const { data: orgUsers } = useGetOrgUsers(orgId);
|
const { data: orgUsers } = useGetOrgUsers(orgId);
|
||||||
@@ -95,7 +90,6 @@ export const MemberListTab = () => {
|
|||||||
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
|
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
|
||||||
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
|
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
|
||||||
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
|
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
|
||||||
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
|
|
||||||
|
|
||||||
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
|
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
|
||||||
if (!currentWorkspace) return;
|
if (!currentWorkspace) return;
|
||||||
@@ -167,47 +161,6 @@ export const MemberListTab = () => {
|
|||||||
handlePopUpClose("removeMember");
|
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 === roleId);
|
|
||||||
},
|
|
||||||
[roles]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onRoleChange = async (membershipId: string, role: string) => {
|
|
||||||
if (!currentOrg?.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
|
|
||||||
role as ProjectMembershipRole
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
|
||||||
handlePopUpOpen("upgradePlan", {
|
|
||||||
description: "You can assign custom roles to members if you upgrade your Infisical plan."
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUserWorkspaceRole({ membershipId, role, workspaceId });
|
|
||||||
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(
|
const filterdUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
members?.filter(
|
members?.filter(
|
||||||
@@ -231,8 +184,6 @@ export const MemberListTab = () => {
|
|||||||
);
|
);
|
||||||
}, [orgUsers, members]);
|
}, [orgUsers, members]);
|
||||||
|
|
||||||
const isLoading = isMembersLoading || isRolesLoading;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||||
{!isLoading &&
|
{!isMembersLoading &&
|
||||||
filterdUsers?.map(
|
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
|
||||||
({ user: u, id: membershipId, roleId, role }) => {
|
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
||||||
const name = u ? `${u.firstName} ${u.lastName}` : "-";
|
const email = u?.email || inviteEmail;
|
||||||
const username = u?.username ?? "-";
|
|
||||||
return (
|
return (
|
||||||
<Tr key={`membership-${membershipId}`} className="w-full">
|
<Tr key={`membership-${membershipId}`} className="w-full">
|
||||||
<Td>{name}</Td>
|
<Td>{name}</Td>
|
||||||
<Td>{username}</Td>
|
<Td>{email}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={ProjectPermissionSub.Member}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<MemberRoles
|
||||||
|
roles={roles}
|
||||||
|
disableEdit={u.id === user?.id && isAllowed}
|
||||||
|
onOpenUpgradeModal={(description) =>
|
||||||
|
handlePopUpOpen("upgradePlan", { description })
|
||||||
|
}
|
||||||
|
membershipId={membershipId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{userId !== u?.id && (
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Edit}
|
I={ProjectPermissionActions.Delete}
|
||||||
a={ProjectPermissionSub.Member}
|
a={ProjectPermissionSub.Member}
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Select
|
<IconButton
|
||||||
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
|
size="lg"
|
||||||
isDisabled={userId === u?.id || !isAllowed}
|
colorSchema="danger"
|
||||||
className="w-40 bg-mineshaft-600"
|
variant="plain"
|
||||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
ariaLabel="update"
|
||||||
onValueChange={(selectedRole) =>
|
className="ml-4"
|
||||||
onRoleChange(membershipId, selectedRole)
|
isDisabled={userId === u?.id || !isAllowed}
|
||||||
}
|
onClick={() => handlePopUpOpen("removeMember", { email: u.email })}
|
||||||
>
|
>
|
||||||
{(roles || [])
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
.filter(({ slug }) =>
|
</IconButton>
|
||||||
slug === "owner" ? isIamOwner || role === "owner" : true
|
|
||||||
)
|
|
||||||
.map(({ slug, name: roleName }) => (
|
|
||||||
<SelectItem value={slug} key={`owner-option-${slug}`}>
|
|
||||||
{roleName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</Td>
|
)}
|
||||||
<Td>
|
</Td>
|
||||||
{userId !== u?.id && (
|
</Tr>
|
||||||
<ProjectPermissionCan
|
);
|
||||||
I={ProjectPermissionActions.Delete}
|
})}
|
||||||
a={ProjectPermissionSub.Member}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<IconButton
|
|
||||||
size="lg"
|
|
||||||
colorSchema="danger"
|
|
||||||
variant="plain"
|
|
||||||
ariaLabel="update"
|
|
||||||
className="ml-4"
|
|
||||||
isDisabled={userId === u?.id || !isAllowed}
|
|
||||||
onClick={() =>
|
|
||||||
handlePopUpOpen("removeMember", { username: u.username })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isLoading && filterdUsers?.length === 0 && (
|
{!isMembersLoading && filterdUsers?.length === 0 && (
|
||||||
<EmptyState title="No project members found" icon={faUsers} />
|
<EmptyState title="No project members found" icon={faUsers} />
|
||||||
)}
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FormControl,
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Spinner,
|
||||||
|
Tag,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useSubscription, useWorkspace } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
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";
|
||||||
|
import { groupBy } from "@app/lib/fn/array";
|
||||||
|
|
||||||
|
const temporaryRoleFormSchema = z.object({
|
||||||
|
temporaryRange: z.string().min(1, "Required")
|
||||||
|
});
|
||||||
|
|
||||||
|
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
|
||||||
|
|
||||||
|
type TTemporaryRoleFormProps = {
|
||||||
|
temporaryConfig?: {
|
||||||
|
isTemporary?: boolean;
|
||||||
|
temporaryAccessEndTime?: string | null;
|
||||||
|
temporaryAccessStartTime?: string | null;
|
||||||
|
temporaryRange?: string | null;
|
||||||
|
};
|
||||||
|
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
|
||||||
|
onRemoveTemporary: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemporaryRoleForm = ({
|
||||||
|
temporaryConfig: defaultValues = {},
|
||||||
|
onSetTemporary,
|
||||||
|
onRemoveTemporary
|
||||||
|
}: TTemporaryRoleFormProps) => {
|
||||||
|
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
|
||||||
|
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
|
||||||
|
resolver: zodResolver(temporaryRoleFormSchema),
|
||||||
|
values: {
|
||||||
|
temporaryRange: defaultValues.temporaryRange || "1h"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const isTemporaryFieldValue = defaultValues.isTemporary;
|
||||||
|
const isExpired =
|
||||||
|
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={popUp.setTempRole.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("setTempRole", isOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<IconButton ariaLabel="role-temp" variant="plain" size="md">
|
||||||
|
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faClock}
|
||||||
|
className={twMerge(
|
||||||
|
isTemporaryFieldValue && "text-primary",
|
||||||
|
isExpired && "text-red-600"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
arrowClassName="fill-gray-600"
|
||||||
|
side="right"
|
||||||
|
sideOffset={12}
|
||||||
|
hideCloseBtn
|
||||||
|
className="border border-gray-600 pt-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
|
||||||
|
Set Role Temporarily
|
||||||
|
</div>
|
||||||
|
{isExpired && <Tag colorSchema="red">Expired</Tag>}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="temporaryRange"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Validity"
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText={
|
||||||
|
<span>
|
||||||
|
1m, 2h, 3d.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-700"
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{isTemporaryFieldValue && (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
type="submit"
|
||||||
|
onClick={() => {
|
||||||
|
handleSubmit(({ temporaryRange }) => {
|
||||||
|
onSetTemporary({
|
||||||
|
temporaryRange,
|
||||||
|
temporaryAccessStartTime: new Date().toISOString()
|
||||||
|
});
|
||||||
|
handlePopUpToggle("setTempRole");
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isTemporaryFieldValue ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
type="submit"
|
||||||
|
onClick={() =>
|
||||||
|
handleSubmit(({ temporaryRange }) => {
|
||||||
|
onSetTemporary({
|
||||||
|
temporaryRange,
|
||||||
|
temporaryAccessStartTime:
|
||||||
|
defaultValues.temporaryAccessStartTime || new Date().toISOString()
|
||||||
|
});
|
||||||
|
handlePopUpToggle("setTempRole");
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Give access
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
onClick={() => {
|
||||||
|
onRemoveTemporary();
|
||||||
|
handlePopUpToggle("setTempRole");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revoke Access
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.record(
|
||||||
|
z.object({
|
||||||
|
isChecked: z.boolean().optional(),
|
||||||
|
temporaryAccess: z.union([
|
||||||
|
z.object({
|
||||||
|
isTemporary: z.literal(true),
|
||||||
|
temporaryRange: z.string().min(1),
|
||||||
|
temporaryAccessStartTime: z.string().datetime(),
|
||||||
|
temporaryAccessEndTime: z.string().datetime().nullable().optional()
|
||||||
|
}),
|
||||||
|
z.boolean()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
);
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export type TMemberRolesProp = {
|
||||||
|
disableEdit?: boolean;
|
||||||
|
membershipId: string;
|
||||||
|
onOpenUpgradeModal: (description: string) => void;
|
||||||
|
roles: TWorkspaceUser["roles"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||||
|
|
||||||
|
export const MemberRoles = ({
|
||||||
|
roles = [],
|
||||||
|
disableEdit = false,
|
||||||
|
membershipId,
|
||||||
|
onOpenUpgradeModal
|
||||||
|
}: TMemberRolesProp) => {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
|
||||||
|
const [searchRoles, setSearchRoles] = useState("");
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { isSubmitting, isDirty }
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
|
||||||
|
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||||
|
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
|
||||||
|
|
||||||
|
const updateMembershipRole = useUpdateUserWorkspaceRole();
|
||||||
|
|
||||||
|
const handleRoleUpdate = async (data: TForm) => {
|
||||||
|
const selectedRoles = Object.keys(data)
|
||||||
|
.filter((el) => Boolean(data[el].isChecked))
|
||||||
|
.map((el) => {
|
||||||
|
const isTemporary = Boolean(data[el].temporaryAccess);
|
||||||
|
if (!isTemporary) {
|
||||||
|
return { role: el, isTemporary: false as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempCfg = data[el].temporaryAccess as {
|
||||||
|
temporaryRange: string;
|
||||||
|
temporaryAccessStartTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
role: el,
|
||||||
|
isTemporary: true as const,
|
||||||
|
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||||
|
temporaryRange: tempCfg.temporaryRange,
|
||||||
|
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCustomRoleSelected = selectedRoles.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,
|
||||||
|
roles: selectedRoles
|
||||||
|
});
|
||||||
|
createNotification({ text: "Successfully updated role", type: "success" });
|
||||||
|
handlePopUpToggle("editRole");
|
||||||
|
setSearchRoles("");
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({ text: "Failed to update role", type: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||||
|
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||||
|
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{roles
|
||||||
|
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||||
|
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||||
|
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||||
|
return (
|
||||||
|
<Tag key={id} className="capitalize">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div>{formatRoleName(role, customRoleName)}</div>
|
||||||
|
{isTemporary && (
|
||||||
|
<div>
|
||||||
|
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faClock}
|
||||||
|
className={twMerge(isExpired && "text-red-600")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
|
||||||
|
<HoverCard>
|
||||||
|
<HoverCardTrigger>
|
||||||
|
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
|
||||||
|
{roles
|
||||||
|
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
|
||||||
|
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
|
||||||
|
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
|
||||||
|
return (
|
||||||
|
<Tag key={id} className="capitalize">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div>{formatRoleName(role, customRoleName)}</div>
|
||||||
|
{isTemporary && (
|
||||||
|
<div>
|
||||||
|
<Tooltip
|
||||||
|
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faClock}
|
||||||
|
className={twMerge(
|
||||||
|
new Date() > new Date(temporaryAccessEndTime as string) &&
|
||||||
|
"text-red-600"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}{" "}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Popover
|
||||||
|
open={popUp.editRole.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("editRole", isOpen);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!disableEdit && (
|
||||||
|
<PopoverTrigger>
|
||||||
|
<IconButton size="sm" variant="plain" ariaLabel="update">
|
||||||
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
|
</IconButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
)}
|
||||||
|
<PopoverContent hideCloseBtn className="pt-4">
|
||||||
|
{isRolesLoading ? (
|
||||||
|
<div className="flex h-8 w-full items-center justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
|
||||||
|
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
|
||||||
|
{projectRoles
|
||||||
|
?.filter(
|
||||||
|
({ name, slug }) =>
|
||||||
|
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
|
||||||
|
slug.toLowerCase().includes(searchRoles.toLowerCase())
|
||||||
|
)
|
||||||
|
?.map(({ id, name, slug }) => {
|
||||||
|
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="flex items-center space-x-4">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue={Boolean(userProjectRoleDetails?.id)}
|
||||||
|
name={`${slug}.isChecked`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
id={slug}
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(isChecked) => {
|
||||||
|
field.onChange(isChecked);
|
||||||
|
setValue(`${slug}.temporaryAccess`, false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`${slug}.temporaryAccess`}
|
||||||
|
defaultValue={
|
||||||
|
userProjectRoleDetails?.isTemporary
|
||||||
|
? {
|
||||||
|
isTemporary: true,
|
||||||
|
temporaryAccessStartTime:
|
||||||
|
userProjectRoleDetails.temporaryAccessStartTime as string,
|
||||||
|
temporaryRange:
|
||||||
|
userProjectRoleDetails.temporaryRange as string,
|
||||||
|
temporaryAccessEndTime:
|
||||||
|
userProjectRoleDetails.temporaryAccessEndTime
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TemporaryRoleForm
|
||||||
|
temporaryConfig={
|
||||||
|
typeof field.value === "boolean"
|
||||||
|
? { isTemporary: field.value }
|
||||||
|
: field.value
|
||||||
|
}
|
||||||
|
onSetTemporary={(data) => {
|
||||||
|
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
|
||||||
|
console.log(data);
|
||||||
|
field.onChange({ isTemporary: true, ...data });
|
||||||
|
}}
|
||||||
|
onRemoveTemporary={() => {
|
||||||
|
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
|
||||||
|
field.onChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className="w-full p-1.5 pl-8"
|
||||||
|
size="xs"
|
||||||
|
value={searchRoles}
|
||||||
|
onChange={(el) => setSearchRoles(el.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||||
|
placeholder="Search roles.."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
type="submit"
|
||||||
|
form="role-update-form"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
||||||
|
isDisabled={!isDirty || isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user