feat: completed new user detail page screen

This commit is contained in:
=
2024-10-21 20:36:00 +05:30
parent 5ef81cd935
commit d03f890471
21 changed files with 809 additions and 110 deletions

View File

@@ -81,9 +81,9 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
await server.register(registerGroupRouter, { prefix: "/groups" });
await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" });
await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" });
await server.register(
async (privilegeRouter) => {
await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" });
await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" });
},
{ prefix: "/additional-privilege" }

View File

@@ -145,7 +145,11 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime;
const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange;
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
...dto,
slug: dto.slug,
permissions: dto.permissions,
isTemporary: dto.isTemporary,
temporaryRange: dto.temporaryRange,
temporaryMode: dto.temporaryMode,
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
@@ -157,7 +161,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
}
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
...dto,
slug: dto.slug,
permissions: dto.permissions,
isTemporary: false,
temporaryAccessStartTime: null,
temporaryAccessEndTime: null,

View File

@@ -9,7 +9,7 @@ import { type VariantProps, cva } from "cva";
import { twMerge } from "tailwind-merge";
const alertVariants = cva(
"w-full bg-mineshaft-800 rounded-lg border px-4 py-3 text-sm flex items-center gap-x-4",
"w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-4",
{
variants: {
variant: {

View File

@@ -15,7 +15,7 @@ export const useCreateProjectUserAdditionalPrivilege = () => {
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", dto);
const { data } = await apiRequest.post("/api/v1/user-project-additional-privilege", dto);
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
@@ -30,7 +30,7 @@ export const useUpdateProjectUserAdditionalPrivilege = () => {
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.patch(
`/api/v1/additional-privilege/users/${dto.privilegeId}`,
`/api/v1/user-project-additional-privilege/${dto.privilegeId}`,
dto
);
return data.privilege;
@@ -47,7 +47,7 @@ export const useDeleteProjectUserAdditionalPrivilege = () => {
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.delete(
`/api/v1/additional-privilege/users/${dto.privilegeId}`
`/api/v1/user-project-additional-privilege/${dto.privilegeId}`
);
return data.privilege;
},

View File

@@ -15,8 +15,8 @@ const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
const {
data: { privilege }
} = await apiRequest.get<{
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/users/${privilegeId}`);
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: TProjectPermission[] };
}>(`/api/v1/user-project-additional-privilege/${privilegeId}`);
return privilege;
};
@@ -37,7 +37,7 @@ export const useListProjectUserPrivileges = (projectMembershipId: string) => {
data: { privileges }
} = await apiRequest.get<{
privileges: Array<Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }>;
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
}>("/api/v1/user-project-additional-privilege", { params: { projectMembershipId } });
return privileges.map((el) => ({
...el,
permissions: el.permissions as TProjectPermission[]

View File

@@ -4,15 +4,6 @@ export enum ProjectUserAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TProjectSpecificPrivilegePermission = {
conditions: {
environment: string;
secretPath?: { $glob: string };
};
actions: string[];
subject: string;
};
export type TProjectUserPrivilege = {
projectMembershipId: string;
slug: string;
@@ -40,11 +31,17 @@ export type TProjectUserPrivilege = {
export type TCreateProjectUserPrivilegeDTO = {
projectMembershipId: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
permissions: TProjectSpecificPrivilegePermission;
type:
| {
isTemporary: true;
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
}
| {
isTemporary: false;
};
permissions: TProjectPermission[];
};
export type TUpdateProjectUserPrivlegeDTO = {

View File

@@ -63,16 +63,16 @@ export const useGetProjectRoles = (projectId: string) =>
enabled: Boolean(projectId)
});
export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) =>
export const useGetProjectRoleBySlug = (projectId: string, roleSlug: string) =>
useQuery({
queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug),
queryKey: roleQueryKeys.getProjectRoleBySlug(projectId, roleSlug),
queryFn: async () => {
const { data } = await apiRequest.get<{ role: TProjectRole }>(
`/api/v2/workspace/${projectSlug}/roles/slug/${roleSlug}`
`/api/v2/workspace/${projectId}/roles/slug/${roleSlug}`
);
return data.role;
},
enabled: Boolean(projectSlug && roleSlug)
enabled: Boolean(projectId && roleSlug)
});
const getOrgRoles = async (orgId: string) => {

View File

@@ -23,6 +23,7 @@ export {
useGetWorkspaceIntegrations,
useGetWorkspaceSecrets,
useGetWorkspaceSlackConfig,
useGetWorkspaceUserDetails,
useGetWorkspaceUsers,
useListWorkspaceCas,
useListWorkspaceCertificates,
@@ -36,7 +37,5 @@ export {
useUpdateIdentityWorkspaceRole,
useUpdateUserWorkspaceRole,
useUpdateWsEnvironment,
useUpgradeProject,
useGetWorkspaceUserDetails
} from "./queries";
useUpgradeProject} from "./queries";
export { workspaceKeys } from "./query-keys";

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { useTranslation } from "react-i18next";
import Head from "next/head";
import { MemberDetailsPage } from "@app/views/Project/MemberDetailsPage";
export default function Page() {

View File

@@ -1,11 +1,15 @@
import { Button } from "@app/components/v2";
import { useRouter } from "next/router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, EmptyState, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useGetWorkspaceUserDetails } from "@app/hooks/api";
import { faChevronLeft, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { useRouter } from "next/router";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
export const MemberDetailsPage = withProjectPermission(
@@ -19,6 +23,14 @@ export const MemberDetailsPage = withProjectPermission(
const { data: membershipDetails, isLoading: isMembershipDetailsLoading } =
useGetWorkspaceUserDetails(workspaceId, membershipId);
if (isMembershipDetailsLoading) {
return (
<div className="flex w-full items-center justify-center p-24">
<Spinner />
</div>
);
}
return (
<div className="container mx-auto flex max-w-7xl flex-col justify-between bg-bunker-800 p-6 text-white">
<div className="mb-4">
@@ -34,36 +46,66 @@ export const MemberDetailsPage = withProjectPermission(
Project Access Control
</Button>
</div>
<div className="mb-4">
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between ">
<h3 className="text-lg font-semibold text-mineshaft-100">Project User Management</h3>
<div>
<Button colorSchema="danger" variant="outline_bg" size="xs">
Remove User
</Button>
{membershipDetails ? (
<>
<div className="mb-4">
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between ">
<h3 className="text-xl font-semibold text-mineshaft-100">Project User Access</h3>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Edit role"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
>
Remove User
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex gap-12">
<div>
<span className="text-xs font-semibold text-gray-400">Name</span>
{membershipDetails && (
<p className="text-lg capitalize">
{membershipDetails.user.firstName || membershipDetails.user.lastName
? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}`
: "-"}
</p>
)}
</div>
<div>
<span className="text-xs font-semibold text-gray-400">Email</span>
{membershipDetails && (
<p className="text-lg">{membershipDetails?.user?.email}</p>
)}
</div>
</div>
<div className="mt-4 text-sm text-gray-400">
Joined on{" "}
{membershipDetails?.createdAt &&
format(new Date(membershipDetails?.createdAt || ""), "yyyy-MM-dd")}
</div>
</div>
</div>
<div className="">
{membershipDetails && (
<p className="capitalize">
{membershipDetails.user.firstName || membershipDetails.user.lastName
? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}`
: "-"}
</p>
)}
<div className="mt-2 text-sm text-gray-400">
Joined on{" "}
{membershipDetails?.createdAt &&
format(new Date(membershipDetails?.createdAt || ""), "yyyy-MM-dd")}
</div>
</div>
</div>
</div>
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
/>
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
</>
) : (
<EmptyState title="Error: Unable to find the user." className="py-12" />
)}
</div>
);
},

View File

@@ -0,0 +1,251 @@
import { faFolder, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format,formatDistance } from "date-fns";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useUser
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useDeleteProjectUserAdditionalPrivilege,
useListProjectUserPrivileges
} from "@app/hooks/api";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { MembershipProjectAdditionalPrivilegeModifySection } from "./MembershipProjectAdditionalPrivilegeModifySection";
type Props = {
membershipDetails: TWorkspaceUser;
};
export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: Props) => {
const { user } = useUser();
const userId = user?.id;
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege",
"modifyPrivilege"
] as const);
const { permission } = useProjectPermission();
const { mutateAsync: deletePrivilege } = useDeleteProjectUserAdditionalPrivilege();
const { data: userProjectPrivileges, isLoading } = useListProjectUserPrivileges(
membershipDetails?.id
);
const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id;
const handlePrivilegeDelete = async () => {
const { id } = popUp?.deletePrivilege?.data as { id: string };
try {
await deletePrivilege({
privilegeId: id,
projectMembershipId: membershipDetails.id
});
createNotification({ type: "success", text: "Successfully removed the privilege" });
handlePopUpClose("deletePrivilege");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to delete role" });
}
};
return (
<div className="relative">
<AnimatePresence>
{popUp?.modifyPrivilege.isOpen ? (
<motion.div
key="privilege-modify"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="absolute min-h-[10rem] w-full"
>
<MembershipProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
projectMembershipId={membershipDetails?.id}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member)
}
/>
</motion.div>
) : (
<motion.div
key="privilege-list"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 0 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
className="absolute w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">
Project Additional Privilege
</h3>
{userId !== membershipDetails?.user?.id &&
membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="New privilege"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyPrivilege");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isLoading &&
userProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non expiry access";
if (privilegeDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(privilegeDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr
key={`user-project-privilege-${privilegeDetails?.id}`}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
handlePopUpOpen("modifyPrivilege", privilegeDetails);
}
}}
onClick={() => handlePopUpOpen("modifyPrivilege", privilegeDetails)}
>
<Td className="capitalize">{privilegeDetails.slug}</Td>
<Td>
<Tooltip asChild={false} content={toolTipText}>
<Tag
className={twMerge(
"capitalize",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Tag>
</Tooltip>
</Td>
<Td>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && !userProjectPrivileges?.length && (
<EmptyState title="This user has no additional privileges" icon={faFolder} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,387 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import {
faCaretDown,
faChevronLeft,
faClock,
faPlus,
faSave
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
ModalTrigger,
Popover,
PopoverContent,
PopoverTrigger,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useCreateProjectUserAdditionalPrivilege,
useGetProjectUserPrivilegeDetails,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
import {
formRolePermission2API,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection";
type Props = {
privilegeId?: string;
projectMembershipId: string;
onGoBack: () => void;
isDisabled?: boolean;
};
export const formSchema = z.object({
slug: z.string().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)
})
])
.default({ isTemporary: false }),
permissions: projectRoleFormSchema.shape.permissions
});
type TFormSchema = z.infer<typeof formSchema>;
export const MembershipProjectAdditionalPrivilegeModifySection = ({
privilegeId,
onGoBack,
projectMembershipId,
isDisabled
}: Props) => {
const isCreate = !privilegeId;
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
const projectId = currentWorkspace?.id || "";
const { data: privilegeDetails, isLoading } = useGetProjectUserPrivilegeDetails(
privilegeId || ""
);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Member
);
const form = useForm<TFormSchema>({
values: privilegeDetails
? {
...privilegeDetails,
permissions: rolePermission2Form(privilegeDetails.permissions),
temporaryAccess: privilegeDetails.isTemporary
? {
isTemporary: true,
temporaryRange: privilegeDetails.temporaryRange || "",
temporaryAccessEndTime: privilegeDetails.temporaryAccessEndTime || "",
temporaryAccessStartTime: privilegeDetails.temporaryAccessStartTime || ""
}
: {
isTemporary: privilegeDetails.isTemporary
}
}
: undefined,
resolver: zodResolver(formSchema)
});
const {
handleSubmit,
formState: { isDirty, isSubmitting }
} = form;
const { mutateAsync: updateUserProjectAdditionalPrivilege } =
useUpdateProjectUserAdditionalPrivilege();
const { mutateAsync: createUserProjectAdditionalPrivilege } =
useCreateProjectUserAdditionalPrivilege();
const onSubmit = async (el: TFormSchema) => {
const accessType = !el.temporaryAccess.isTemporary
? { role: el.slug, isTemporary: false as const }
: {
role: el.slug,
isTemporary: true as const,
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
temporaryRange: el.temporaryAccess.temporaryRange,
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
};
try {
if (isCreate) {
await createUserProjectAdditionalPrivilege({
permissions: formRolePermission2API(el.permissions),
projectMembershipId,
slug: el.slug || undefined,
type: accessType
});
createNotification({ type: "success", text: "Successfully created privilege" });
} else {
if (!projectId || !privilegeDetails?.id) return;
await updateUserProjectAdditionalPrivilege({
privilegeId: privilegeDetails.id,
permissions: formRolePermission2API(el.permissions),
projectMembershipId,
slug: el.slug || undefined,
type: accessType
});
createNotification({ type: "success", text: "Successfully updated privilege" });
}
onGoBack();
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const privilegeTemporaryAccess = form.watch("temporaryAccess");
const isTemporary = privilegeTemporaryAccess?.isTemporary;
const isExpired =
privilegeTemporaryAccess?.isTemporary &&
new Date() > new Date(privilegeTemporaryAccess.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non expiry access";
if (isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(privilegeTemporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<div className="flex space-x-2">
<IconButton ariaLabel="go-back" variant="plain" onClick={onGoBack}>
<FontAwesomeIcon icon={faChevronLeft} />
</IconButton>
<h3 className="text-lg font-semibold text-mineshaft-100">
Modify Additional Privilege
</h3>
</div>
<div className="flex items-center space-x-4">
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={onGoBack}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty || isDisabled}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<Modal
isOpen={popUp.createPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
>
<ModalTrigger asChild>
<Button
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={isDisabled}
>
New policy
</Button>
</ModalTrigger>
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
</ModalContent>
</Modal>
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 first:rounded-t-md last:rounded-b-md">
<div className="mb-2 text-lg">Overview</div>
<div className=" flex items-end space-x-6">
<div className="w-full max-w-md">
<Controller
control={form.control}
name="slug"
render={({ field }) => (
<FormControl label="Privilege Name" isOptional className="mb-0">
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
<div className="w-full max-w-md flex-grow">
<Tooltip content={toolTipText}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"w-full border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{text}
</Button>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={form.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 = form.getValues("temporaryAccess.temporaryRange");
if (!temporaryRange) {
form.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
form.clearErrors("temporaryAccess.temporaryRange");
form.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{isTemporary ? "Restart" : "Grant"}
</Button>
{isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
form.setValue(
"temporaryAccess",
{
isTemporary: false
},
{ shouldDirty: true }
);
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
{(isCreate || !isLoading) && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
);
};

View File

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

View File

@@ -1,22 +1,26 @@
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DeleteActionModal,
IconButton,
TableContainer,
Td,
Table,
Tr,
Th,
THead,
TableSkeleton,
EmptyState,
TBody,
Tooltip,
IconButton,
Modal,
ModalContent,
Tag
} from "@app/components/v2";
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@@ -28,10 +32,7 @@ import { usePopUp } from "@app/hooks";
import { useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { faFolder, faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { MemberRoleModify } from "./MemberRoleModify";
type Props = {
@@ -44,6 +45,7 @@ export const MemberRoleDetailsSection = ({
isMembershipDetailsLoading
}: Props) => {
const { user } = useUser();
const userId = user?.id;
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deleteRole",
@@ -51,7 +53,7 @@ export const MemberRoleDetailsSection = ({
] as const);
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const userId = user?.id;
const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id;
const handleRoleDelete = async () => {
const { id } = popUp?.deleteRole?.data as TProjectRole;
@@ -71,10 +73,10 @@ export const MemberRoleDetailsSection = ({
};
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Project Roles</h3>
{userId !== membershipDetails?.user?.id && membershipDetails?.status !== "invited" && (
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
@@ -170,7 +172,7 @@ export const MemberRoleDetailsSection = ({
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
isDisabled={!isAllowed || isOwnProjectMembershipDetails}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
@@ -191,7 +193,7 @@ export const MemberRoleDetailsSection = ({
</TBody>
</Table>
{!isMembershipDetailsLoading && !membershipDetails?.roles?.length && (
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} />
<EmptyState title="This user has no roles" icon={faFolder} />
)}
</TableContainer>
</div>

View File

@@ -1,20 +1,29 @@
import Link from "next/link";
import { Alert, AlertDescription } from "@app/components/v2";
import { useWorkspace } from "@app/context";
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) => {
const { currentWorkspace } = useWorkspace();
return (
<div>
<MemberRbacSection
projectMember={projectMember}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
<SpecificPrivilegeSection membershipId={projectMember?.id} />
<MemberRbacSection projectMember={projectMember} onOpenUpgradeModal={onOpenUpgradeModal} />
<Alert className="mt-4">
<AlertDescription>
Additional privileges now offer full permissions and have been moved to a new screen.
<br />
<Link href={`/project/${currentWorkspace?.id || ""}/members/${projectMember?.id}`}>
<span className="cursor-pointer text-primary">Click here to access them.</span>
</Link>
</AlertDescription>
</Alert>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useRouter } from "next/router";
import {
faClock,
faEdit,
@@ -7,7 +8,6 @@ import {
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useRouter } from "next/router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";

View File

@@ -44,7 +44,7 @@ export const RolePage = withProjectPermission(
if (!currentWorkspace?.slug || !data?.id) return;
await deleteProjectRole({
projectSlug: currentWorkspace.slug,
projectId,
id: data.id
});

View File

@@ -10,7 +10,8 @@ import { useWorkspace } from "@app/context";
import {
useCreateProjectRole,
useGetProjectRoleBySlug,
useUpdateProjectRole} from "@app/hooks/api";
useUpdateProjectRole
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
@@ -36,9 +37,9 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
};
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const projectId = currentWorkspace?.id || "";
const { data: role } = useGetProjectRoleBySlug(projectSlug, popupData?.roleSlug ?? "");
const { data: role } = useGetProjectRoleBySlug(projectId, popupData?.roleSlug ?? "");
const { mutateAsync: createProjectRole } = useCreateProjectRole();
const { mutateAsync: updateProjectRole } = useUpdateProjectRole();
@@ -74,13 +75,13 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
const onFormSubmit = async ({ name, description, slug }: FormData) => {
try {
if (!projectSlug) return;
if (!projectId) return;
if (role) {
// update
await updateProjectRole({
id: role.id,
projectSlug,
projectId,
name,
description,
slug
@@ -90,7 +91,7 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
} else {
// create
const newRole = await createProjectRole({
projectSlug,
projectId,
name,
description,
slug,

View File

@@ -71,7 +71,7 @@ const ConditionSchema = z
{ message: "Duplicate operator found for a condition" }
);
export const formSchema = z.object({
export const projectRoleFormSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
slug: z
@@ -133,7 +133,7 @@ export const formSchema = z.object({
.optional()
});
export type TFormSchema = z.infer<typeof formSchema>;
export type TFormSchema = z.infer<typeof projectRoleFormSchema>;
type TConditionalFields =
| ProjectPermissionSub.Secrets

View File

@@ -17,9 +17,9 @@ import { SecretPermissionConditions } from "./components/SecretPermissionConditi
import { PermissionEmptyState } from "./PermissionEmptyState";
import {
formRolePermission2API,
formSchema,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form,
TFormSchema
} from "./ProjectRoleModifySection.utils";
@@ -29,7 +29,10 @@ type Props = {
isDisabled?: boolean;
};
const renderConditionalComponents = (subject: ProjectPermissionSub, isDisabled?: boolean) => {
export const renderConditionalComponents = (
subject: ProjectPermissionSub,
isDisabled?: boolean
) => {
if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />;
@@ -43,7 +46,7 @@ const renderConditionalComponents = (subject: ProjectPermissionSub, isDisabled?:
export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
const projectSlug = currentWorkspace?.slug || "";
const projectId = currentWorkspace?.id || "";
const { data: role, isLoading } = useGetProjectRoleBySlug(
currentWorkspace?.slug ?? "",
roleSlug as string
@@ -51,7 +54,7 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const form = useForm<TFormSchema>({
values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined,
resolver: zodResolver(formSchema)
resolver: zodResolver(projectRoleFormSchema)
});
const {
@@ -64,14 +67,13 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const onSubmit = async (el: TFormSchema) => {
try {
if (!projectSlug || !role?.id) return;
if (!projectId || !role?.id) return;
await updateRole({
id: role?.id as string,
projectSlug,
projectId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
} catch (err) {
console.log(err);

View File

@@ -14,9 +14,9 @@ import {
import { ProjectPermissionSub } from "@app/context";
import {
formSchema,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
TFormSchema
} from "../ProjectRoleModifySection.utils";
@@ -32,7 +32,9 @@ export const NewPermissionRule = ({ onClose }: Props) => {
permissions: NonNullable<TFormSchema["permissions"]>;
}>({
resolver: zodResolver(
formSchema.pick({ permissions: true }).extend({ type: z.nativeEnum(ProjectPermissionSub) })
projectRoleFormSchema
.pick({ permissions: true })
.extend({ type: z.nativeEnum(ProjectPermissionSub) })
),
defaultValues: {
type: ProjectPermissionSub.Secrets