Merge pull request #1574 from akhilmhdh/feat/additional-privilege

feat: additional privilege for users and identity
This commit is contained in:
Maidul Islam
2024-04-01 11:09:05 -07:00
committed by GitHub
59 changed files with 4332 additions and 1266 deletions
+1
View File
@@ -20,6 +20,7 @@ const tagVariants = cva(
green: "bg-primary-800 text-white"
},
size: {
xs: "text-xs px-1 py-0.5",
sm: "px-2 py-0.5"
}
}
+24 -12
View File
@@ -29,18 +29,30 @@ export type IdentityMembershipOrg = {
export type IdentityMembership = {
id: string;
identity: Identity;
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;
}[];
roles: Array<
{
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
} & (
| {
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)
>;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,7 @@
export {
useCreateIdentityProjectAdditionalPrivilege,
useDeleteIdentityProjectAdditionalPrivilege,
useUpdateIdentityProjectAdditionalPrivilege
} from "./mutation";
export { useGetIdentityProjectPrivilegeDetails } from "./queries";
export type { TIdentityProjectPrivilege } from "./types";
@@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { identitiyProjectPrivilegeKeys } from "./queries";
import {
TCreateIdentityProjectPrivilegeDTO,
TDeleteIdentityProjectPrivilegeDTO,
TIdentityProjectPrivilege,
TUpdateIdentityProjectPrivlegeDTO
} from "./types";
export const useCreateIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TCreateIdentityProjectPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post(
"/api/v1/additional-privilege/identity/permanent",
dto
);
return data.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};
export const useUpdateIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TUpdateIdentityProjectPrivlegeDTO>({
mutationFn: async ({ privilegeSlug, projectSlug, identityId, privilegeDetails }) => {
const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", {
privilegeSlug,
projectSlug,
identityId,
privilegeDetails
});
return res.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};
export const useDeleteIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TDeleteIdentityProjectPrivilegeDTO>({
mutationFn: async ({ identityId, projectSlug, privilegeSlug }) => {
const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", {
data: {
identityId,
projectSlug,
privilegeSlug
}
});
return data.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};
@@ -0,0 +1,77 @@
import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TProjectPermission } from "../roles/types";
import {
TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails,
TIdentityProjectPrivilege,
TListIdentityUserPrivileges as TListIdentityProjectPrivileges
} from "./types";
export const identitiyProjectPrivilegeKeys = {
details: ({ identityId, privilegeSlug, projectSlug }: TGetIdentityProjectPrivilegeDetails) =>
[
"identity-user-privilege",
{
identityId,
projectSlug,
privilegeSlug
}
] as const,
list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) =>
["identity-user-privileges", { identityId, projectSlug }] as const
};
export const useGetIdentityProjectPrivilegeDetails = ({
projectSlug,
identityId,
privilegeSlug
}: TGetIdentityProjectPrivilegeDetails) => {
return useQuery({
enabled: Boolean(projectSlug && identityId && privilegeSlug),
queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, privilegeSlug, identityId }),
queryFn: async () => {
const {
data: { privilege }
} = await apiRequest.get<{
privilege: Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, {
params: {
identityId,
projectSlug
}
});
return {
...privilege,
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
};
}
});
};
export const useListIdentityProjectPrivileges = ({
projectSlug,
identityId
}: TListIdentityProjectPrivileges) => {
return useQuery({
enabled: Boolean(projectSlug && identityId),
queryKey: identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }),
queryFn: async () => {
const {
data: { privileges }
} = await apiRequest.get<{
privileges: Array<
Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown }
>;
}>("/api/v1/additional-privilege/identity", {
params: { identityId, projectSlug, unpacked: false }
});
return privileges.map((el) => ({
...el,
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
}));
}
});
};
@@ -0,0 +1,64 @@
import { TProjectPermission } from "../roles/types";
export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TIdentityProjectPrivilege = {
projectMembershipId: string;
slug: string;
id: string;
createdAt: Date;
updatedAt: Date;
permissions?: TProjectPermission[];
} & (
| {
isTemporary: true;
temporaryMode: string;
temporaryRange: string;
temporaryAccessStartTime: string;
temporaryAccessEndTime?: string;
}
| {
isTemporary: false;
temporaryMode?: null;
temporaryRange?: null;
temporaryAccessStartTime?: null;
temporaryAccessEndTime?: null;
}
);
export type TCreateIdentityProjectPrivilegeDTO = {
identityId: string;
projectSlug: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
permissions: TProjectPermission[];
};
export type TUpdateIdentityProjectPrivlegeDTO = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
privilegeDetails: Partial<Omit<TCreateIdentityProjectPrivilegeDTO, "projectMembershipId" | "projectId">>;
};
export type TDeleteIdentityProjectPrivilegeDTO = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
};
export type TListIdentityUserPrivileges = {
projectSlug: string;
identityId: string;
};
export type TGetIdentityProejctPrivilegeDetails = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
};
+2
View File
@@ -6,12 +6,14 @@ export * from "./bots";
export * from "./dynamicSecret";
export * from "./dynamicSecretLease";
export * from "./identities";
export * from "./identityProjectAdditionalPrivilege";
export * from "./incidentContacts";
export * from "./integrationAuth";
export * from "./integrations";
export * from "./keys";
export * from "./ldapConfig";
export * from "./organization";
export * from "./projectUserAdditionalPrivilege";
export * from "./roles";
export * from "./scim";
export * from "./secretApproval";
@@ -0,0 +1,7 @@
export {
useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege,
useUpdateProjectUserAdditionalPrivilege
} from "./mutation";
export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries";
export type { TProjectUserPrivilege } from "./types";
@@ -0,0 +1,62 @@
import { packRules } from "@casl/ability/extra";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { projectUserPrivilegeKeys } from "./queries";
import {
TCreateProjectUserPrivilegeDTO,
TDeleteProjectUserPrivilegeDTO,
TProjectUserPrivilege,
TUpdateProjectUserPrivlegeDTO
} from "./types";
export const useCreateProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", {
...dto,
permissions: packRules(dto.permissions)
});
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
export const useUpdateProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.patch(
`/api/v1/additional-privilege/users/${dto.privilegeId}`,
{ ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined }
);
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
export const useDeleteProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.delete(
`/api/v1/additional-privilege/users/${dto.privilegeId}`
);
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
@@ -0,0 +1,51 @@
import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TProjectPermission } from "../roles/types";
import { TProjectUserPrivilege } from "./types";
export const projectUserPrivilegeKeys = {
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const,
list: (projectMembershipId: string) =>
["project-user-privileges", { projectMembershipId }] as const
};
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
const {
data: { privilege }
} = await apiRequest.get<{
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/users/${privilegeId}`);
return {
...privilege,
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
};
};
export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
return useQuery({
enabled: Boolean(privilegeId),
queryKey: projectUserPrivilegeKeys.details(privilegeId),
queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId)
});
};
export const useListProjectUserPrivileges = (projectMembershipId: string) => {
return useQuery({
enabled: Boolean(projectMembershipId),
queryKey: projectUserPrivilegeKeys.list(projectMembershipId),
queryFn: async () => {
const {
data: { privileges }
} = await apiRequest.get<{
privileges: Array<Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }>;
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
return privileges.map((el) => ({
...el,
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
}));
}
});
};
@@ -0,0 +1,57 @@
import { TProjectPermission } from "../roles/types";
export enum ProjectUserAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TProjectUserPrivilege = {
projectMembershipId: string;
slug: string;
id: string;
createdAt: Date;
updatedAt: Date;
permissions?: TProjectPermission[];
} & (
| {
isTemporary: true;
temporaryMode: string;
temporaryRange: string;
temporaryAccessStartTime: string;
temporaryAccessEndTime?: string;
}
| {
isTemporary: false;
temporaryMode?: null;
temporaryRange?: null;
temporaryAccessStartTime?: null;
temporaryAccessEndTime?: null;
}
);
export type TCreateProjectUserPrivilegeDTO = {
projectMembershipId: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
permissions: TProjectPermission[];
};
export type TUpdateProjectUserPrivlegeDTO = {
privilegeId: string;
projectMembershipId: string;
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
export type TDeleteProjectUserPrivilegeDTO = {
privilegeId: string;
projectMembershipId: string;
};
export type TGetProjectUserPrivilegeDetails = {
privilegeId: string;
};
export type TListProjectUserPrivileges = {
projectMembershipId: string;
};
+26 -12
View File
@@ -76,18 +76,32 @@ export type TWorkspaceUser = {
};
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;
}[];
roles: (
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
};
@@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission(
<Tab value={TabSections.Roles}>Project Roles</Tab>
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab />
</motion.div>
<MemberListTab />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityTab />
@@ -1,17 +1,351 @@
import Link from "next/link";
import {
faArrowUpRightFromSquare,
faClock,
faEdit,
faPlus,
faServer,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { IdentitySection } from "./components";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const IdentityTab = () => {
return (
<motion.div
key="panel-identity"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<IdentitySection />
</motion.div>
);
import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
export const IdentityTab = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? "";
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"deleteIdentity",
"upgradePlan",
"updateRole"
] as const);
const onRemoveIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add identity
</Button>
)}
</ProjectPermissionCan>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map((identityMember, index) => {
const {
identity: { id, name },
roles,
createdAt
} = identityMember;
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{formatRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Timed role expired"
: "Timed role 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: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() >
new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Access expired"
: "Temporary access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() >
new Date(
temporaryAccessEndTime as string
) && "text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
)}
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...identityMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState
title="No identities have been added to this project"
icon={faServer}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-3xl"
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name
}`}
subTitle={`
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
`}
>
<IdentityRoleForm
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
}
identityProjectMember={
data?.[
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index
] as IdentityMembership
}
/>
</ModalContent>
</Modal>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
</motion.div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
);
@@ -0,0 +1,353 @@
/* 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, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/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<typeof roleFormSchema>;
type Props = {
identityProjectMember: IdentityMembership;
onOpenUpgradeModal: (title: string) => void;
};
export const IdentityRbacSection = ({ identityProjectMember, 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.Identity
);
const roleForm = useForm<TRoleForm>({
resolver: zodResolver(roleFormSchema),
values: {
roles: identityProjectMember?.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 = useUpdateIdentityWorkspaceRole();
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,
identityId: identityProjectMember.identity.id,
roles: sanitizedRoles
});
createNotification({ text: "Successfully updated roles", type: "success" });
roleForm.reset(undefined, { keepValues: true });
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<div>
<div className="text-lg font-medium">Roles</div>
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
<div>
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non expiry access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</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">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"opacity-0",
roleForm.formState.isDirty && "opacity-100"
)}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,20 @@
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { IdentityRbacSection } from "./IdentityRbacSection";
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
type Props = {
identityProjectMember: IdentityMembership;
onOpenUpgradeModal: (title: string) => void;
};
export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
return (
<div>
<IdentityRbacSection
identityProjectMember={identityProjectMember}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
<SpecificPrivilegeSection identityId={identityProjectMember?.identity?.id} />
</div>
);
};
@@ -0,0 +1,532 @@
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
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,
Checkbox,
DeleteActionModal,
FormControl,
FormLabel,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
useCreateIdentityProjectAdditionalPrivilege,
useDeleteIdentityProjectAdditionalPrivilege,
useUpdateIdentityProjectAdditionalPrivilege
} from "@app/hooks/api";
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
const secretPermissionSchema = z.object({
secretPath: z.string().optional(),
environmentSlug: z.string(),
[ProjectPermissionActions.Edit]: z.boolean().optional(),
[ProjectPermissionActions.Read]: z.boolean().optional(),
[ProjectPermissionActions.Create]: z.boolean().optional(),
[ProjectPermissionActions.Delete]: z.boolean().optional(),
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)
})
])
});
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
const SpecificPrivilegeSecretForm = ({
privilege,
identityId
}: {
privilege: TProjectUserPrivilege;
identityId: string;
}) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege"
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
);
const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema),
values: {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read)
),
edit: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Edit)
),
create: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Create)
),
delete: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Delete)
),
// zod will pick it
temporaryAccess: privilege
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
if (updateIdentityPrivilege.isLoading) return;
try {
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await updateIdentityPrivilege.mutateAsync({
privilegeDetails: {
...data.temporaryAccess,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
},
privilegeSlug: privilege.slug,
identityId,
projectSlug
});
createNotification({
type: "success",
text: "Successfully updated privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update privilege"
});
}
};
const handleDeletePrivilege = async () => {
if (deleteIdentityPrivilege.isLoading) return;
try {
await deleteIdentityPrivilege.mutateAsync({
identityId,
privilegeSlug: privilege.slug,
projectSlug
});
createNotification({
type: "success",
text: "Successfully deleted privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime)
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
<div className="mt-4">
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
<div className="flex items-start space-x-4">
<Controller
control={privilegeForm.control}
name="environmentSlug"
render={({ field: { onChange, ...field } }) => (
<FormControl label="Env">
<Select
{...field}
isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)}
>
{currentWorkspace?.environments?.map(({ slug, id }) => (
<SelectItem value={slug} key={id}>
{slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={privilegeForm.control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path">
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
</FormControl>
)}
/>
<div className="flex flex-grow justify-between">
<Controller
control={privilegeForm.control}
name="read"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="View" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-read"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="create"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Create" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-create"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="edit"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Modify" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-modify"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="delete"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Delete" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-delete"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-2">
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip content={getAccessLabel(true)}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel()}
</Button>
</Tooltip>
</div>
</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">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={privilegeForm.control}
defaultValue="1h"
name="temporaryAccess.temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = privilegeForm.getValues(
"temporaryAccess.temporaryRange"
);
if (!temporaryRange) {
privilegeForm.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
privilegeForm.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccessField.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
privilegeForm.setValue("temporaryAccess", {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{privilegeForm.formState.isDirty ? (
<>
<Tooltip content="Cancel" className="mr-4">
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
</IconButton>
</Tooltip>
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Save"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-3"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
) : (
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
)}
</IconButton>
</Tooltip>
</>
) : (
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
</div>
</form>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title="Remove user additional privilege"
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onClose={() => handlePopUpClose("deletePrivilege")}
onDeleteApproved={handleDeletePrivilege}
/>
</div>
);
};
type Props = {
identityId: string;
};
export const SpecificPrivilegeSection = ({ identityId }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({
identityId,
projectSlug
});
const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege();
const handleCreatePrivilege = async () => {
if (createIdentityPrivilege.isLoading) return;
try {
await createIdentityPrivilege.mutateAsync({
permissions: [
{
action: ProjectPermissionActions.Read,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: currentWorkspace?.environments?.[0].slug
}
}
],
identityId,
projectSlug
});
createNotification({
type: "success",
text: "Successfully created privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to create privilege"
});
}
};
return (
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
<div className="flex items-center space-x-2 text-lg font-medium">
Additional Privileges
{isLoading && <Spinner size="xs" />}
</div>
<p className="mt-0.5 text-sm text-mineshaft-400">
Select individual privileges to associate with the identity.
</p>
<div>
{identityPrivileges
?.filter(({ permissions }) =>
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
)
?.map((privilege) => (
<SpecificPrivilegeSecretForm
privilege={privilege as TProjectUserPrivilege}
identityId={identityId}
key={privilege?.id}
/>
))}
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
{(isAllowed) => (
<Button
variant="outline_bg"
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreatePrivilege}
isLoading={createIdentityPrivilege.isLoading}
isDisabled={!isAllowed}
>
Add additional privilege
</Button>
)}
</ProjectPermissionCan>
</div>
);
};
@@ -0,0 +1 @@
export { IdentityRoleForm } from "./IdentityRoleForm";
@@ -1,459 +0,0 @@
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 { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } 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 IdentityTemporaryRoleForm = ({
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");
})()
}
>
Grant 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;
identityId: string;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const IdentityRoles = ({
roles = [],
disableEdit = false,
identityId
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
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 updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
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
};
});
try {
await updateIdentityWorkspaceRole.mutateAsync({
workspaceId,
identityId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated identity role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update identity 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 }) => (
<IdentityTemporaryRoleForm
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>
);
};
@@ -1,107 +0,0 @@
import Link from "next/link";
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
export const IdentitySection = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"deleteIdentity",
"upgradePlan"
] as const);
const onRemoveIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add identity
</Button>
)}
</ProjectPermissionCan>
</div>
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
);
@@ -1,108 +0,0 @@
import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityRoles } from "./IdentityRoles";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
data?: {
identityId?: string;
name?: string;
}
) => void;
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(({ identity: { id, name }, roles, createdAt }) => {
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState title="No identities have been added to this project" icon={faServer} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -1 +0,0 @@
export { IdentitySection } from "./IdentitySection";
@@ -1 +0,0 @@
export { IdentitySection } from "./IdentitySection";
@@ -2,9 +2,18 @@ import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import {
faClock,
faEdit,
faMagnifyingGlass,
faPlus,
faUsers,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -14,6 +23,9 @@ import {
DeleteActionModal,
EmptyState,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Modal,
@@ -23,10 +35,12 @@ import {
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr,
UpgradePlanModal
} from "@app/components/v2";
@@ -46,9 +60,11 @@ import {
useGetUserWsKey,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { MemberRoles } from "./MemberRoles";
import { MemberRoleForm } from "./MemberRoleForm";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -56,8 +72,15 @@ const addMemberFormSchema = z.object({
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
export const MemberListTab = () => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
@@ -77,7 +100,8 @@ export const MemberListTab = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan"
"upgradePlan",
"updateRole"
] as const);
const {
@@ -186,7 +210,14 @@ export const MemberListTab = () => {
}, [orgUsers, members]);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<motion.div
key="user-role-1"
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Members</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
@@ -223,7 +254,8 @@ export const MemberListTab = () => {
<TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
filterdUsers?.map((projectMember, index) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
@@ -232,44 +264,136 @@ export const MemberListTab = () => {
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<MemberRoles
roles={roles}
disableEdit={u.id === user?.id || !isAllowed}
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
<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}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{formatRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired ? "Timed role expired" : "Timed role access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
membershipId={membershipId}
/>
)}
{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
? "Access expired"
: "Temporary access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() >
new Date(
temporaryAccessEndTime as string
) && "text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
)}
</ProjectPermissionCan>
{userId !== u?.id && (
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...projectMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
)}
</div>
</Td>
<Td>
{userId !== u?.id && (
<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>
<div className="flex items-center space-x-2">
<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>
</div>
)}
</Td>
</Tr>
@@ -343,6 +467,27 @@ export const MemberListTab = () => {
)}
</ModalContent>
</Modal>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-4xl"
title={`Manage Access for ${(popUp.updateRole.data as TWorkspaceUser)?.user?.email}`}
subTitle={`
Configure role-based access control by assigning Infisical users a mix of roles and specific privileges. A user will gain access to all actions within the roles assigned to them, not just the actions those roles share in common. You must choose at least one permanent role.
`}
>
<MemberRoleForm
onOpenUpgradeModal={(description) => handlePopUpOpen("upgradePlan", { description })}
projectMember={
filterdUsers?.[
(popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index
] as TWorkspaceUser
}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
@@ -355,6 +500,6 @@ export const MemberListTab = () => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
</motion.div>
);
};
@@ -0,0 +1,350 @@
/* 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<typeof roleFormSchema>;
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRbacSection = ({ 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<TRoleForm>({
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" });
roleForm.reset(undefined, { keepValues: true });
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<div>
<div className="text-lg font-medium">Roles</div>
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
<div>
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
<div>
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non expiry access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</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">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"opacity-0",
roleForm.formState.isDirty && "opacity-100"
)}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,20 @@
import { TWorkspaceUser } from "@app/hooks/api/types";
import { MemberRbacSection } from "./MemberRbacSection";
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
return (
<div>
<MemberRbacSection
projectMember={projectMember}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
<SpecificPrivilegeSection membershipId={projectMember?.id} />
</div>
);
};
@@ -0,0 +1,514 @@
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
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,
Checkbox,
DeleteActionModal,
FormControl,
FormLabel,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege,
useListProjectUserPrivileges,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
const secretPermissionSchema = z.object({
secretPath: z.string().optional(),
environmentSlug: z.string(),
[ProjectPermissionActions.Edit]: z.boolean().optional(),
[ProjectPermissionActions.Read]: z.boolean().optional(),
[ProjectPermissionActions.Create]: z.boolean().optional(),
[ProjectPermissionActions.Delete]: z.boolean().optional(),
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)
})
])
});
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege"
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Member
);
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema),
values: {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read)
),
edit: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Edit)
),
create: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Create)
),
delete: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Delete)
),
// zod will pick it
temporaryAccess: privilege
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
if (updateUserPrivilege.isLoading) return;
try {
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await updateUserPrivilege.mutateAsync({
privilegeId: privilege.id,
...data.temporaryAccess,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
})),
projectMembershipId: privilege.projectMembershipId
});
createNotification({
type: "success",
text: "Successfully updated privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update privilege"
});
}
};
const handleDeletePrivilege = async () => {
if (deleteUserPrivilege.isLoading) return;
try {
await deleteUserPrivilege.mutateAsync({
privilegeId: privilege.id,
projectMembershipId: privilege.projectMembershipId
});
createNotification({
type: "success",
text: "Successfully deleted privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime)
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
<div className="mt-4">
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
<div className="flex items-start space-x-4">
<Controller
control={privilegeForm.control}
name="environmentSlug"
render={({ field: { onChange, ...field } }) => (
<FormControl label="Env">
<Select
{...field}
isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)}
>
{currentWorkspace?.environments?.map(({ slug, id }) => (
<SelectItem value={slug} key={id}>
{slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={privilegeForm.control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path">
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
</FormControl>
)}
/>
<div className="flex flex-grow justify-between">
<Controller
control={privilegeForm.control}
name="read"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="View" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-read"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="create"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Create" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-create"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="edit"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Modify" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-modify"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="delete"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Delete" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-delete"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-2">
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip content={getAccessLabel(true)}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel()}
</Button>
</Tooltip>
</div>
</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">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={privilegeForm.control}
defaultValue="1h"
name="temporaryAccess.temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = privilegeForm.getValues(
"temporaryAccess.temporaryRange"
);
if (!temporaryRange) {
privilegeForm.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
privilegeForm.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccessField.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
privilegeForm.setValue("temporaryAccess", {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{privilegeForm.formState.isDirty ? (
<>
<Tooltip content="Cancel" className="mr-4">
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
</IconButton>
</Tooltip>
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Save"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-3"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
) : (
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
)}
</IconButton>
</Tooltip>
</>
) : (
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
</div>
</form>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title="Remove user additional privilege"
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onClose={() => handlePopUpClose("deletePrivilege")}
onDeleteApproved={handleDeletePrivilege}
/>
</div>
);
};
type Props = {
membershipId: string;
};
export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId);
const { currentWorkspace } = useWorkspace();
const createUserPrivilege = useCreateProjectUserAdditionalPrivilege();
const handleCreatePrivilege = async () => {
if (createUserPrivilege.isLoading) return;
try {
await createUserPrivilege.mutateAsync({
permissions: [
{
action: ProjectPermissionActions.Read,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: currentWorkspace?.environments?.[0].slug
}
}
],
projectMembershipId: membershipId
});
createNotification({
type: "success",
text: "Successfully created privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to create privilege"
});
}
};
return (
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
<div className="flex items-center space-x-2 text-lg font-medium">
Additional Privileges
{isLoading && <Spinner size="xs" />}
</div>
<p className="mt-0.5 text-sm text-mineshaft-400">
Select individual privileges to associate with the user.
</p>
<div>
{userPrivileges
?.filter(({ permissions }) =>
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
)
?.map((privilege) => (
<SpecificPrivilegeSecretForm
privilege={privilege as TProjectUserPrivilege}
key={privilege?.id}
/>
))}
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreatePrivilege}
isLoading={createUserPrivilege.isLoading}
isDisabled={!isAllowed}
>
Add additional privilege
</Button>
)}
</ProjectPermissionCan>
</div>
);
};
@@ -0,0 +1 @@
export { MemberRoleForm } from "./MemberRoleForm";
@@ -1,471 +0,0 @@
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 { createNotification } from "@app/components/notifications";
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 ? "Timed access expired" : "Grant timed 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">
Configure timed access
</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");
})()
}
>
Grant 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 { 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}>
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Timed role expired" : "Timed role 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 ? "Access expired" : "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>
);
};
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
@@ -28,6 +28,7 @@ type Props = {
formName: "secrets";
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
getValue: UseFormGetValues<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
@@ -44,6 +45,7 @@ enum Permission {
export const MultiEnvProjectPermission = ({
isNonEditable,
setValue,
getValue,
control,
formName,
title,
@@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({
const handlePermissionChange = (val: Permission) => {
switch (val) {
case Permission.NoAccess:
setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
case Permission.NoAccess: {
const permissions = getValue("permissions");
if (permissions) delete permissions[formName];
setValue("permissions", permissions, { shouldDirty: true });
break;
}
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
@@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
"border-l-2 border-primary-600"
"border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
@@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
register,
formState: { isSubmitting, isDirty, errors },
setValue,
getValues,
control
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
@@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
</div>
<div>
<MultiEnvProjectPermission
getValue={getValues}
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
@@ -31,6 +31,7 @@ export const formSchema = z.object({
slug: z
.string()
.trim()
.toLowerCase()
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
permissions: z
.object({
@@ -416,7 +416,7 @@ export const ActionBar = ({
{Object.keys(selectedSecrets).length} Selected
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
@@ -119,9 +119,12 @@ export const AdminDashboardPage = () => {
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex justify-between">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Allow user sign up
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
Allow user signups
</div>
<div className="mb-4 text-sm max-w-sm text-mineshaft-400">
Select if you want users to be able to signup freely into your Infisical instance.
</div>
<Controller
control={control}
@@ -147,9 +150,9 @@ export const AdminDashboardPage = () => {
/>
</div>
{signupMode === "anyone" && (
<div className="mt-4 flex items-center justify-between">
<div className="mb-4 flex text-mineshaft-100">
Restrict sign up by email domain(s)
<div className="mt-8 mb-8 flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Restrict signup by email domain(s)
</div>
<Controller
control={control}