diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index b8829f798..4ac19c351 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -29,30 +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; - }[]; - additionalPrivileges: { - id: string; - name: string; - description: string | null | undefined; - slug: string; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessEndTime: string | null | undefined; - temporaryAccessStartTime: string | null | undefined; - isTemporary: boolean; - createdAt: string; - }[]; + 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; }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx index 420d29485..d9a2a0b0a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { workspaceKeys } from "../workspace/queries"; +import { identitiyProjectPrivilegeKeys } from "./queries"; import { TCreateIdentityProjectPrivilegeDTO, TDeleteIdentityProjectPrivilegeDTO, @@ -14,20 +14,19 @@ import { export const useCreateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TCreateIdentityProjectPrivilegeDTO - >({ + return useMutation({ mutationFn: async (dto) => { const { data } = await apiRequest.post("/api/v1/additional-privilege/identity", { ...dto, + isPackedPermission: true, permissions: packRules(dto.permissions) }); return data.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; @@ -35,20 +34,24 @@ export const useCreateIdentityProjectAdditionalPrivilege = () => { export const useUpdateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TUpdateIdentityProjectPrivlegeDTO - >({ - mutationFn: async (dto) => { - const { data } = await apiRequest.patch( - `/api/v1/additional-privilege/identity/${dto.privilegeId}`, - { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined } - ); - return data.privilege; + return useMutation({ + mutationFn: async ({ slug, projectSlug, identityId, data }) => { + const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", { + slug, + projectSlug, + identityId, + data: { + isPackedPermission: true, + ...data, + permissions: data.permissions ? packRules(data.permissions) : undefined + } + }); + return res.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; @@ -56,19 +59,21 @@ export const useUpdateIdentityProjectAdditionalPrivilege = () => { export const useDeleteIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TDeleteIdentityProjectPrivilegeDTO - >({ - mutationFn: async (dto) => { - const { data } = await apiRequest.delete( - `/api/v1/additional-privilege/identity/${dto.privilegeId}` - ); + return useMutation({ + mutationFn: async ({ identityId, projectSlug, slug }) => { + const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", { + data: { + identityId, + projectSlug, + slug + } + }); return data.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx index 2bbd71c70..8ad60ba6c 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -4,28 +4,74 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TProjectPermission } from "../roles/types"; -import { TIdentityProjectPrivilege } from "./types"; +import { + TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails, + TIdentityProjectPrivilege, + TListIdentityUserPrivileges as TListIdentityProjectPrivileges +} from "./types"; export const identitiyProjectPrivilegeKeys = { - details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const + details: ({ identityId, slug, projectSlug }: TGetIdentityProjectPrivilegeDetails) => + [ + "identity-user-privilege", + { + identityId, + projectSlug, + slug + } + ] as const, + list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) => + ["identity-user-privileges", { identityId, projectSlug }] as const }; -const fetchIdentityProjectPrivilegeDetails = async (privilegeId: string) => { - const { - data: { privilege } - } = await apiRequest.get<{ - privilege: Omit & { permissions: unknown }; - }>(`/api/v1/additional-privilege/identity/${privilegeId}`); - return { - ...privilege, - permissions: unpackRules(privilege.permissions as PackRule[]) - }; -}; - -export const useGetIdentityProjectPrivilegeDetails = (privilegeId: string) => { +export const useGetIdentityProjectPrivilegeDetails = ({ + projectSlug, + identityId, + slug +}: TGetIdentityProjectPrivilegeDetails) => { return useQuery({ - enabled: Boolean(privilegeId), - queryKey: identitiyProjectPrivilegeKeys.details(privilegeId), - queryFn: () => fetchIdentityProjectPrivilegeDetails(privilegeId) + enabled: Boolean(projectSlug && identityId && slug), + queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, slug, identityId }), + queryFn: async () => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/identity/${slug}`, { + params: { + identityId, + projectSlug + } + }); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; + } + }); +}; + +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 & { permissions: unknown } + >; + }>("/api/v1/additional-privilege/identity", { + params: { identityId, projectSlug, unpacked: false } + }); + return privileges.map((el) => ({ + ...el, + permissions: unpackRules(el.permissions as PackRule[]) + })); + } }); }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 3362e3301..1e07db597 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -7,25 +7,31 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode { export type TIdentityProjectPrivilege = { projectMembershipId: string; slug: string; - name: string; - isTemporary: boolean; id: string; createdAt: Date; updatedAt: Date; - description?: string | null | undefined; - temporaryMode?: string | null | undefined; - temporaryRange?: string | null | undefined; - temporaryAccessStartTime?: string | null | undefined; - temporaryAccessEndTime?: Date | null | undefined; 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; - projectId: string; - slug: string; - name: string; - description?: string; + projectSlug: string; + slug?: string; isTemporary?: boolean; temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; @@ -34,15 +40,25 @@ export type TCreateIdentityProjectPrivilegeDTO = { }; export type TUpdateIdentityProjectPrivlegeDTO = { - privilegeId: string; - projectId: string; -} & Partial>; + projectSlug: string; + identityId: string; + slug: string; + data: Partial>; +}; export type TDeleteIdentityProjectPrivilegeDTO = { - privilegeId: string; - projectId: string; + projectSlug: string; + identityId: string; + slug: string; +}; + +export type TListIdentityUserPrivileges = { + projectSlug: string; + identityId: string; }; export type TGetIdentityProejctPrivilegeDetails = { - privilegeId: string; + projectSlug: string; + identityId: string; + slug: string; }; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 816d786ed..d82c53d2a 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { faArrowUpRightFromSquare, + faClock, + faEdit, faPlus, faServer, faXmark @@ -8,6 +10,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -15,23 +18,39 @@ 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"; 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"; + return role; +}; export const IdentityTab = withProjectPermission( () => { const { currentWorkspace } = useWorkspace(); @@ -45,7 +64,7 @@ export const IdentityTab = withProjectPermission( "identity", "deleteIdentity", "upgradePlan", - "additionalPrivilege" + "updateRole" ] as const); const onRemoveIdentitySubmit = async (identityId: string) => { @@ -75,7 +94,7 @@ export const IdentityTab = withProjectPermission( return ( 0 && - data.map(({ identity: { id, name }, createdAt }) => { + data.map((identityMember, index) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; return ( {name} + + +
+ {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 ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {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 ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
+ )} + + + handlePopUpOpen("updateRole", { ...identityMember, index }) + } + > + + + +
+ {format(new Date(createdAt), "yyyy-MM-dd")} - + handlePopUpToggle("updateRole", state)} + > + + + handlePopUpOpen("upgradePlan", { description }) + } + identityProjectMember={ + data?.[ + (popUp.updateRole?.data as IdentityMembership & { index: number })?.index + ] as IdentityMembership + } + /> + + ; + +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({ + 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 ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx new file mode 100644 index 000000000..e354f1015 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx @@ -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 ( +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..806072a89 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,515 @@ +import { Controller, useForm } from "react-hook-form"; +import { faCancel, faCaretDown, faClock, faClose, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { 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; +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({ + 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 = { environment: data.environmentSlug }; + if (data.secretPath) { + conditions.secretPath = { $glob: data.secretPath }; + } + await updateIdentityPrivilege.mutateAsync({ + data: { + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }, + slug: 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, + slug: privilege.slug, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = () => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + privilegeForm.reset()} + > + + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +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 ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the identity. +

+
+ {identityPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx new file mode 100644 index 000000000..f59675cb3 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx @@ -0,0 +1 @@ +export { IdentityRoleForm } from "./IdentityRoleForm"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx deleted file mode 100644 index 6ff10cfa8..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx +++ /dev/null @@ -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; - -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({ - resolver: zodResolver(temporaryRoleFormSchema), - values: { - temporaryRange: defaultValues.temporaryRange || "1h" - } - }); - const isTemporaryFieldValue = defaultValues.isTemporary; - const isExpired = - isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); - - return ( - { - handlePopUpToggle("setTempRole", isOpen); - }} - > - - - - - - - - -
-
- Set Role Temporarily -
- {isExpired && Expired} - ( - - 1m, 2h, 3d.{" "} - - More - - - } - > - - - )} - /> -
- {isTemporaryFieldValue && ( - - )} - {!isTemporaryFieldValue ? ( - - ) : ( - - )} -
-
-
-
- ); -}; - -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; - -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({ - 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 ( -
- {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 ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - - -
- )} -
-
- ); - })} - {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( - - - +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} - - - {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 ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - new Date(temporaryAccessEndTime as string) && - "text-red-600" - )} - /> - -
- )} -
-
- ); - })}{" "} -
-
- )} -
- { - handlePopUpToggle("editRole", isOpen); - reset(); - }} - > - {!disableEdit && ( - - - - - - )} - - {isRolesLoading ? ( -
- -
- ) : ( -
-
- {projectRoles - ?.filter( - ({ name, slug }) => - name.toLowerCase().includes(searchRoles.toLowerCase()) || - slug.toLowerCase().includes(searchRoles.toLowerCase()) - ) - ?.map(({ id, name, slug }) => { - const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; - - return ( -
-
- ( - { - field.onChange(isChecked); - setValue(`${slug}.temporaryAccess`, false); - }} - > - {name} - - )} - /> -
-
- ( - { - setValue(`${slug}.isChecked`, true, { shouldDirty: true }); - console.log(data); - field.onChange({ isTemporary: true, ...data }); - }} - onRemoveTemporary={() => { - setValue(`${slug}.isChecked`, false, { shouldDirty: true }); - field.onChange(false); - }} - /> - )} - /> -
-
- ); - })} -
-
-
- setSearchRoles(el.target.value)} - leftIcon={} - placeholder="Search roles.." - /> -
-
- -
-
-
- )} -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 9f1711199..3bb29ff61 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -210,7 +210,7 @@ export const MemberListTab = () => { return ( {privilegeForm.formState.isSubmitting ? ( - + ) : ( )} @@ -460,7 +460,7 @@ export const SpecificPrivilegeSection = ({ membershipId }: Props) => { return (
-
+
Additional Privileges {isLoading && }