feat(ui): updated ui with new role form for users

This commit is contained in:
Akhil Mohan
2024-03-28 15:30:36 +05:30
parent 6b51c7269a
commit 51716336c2
17 changed files with 1145 additions and 1493 deletions

View File

@@ -3,5 +3,5 @@ export {
useDeleteProjectUserAdditionalPrivilege,
useUpdateProjectUserAdditionalPrivilege
} from "./mutation";
export { useGetProjectUserPrivilegeDetails } from "./queries";
export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries";
export type { TProjectUserPrivilege } from "./types";

View File

@@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { workspaceKeys } from "../workspace/queries";
import { projectUserPrivilegeKeys } from "./queries";
import {
TCreateProjectUserPrivilegeDTO,
TDeleteProjectUserPrivilegeDTO,
@@ -22,8 +22,8 @@ export const useCreateProjectUserAdditionalPrivilege = () => {
});
return data.privilege;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
@@ -39,8 +39,8 @@ export const useUpdateProjectUserAdditionalPrivilege = () => {
);
return data.privilege;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
@@ -55,8 +55,8 @@ export const useDeleteProjectUserAdditionalPrivilege = () => {
);
return data.privilege;
},
onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};

View File

@@ -7,7 +7,9 @@ import { TProjectPermission } from "../roles/types";
import { TProjectUserPrivilege } from "./types";
export const projectUserPrivilegeKeys = {
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const,
list: (projectMembershipId: string) =>
["project-user-privileges", { projectMembershipId }] as const
};
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
@@ -29,3 +31,21 @@ export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
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>[])
}));
}
});
};

View File

@@ -7,25 +7,30 @@ export enum ProjectUserAdditionalPrivilegeTemporaryMode {
export type TProjectUserPrivilege = {
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 TCreateProjectUserPrivilegeDTO = {
projectMembershipId: string;
slug: string;
name: string;
workspaceId: string;
description?: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
@@ -35,14 +40,18 @@ export type TCreateProjectUserPrivilegeDTO = {
export type TUpdateProjectUserPrivlegeDTO = {
privilegeId: string;
workspaceId: string;
projectMembershipId: string;
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
export type TDeleteProjectUserPrivilegeDTO = {
privilegeId: string;
workspaceId: string;
projectMembershipId: string;
};
export type TGetProejctUserPrivilegeDetails = {
export type TGetProjectUserPrivilegeDetails = {
privilegeId: string;
};
export type TListProjectUserPrivileges = {
projectMembershipId: string;
};

View File

@@ -76,30 +76,32 @@ export type TWorkspaceUser = {
};
inviteEmail: string;
organization: string;
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: {
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[];
};

View File

@@ -315,13 +315,6 @@ export const useGetWorkspaceUsers = (workspaceId: string) => {
);
return users;
},
select: (data) =>
data.map((el) => ({
...el,
additionalPrivileges: el.additionalPrivileges.sort((a, b) =>
a.createdAt.localeCompare(b.createdAt)
)
})),
enabled: true
});
};

View File

@@ -1,328 +0,0 @@
import { useForm } from "react-hook-form";
import { faElementor } from "@fortawesome/free-brands-svg-icons";
import {
faAnchorLock,
faArrowLeft,
faBook,
faCog,
faKey,
faLock,
faNetworkWired,
faPuzzlePiece,
faServer,
faShield,
faTags,
faUser,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications";
import guidGenerator from "@app/components/utilities/randomId";
import { Button, FormControl, Input, Spinner } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import {
useCreateIdentityProjectAdditionalPrivilege,
useCreateProjectUserAdditionalPrivilege,
useGetIdentityProjectPrivilegeDetails,
useGetProjectUserPrivilegeDetails,
useUpdateIdentityProjectAdditionalPrivilege,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { MultiEnvProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission";
import {
formRolePermission2API,
formSchema,
rolePermission2Form,
TFormSchema
} from "../ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils";
import { SecretRollbackPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission";
import { SingleProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission";
import { WsProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission";
const SINGLE_PERMISSION_LIST = [
{
title: "Integrations",
subtitle: "Integration management control",
icon: faPuzzlePiece,
formName: "integrations"
},
{
title: "Secret Protect policy",
subtitle: "Manage policies for secret protection for unauthorized secret changes",
icon: faShield,
formName: ProjectPermissionSub.SecretApproval
},
{
title: "Roles",
subtitle: "Role management control",
icon: faUsers,
formName: "role"
},
{
title: "Project Members",
subtitle: "Project members management control",
icon: faUser,
formName: "member"
},
{
title: "Machine identity management",
subtitle: "Add, view, update and remove (machine) identities from the project",
icon: faServer,
formName: "identity"
},
{
title: "Webhooks",
subtitle: "Webhook management control",
icon: faAnchorLock,
formName: "webhooks"
},
{
title: "Service Tokens",
subtitle: "Token management control",
icon: faKey,
formName: "service-tokens"
},
{
title: "Settings",
subtitle: "Settings control",
icon: faCog,
formName: "settings"
},
{
title: "Environments",
subtitle: "Environment management control",
icon: faElementor,
formName: "environments"
},
{
title: "Tags",
subtitle: "Tag management control",
icon: faTags,
formName: "tags"
},
{
title: "Audit Logs",
subtitle: "Audit log management control",
icon: faBook,
formName: "audit-logs"
},
{
title: "IP Allowlist",
subtitle: "IP allowlist management control",
icon: faNetworkWired,
formName: "ip-allowlist"
}
] as const;
type Props = {
onGoBack: VoidFunction;
isIdentity?: boolean;
privilegeId?: string;
workspaceId: string;
// isIdentity true -> actorId is identity Id
// isIdentity false -> actorId is projectMembershipId
actorId: string;
};
export const AdditionalPrivilegeForm = ({
onGoBack,
privilegeId,
actorId,
workspaceId,
isIdentity
}: Props) => {
const isEdit = Boolean(privilegeId);
const { data: projectUserPrivilegeDetails, isLoading: isProjectUserPrivilegeLoading } =
useGetProjectUserPrivilegeDetails(privilegeId && !isIdentity ? privilegeId : "");
const { data: identityProjectPrivilegeDetails, isLoading: isIdentityProjectPrivilegeLoading } =
useGetIdentityProjectPrivilegeDetails(isIdentity && privilegeId ? privilegeId : "");
const privileges = isIdentity ? identityProjectPrivilegeDetails : projectUserPrivilegeDetails;
const isLoading = isIdentity ? isIdentityProjectPrivilegeLoading : isProjectUserPrivilegeLoading;
const {
handleSubmit,
register,
formState: { isSubmitting, isDirty, errors },
setValue,
getValues,
control
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
slug: `privilege-${guidGenerator().slice(0, 4).toLowerCase()}`
},
values: privileges && {
...privileges,
description: privileges.description || "",
permissions: rolePermission2Form(privileges.permissions)
}
});
const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege();
const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege();
const createIdentityProjectAdditionalPrivilege = useCreateIdentityProjectAdditionalPrivilege();
const updateIdentityProjectAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
const handleRoleUpdate = async (el: TFormSchema) => {
try {
if (isIdentity) {
await updateIdentityProjectAdditionalPrivilege.mutateAsync({
...el,
permissions: formRolePermission2API(el.permissions),
privilegeId: privilegeId as string,
projectId: workspaceId
});
} else {
await updateProjectUserAdditionalPrivilege.mutateAsync({
...el,
permissions: formRolePermission2API(el.permissions),
privilegeId: privilegeId as string,
workspaceId
});
}
createNotification({ type: "success", text: "Successfully update privilege" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update privilege" });
}
};
const handleFormSubmit = async (el: TFormSchema) => {
if (isEdit) {
await handleRoleUpdate(el);
return;
}
try {
if (isIdentity) {
await createIdentityProjectAdditionalPrivilege.mutateAsync({
...el,
permissions: formRolePermission2API(el.permissions),
identityId: actorId,
projectId: workspaceId
});
} else {
await createProjectUserAdditionalPrivilege.mutateAsync({
...el,
permissions: formRolePermission2API(el.permissions),
projectMembershipId: actorId,
workspaceId
});
}
createNotification({ type: "success", text: "Created new privilege" });
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to create privilege" });
}
};
if (isEdit && isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spinner />
</div>
);
}
return (
<div>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="mb-2 flex items-center justify-between">
<h1 className="text-xl font-semibold text-mineshaft-100">
{!isEdit ? "New" : "Edit"} user additional privilege
</h1>
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
</div>
<p className="mb-8 text-gray-400">
Select multiple privilege that can be granted to the user
</p>
<div className="flex flex-col space-y-6">
<FormControl
label="Name"
helperText="Use descriptive names to clearly identify permissions"
isRequired
className="mb-0"
isError={Boolean(errors?.name)}
errorText={errors?.name?.message}
>
<Input {...register("name")} />
</FormControl>
<FormControl
label="Slug"
helperText="Slugs are used for API access"
isError={Boolean(errors?.slug)}
errorText={errors?.slug?.message}
>
<Input {...register("slug")} placeholder="biller" />
</FormControl>
<FormControl
label="Description"
helperText="A short description about this privilege"
isError={Boolean(errors?.description)}
errorText={errors?.description?.message}
>
<Input {...register("description")} />
</FormControl>
<div className="flex items-center justify-between border-t border-t-mineshaft-800 pt-6">
<div>
<h2 className="text-xl font-medium">Add Privilege</h2>
</div>
</div>
<div>
<MultiEnvProjectPermission
getValue={getValues}
control={control}
setValue={setValue}
icon={faLock}
title="Secrets"
subtitle="Create, modify and remove secrets, folders and secret imports"
formName="secrets"
/>
</div>
<div key="permission-ws">
<WsProjectPermission control={control} setValue={setValue} />
</div>
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
<div key={`permission-${title}`}>
<SingleProjectPermission
control={control}
setValue={setValue}
icon={icon}
title={title}
subtitle={subtitle}
formName={formName}
/>
</div>
))}
<div key="permission-secret-rollback">
<SecretRollbackPermission control={control} setValue={setValue} />
</div>
</div>
<div className="mt-12 flex items-center space-x-4">
<Button type="submit" isDisabled={isSubmitting || !isDirty} isLoading={isSubmitting}>
{!isEdit ? "Grant Privilege" : "Save Changes"}
</Button>
<Button onClick={onGoBack} variant="outline_bg">
Cancel
</Button>
</div>
</form>
</div>
);
};

View File

@@ -1,201 +0,0 @@
import {
faArrowLeft,
faPencil,
faPlus,
faTrash,
faUserShield
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
IconButton,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useDeleteIdentityProjectAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm";
import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTemporaryAccess";
type Props = {
onGoBack: VoidFunction;
name: string;
isIdentity?: boolean;
// isIdentity id - identity id else projectMembershipId
actorId: string;
privileges: TWorkspaceUser["additionalPrivileges"];
};
export const AdditionalPrivilegeSection = ({
onGoBack,
privileges = [],
actorId,
name,
isIdentity
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"modifyPrivilege",
"deletePrivilege"
] as const);
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege();
const deleteProjectIdentityAdditionalPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
const onPrivilegeDelete = async (privilegeId: string) => {
try {
if (isIdentity) {
await deleteProjectIdentityAdditionalPrivilege.mutateAsync({
privilegeId,
projectId: workspaceId
});
} else {
await deleteProjectUserAdditionalPrivilege.mutateAsync({
privilegeId,
workspaceId
});
}
handlePopUpClose("deletePrivilege");
createNotification({
type: "success",
text: "Successfully removed privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
if (popUp.modifyPrivilege.isOpen) {
const privilegeDetails = popUp?.modifyPrivilege?.data as {
id: string;
};
return (
<motion.div
key="panel-additional-permission"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<AdditionalPrivilegeForm
onGoBack={() => handlePopUpClose("modifyPrivilege")}
privilegeId={privilegeDetails?.id}
workspaceId={workspaceId}
isIdentity={isIdentity}
actorId={actorId}
/>
</motion.div>
);
}
return (
<motion.div
key="panel-privileges-list"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-8 flex items-center justify-between rounded-lg">
<h1 className="text-xl font-semibold capitalize text-mineshaft-100">
Additional Privileges - {name}
</h1>
<div className="flex items-center space-x-4">
<Button
onClick={onGoBack}
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
>
Go back
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("modifyPrivilege")}
>
New Privilege
</Button>
</div>
</div>
<div className="mt-6 flex flex-col space-y-4">
{privileges.length === 0 && (
<EmptyState
title={`${isIdentity ? "Machine identity" : "User"} has no additional privileges`}
iconSize="3x"
icon={faUserShield}
/>
)}
{privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => (
<div
className="flex items-center space-x-4 rounded-md bg-mineshaft-800 p-4 px-6"
key={id}
>
<div className="flex flex-grow flex-col">
<div className="mb-1 flex items-center text-lg font-medium">
<span className="capitalize">{privilegeName}</span>
<Tag size="xs" className="ml-2">
{slug}
</Tag>
</div>
<div className="text-xs font-light capitalize">{description}</div>
</div>
<div className="flex items-center space-x-4">
<AdditionalPrivilegeTemporaryAccess
isIdentity={isIdentity}
privilegeId={id}
workspaceId={workspaceId}
temporaryConfig={!dto.isTemporary ? { isTemporary: false } : { ...dto }}
/>
<IconButton
size="sm"
variant="outline_bg"
ariaLabel="update"
onClick={() => handlePopUpOpen("modifyPrivilege", { id })}
>
<Tooltip content="Edit">
<FontAwesomeIcon icon={faPencil} />
</Tooltip>
</IconButton>
<IconButton
size="sm"
colorSchema="danger"
variant="outline_bg"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege", { name: privilegeName, id })}
>
<Tooltip content="Delete">
<FontAwesomeIcon icon={faTrash} />
</Tooltip>
</IconButton>
</div>
</div>
))}
</div>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title={`Are you sure want to remove privilege ${(popUp?.deletePrivilege.data as { name: string })?.name || " "
} for user ${name}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onDeleteApproved={async () =>
onPrivilegeDelete((popUp?.deletePrivilege.data as { id: string }).id)
}
/>
</motion.div>
);
};

View File

@@ -1,219 +0,0 @@
import { Controller, useForm } from "react-hook-form";
import { faClock } 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,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Tag,
Tooltip
} from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import {
useUpdateIdentityProjectAdditionalPrivilege,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
privilegeId: string;
workspaceId: string;
isIdentity?: boolean;
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
};
export const AdditionalPrivilegeTemporaryAccess = ({
temporaryConfig: defaultValues = {},
workspaceId,
privilegeId,
isIdentity
}: 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 || "");
const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege();
const updateProjectIdentityAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => {
try {
if (isIdentity) {
await updateProjectIdentityAdditionalPrivilege.mutateAsync({
privilegeId: privilegeId as string,
projectId: workspaceId,
isTemporary: true,
temporaryRange: el.temporaryRange,
temporaryAccessStartTime: new Date().toISOString(),
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative
});
} else {
await updateProjectUserAdditionalPrivilege.mutateAsync({
privilegeId: privilegeId as string,
workspaceId,
isTemporary: true,
temporaryRange: el.temporaryRange,
temporaryAccessStartTime: new Date().toISOString(),
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative
});
}
createNotification({ type: "success", text: "Successfully updated access" });
handlePopUpToggle("setTempRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update access" });
}
};
const handleRevokeTemporaryAccess = async () => {
try {
if (isIdentity) {
await updateProjectIdentityAdditionalPrivilege.mutateAsync({
privilegeId: privilegeId as string,
projectId: workspaceId,
isTemporary: false
});
} else {
await updateProjectUserAdditionalPrivilege.mutateAsync({
privilegeId: privilegeId as string,
workspaceId,
isTemporary: false
});
}
createNotification({ type: "success", text: "Successfully updated access" });
handlePopUpToggle("setTempRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update access" });
}
};
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" size="sm" variant="outline_bg">
<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">
<Button
size="xs"
isLoading={
updateProjectUserAdditionalPrivilege.isLoading &&
updateProjectUserAdditionalPrivilege.variables?.isTemporary
}
isDisabled={
updateProjectUserAdditionalPrivilege.isLoading &&
updateProjectUserAdditionalPrivilege.variables?.isTemporary
}
onClick={() => {
handleSubmit(({ temporaryRange }) => {
handleGrantTemporaryAccess({ temporaryRange });
})();
}}
>
{isTemporaryFieldValue ? "Restart" : "Grant access"}
</Button>
{isTemporaryFieldValue && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={handleRevokeTemporaryAccess}
isLoading={
updateProjectUserAdditionalPrivilege.isLoading &&
!updateProjectUserAdditionalPrivilege.variables?.isTemporary
}
isDisabled={
updateProjectUserAdditionalPrivilege.isLoading &&
!updateProjectUserAdditionalPrivilege.variables?.isTemporary
}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -1 +0,0 @@
export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection";

View File

@@ -3,13 +3,11 @@ import {
faArrowUpRightFromSquare,
faPlus,
faServer,
faUserShield,
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 { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
@@ -32,13 +30,10 @@ import { withProjectPermission } from "@app/hoc";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection";
import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoles } from "./components/IdentityRoles";
export const IdentityTab = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? "";
@@ -78,33 +73,6 @@ export const IdentityTab = withProjectPermission(
}
};
if (popUp.additionalPrivilege.isOpen) {
const privilegeDetails = popUp?.additionalPrivilege?.data as {
name: string;
index: number;
identityId: string;
};
return (
<motion.div
key="panel-additional-permission"
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 }}
>
<AdditionalPrivilegeSection
isIdentity
onGoBack={() => handlePopUpClose("additionalPrivilege")}
privileges={data?.[privilegeDetails.index]?.additionalPrivileges || []}
name={privilegeDetails.name}
actorId={privilegeDetails.identityId}
/>
</motion.div>
);
}
return (
<motion.div
key="panel-identity"
@@ -159,82 +127,39 @@ export const IdentityTab = withProjectPermission(
{!isLoading &&
data &&
data.length > 0 &&
data.map(
({ identity: { id, name }, roles, createdAt, additionalPrivileges }, index) => {
const hasAdditionalPrivilege = Boolean(additionalPrivileges.length);
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">
<div className="flex items-center space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
allowedLabel="Additional Privilege"
data.map(({ identity: { id, name }, createdAt }) => {
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</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}
>
{(isAllowed) => (
<IconButton
size="lg"
variant="plain"
ariaLabel="update"
className={twMerge(hasAdditionalPrivilege && "text-primary")}
isDisabled={!isAllowed}
onClick={() =>
handlePopUpOpen("additionalPrivilege", {
name,
index,
identityId: id
})
}
>
<FontAwesomeIcon icon={faUserShield} />
</IconButton>
)}
</ProjectPermissionCan>
<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>
</div>
</Td>
</Tr>
);
}
)}
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>

View File

@@ -3,10 +3,11 @@ import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import {
faClock,
faEdit,
faMagnifyingGlass,
faPlus,
faUsers,
faUserShield,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -22,6 +23,9 @@ import {
DeleteActionModal,
EmptyState,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Modal,
@@ -31,10 +35,12 @@ import {
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr,
UpgradePlanModal
} from "@app/components/v2";
@@ -54,10 +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 { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection";
import { MemberRoles } from "./MemberRoles";
import { MemberRoleForm } from "./MemberRoleForm";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@@ -65,8 +72,14 @@ 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";
return role;
};
export const MemberListTab = () => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
@@ -87,7 +100,7 @@ export const MemberListTab = () => {
"addMember",
"removeMember",
"upgradePlan",
"additionalPrivilege"
"updateRole"
] as const);
const {
@@ -195,32 +208,6 @@ export const MemberListTab = () => {
);
}, [orgUsers, members]);
if (popUp.additionalPrivilege.isOpen) {
const privilegeDetails = popUp?.additionalPrivilege?.data as {
name: string;
index: number;
projectMembershipId: string;
};
return (
<motion.div
key="panel-additional-permission"
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 }}
>
<AdditionalPrivilegeSection
onGoBack={() => handlePopUpClose("additionalPrivilege")}
privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []}
name={privilegeDetails.name}
actorId={privilegeDetails.projectMembershipId}
/>
</motion.div>
);
}
return (
<motion.div
key="panel-1"
@@ -266,90 +253,149 @@ export const MemberListTab = () => {
<TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filterdUsers?.map(
(
{ user: u, inviteEmail, id: membershipId, roles, additionalPrivileges },
index
) => {
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
const hasAdditionalPrivilege = Boolean(additionalPrivileges.length);
filterdUsers?.map((projectMember, index) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
return (
<Tr key={`membership-${membershipId}`} className="w-full">
<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 })
}
membershipId={membershipId}
/>
return (
<Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<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>
);
}
)}
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<div className="flex items-center space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
allowedLabel="Additional Privilege"
>
{(isAllowed) => (
<IconButton
size="lg"
variant="plain"
ariaLabel="update"
className={twMerge(hasAdditionalPrivilege && "text-primary")}
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("additionalPrivilege", {
name: `${user.firstName} ${user.lastName || ""}`,
index,
projectMembershipId: 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>
);
}
>
<FontAwesomeIcon icon={faUserShield} />
</IconButton>
)}
</ProjectPermissionCan>
<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>
)}
</HoverCardContent>
</HoverCard>
)}
</Td>
</Tr>
);
}
)}
{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 && (
<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>
);
})}
</TBody>
</Table>
{!isMembersLoading && filterdUsers?.length === 0 && (
@@ -418,6 +464,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 Infisical users a mix of one built-in role, multiple custom roles, and multiple specific privileges. A user will gain access to alll 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"

View File

@@ -0,0 +1,338 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faClose } 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,
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"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<Tooltip
asChild
content={isExpired ? "Timed access expired" : "Grant timed 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 capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{!temporaryAccess?.isTemporary
? "Permanent"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)}
</Button>
</Tooltip>
</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>
<Tooltip content={isMemberEditDisabled ? "Access restricted" : "Remove"}>
<IconButton
variant="outline_bg"
className="border-none bg-mineshaft-600 py-3"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faClose} />
</IconButton>
</Tooltip>
</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}
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 Changes
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,497 @@
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,
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 = () => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
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"
onValueChange={(e) => onChange(e)}
// className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
>
{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}>
<Tooltip
asChild
content={isExpired ? "Timed access expired" : "Grant timed access"}
>
<Button
variant="outline_bg"
isDisabled={isMemberEditDisabled}
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
className={twMerge(
"border-none py-1.5 capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel()}
</Button>
</Tooltip>
</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={isMemberEditDisabled ? "Access restricted" : "Save"}>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-2.5"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="sm" />
) : (
<FontAwesomeIcon icon={faSave} />
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
variant="outline_bg"
className="border-none bg-mineshaft-600 py-2.5"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faCancel} />
</IconButton>
</Tooltip>
</>
) : (
<Tooltip content={isMemberEditDisabled ? "Access restricted" : "Delete"}>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border-none bg-mineshaft-600 py-2.5"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faClose} />
</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-gray-700 pt-6">
<div className="text-lg font-medium flex items-center space-x-2">
Additional Privileges
{isLoading && <Spinner size="xs" />}
</div>
<p className="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"
onClick={handleCreatePrivilege}
isLoading={createUserPrivilege.isLoading}
isDisabled={!isAllowed}
>
Add additional privilege
</Button>
)}
</ProjectPermissionCan>
</div>
);
};

View File

@@ -0,0 +1 @@
export { MemberRoleForm } from "./MemberRoleForm";

View File

@@ -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>
);
};