feat: renamed old frontend and fixed some minor bugs

This commit is contained in:
=
2025-01-06 14:56:49 +05:30
parent 8c902d7699
commit 54377a44d3
2577 changed files with 35240 additions and 35116 deletions
@@ -0,0 +1,100 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { getProjectTitle } from "@app/helpers/project";
import { withProjectPermission } from "@app/hoc";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectAccessControlTabs } from "@app/types/project";
import {
GroupsTab,
IdentityTab,
MembersTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
const Page = withProjectPermission(
() => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const selectedTab = useSearch({
strict: false,
select: (el) => el.selectedTab
});
const updateSelectedTab = (tab: string) => {
navigate({
to: `/${currentWorkspace.type}/$projectId/access-management` as const,
search: (prev) => ({ ...prev, selectedTab: tab }),
params: {
projectId: currentWorkspace.id
}
});
};
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<p className="mb-4 mr-4 text-3xl font-semibold text-white">
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
Control
</p>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={ProjectAccessControlTabs.Member}>Users</Tab>
<Tab value={ProjectAccessControlTabs.Groups}>Groups</Tab>
<Tab value={ProjectAccessControlTabs.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
</div>
</Tab>
{currentWorkspace?.type === ProjectType.SecretManager && (
<Tab value={ProjectAccessControlTabs.ServiceTokens}>Service Tokens</Tab>
)}
<Tab value={ProjectAccessControlTabs.Roles}>Project Roles</Tab>
</TabList>
<TabPanel value={ProjectAccessControlTabs.Member}>
<MembersTab />
</TabPanel>
<TabPanel value={ProjectAccessControlTabs.Groups}>
<GroupsTab />
</TabPanel>
<TabPanel value={ProjectAccessControlTabs.Identities}>
<IdentityTab />
</TabPanel>
{currentWorkspace?.type === ProjectType.SecretManager && (
<TabPanel value={ProjectAccessControlTabs.ServiceTokens}>
<ServiceTokenTab />
</TabPanel>
)}
<TabPanel value={ProjectAccessControlTabs.Roles}>
<ProjectRoleListTab />
</TabPanel>
</Tabs>
</div>
</div>
);
},
{
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Member
}
);
export const AccessControlPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<Page />
</>
);
};
@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { GroupsSection } from "./components";
export const GroupsTab = () => {
return (
<motion.div
key="panel-groups"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<GroupsSection />
</motion.div>
);
};
@@ -0,0 +1,168 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddGroupToWorkspace,
useGetOrganizationGroups,
useGetProjectRoles,
useListWorkspaceGroups
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
group: z.object({ id: z.string(), name: z.string() }),
role: z.object({ slug: z.string(), name: z.string() })
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["group"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
};
// TODO: update backend to support adding multiple roles at once
const Content = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?.id || "";
const { data: groups } = useGetOrganizationGroups(orgId);
const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { data: roles } = useGetProjectRoles(currentWorkspace?.id || "");
const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace();
const filteredGroupMembershipOrgs = useMemo(() => {
const wsGroupIds = new Map();
groupMemberships?.forEach((groupMembership) => {
wsGroupIds.set(groupMembership.group.id, true);
});
return (groups || []).filter(({ id }) => !wsGroupIds.has(id));
}, [groups, groupMemberships]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ group, role }: FormData) => {
try {
await addGroupToWorkspaceMutateAsync({
projectId: currentWorkspace?.id || "",
groupId: group.id,
role: role.slug || undefined
});
reset();
handlePopUpToggle("group", false);
createNotification({
text: "Successfully added group to project",
type: "success"
});
} catch {
createNotification({
text: "Failed to add group to project",
type: "error"
});
}
};
return filteredGroupMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="group"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
options={filteredGroupMembershipOrgs}
placeholder="Select group..."
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
options={roles}
placeholder="Select role..."
/>
</FormControl>
)}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.group?.data ? "Update" : "Add"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("group", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All groups in your organization have already been added to this project.
</div>
<Link to={"/organization/access-management" as const}>
<Button variant="outline_bg">Create a new group</Button>
</Link>
</div>
);
};
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.group?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
>
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,453 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const IdentityTemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger asChild>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Set Role Temporarily
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
groupId: string;
roles: TGroupMembership["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const GroupRoles = ({ roles = [], disableEdit = false, groupId }: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(
currentWorkspace?.id ?? ""
);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
try {
await updateGroupWorkspaceRole.mutateAsync({
projectId: currentWorkspace?.id || "",
groupId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated group role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch {
createNotification({ text: "Failed to update group role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<IdentityTemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
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>
);
};
@@ -0,0 +1,106 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useSubscription,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteGroupFromWorkspace } from "@app/hooks/api";
import { GroupModal } from "./GroupModal";
import { GroupTable } from "./GroupsTable";
export const GroupsSection = () => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"group",
"deleteGroup",
"upgradePlan"
] as const);
const handleAddGroupModal = () => {
if (!subscription?.groups) {
handlePopUpOpen("upgradePlan", {
description:
"You can manage users more efficiently with groups if you upgrade your Infisical plan."
});
} else {
handlePopUpOpen("group");
}
};
const onRemoveGroupSubmit = async (groupId: string) => {
try {
await deleteMutateAsync({
groupId,
projectId: currentWorkspace?.id || ""
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteGroup");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove group from project";
createNotification({
text,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">User Groups</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Groups}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handleAddGroupModal()}
isDisabled={!isAllowed}
>
Add Group
</Button>
)}
</ProjectPermissionCan>
</div>
<GroupModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<GroupTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteGroup.isOpen}
title={`Are you sure want to remove the group ${
(popUp?.deleteGroup?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveGroupSubmit((popUp?.deleteGroup?.data as { id: string })?.id)
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,202 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListWorkspaceGroups } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupRoles } from "./GroupRoles";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteGroup", "group"]>,
data?: {
id?: string;
name?: string;
}
) => void;
};
enum GroupsOrderBy {
Name = "name"
}
export const GroupTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
toggleOrderDirection
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
const { data: groupMemberships = [], isPending } = useListWorkspaceGroups(
currentWorkspace?.id || ""
);
const filteredGroupMemberships = useMemo(() => {
const filtered = search
? groupMemberships?.filter(
({ group: { name, slug } }) =>
name.toLowerCase().includes(search.toLowerCase()) ||
slug.toLowerCase().includes(search.toLowerCase())
)
: groupMemberships;
const ordered = filtered?.sort((a, b) =>
a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase())
);
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
}, [search, groupMemberships, orderBy, orderDirection]);
useResetPageHelper({
totalCount: filteredGroupMemberships.length,
offset,
setPage
});
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-groups" />}
{!isPending &&
filteredGroupMemberships &&
filteredGroupMemberships.length > 0 &&
filteredGroupMemberships
.slice(offset, perPage * page)
.map(({ group: { id, name }, roles, createdAt }) => {
return (
<Tr className="group h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Groups}
>
{(isAllowed) => (
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content="Remove">
<IconButton
onClick={() => {
handlePopUpOpen("deleteGroup", {
id,
name
});
}}
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No project groups match search..."
: "No project groups found"
}
icon={groupMemberships.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { GroupsSection } from "./GroupsSection";
@@ -0,0 +1 @@
export { GroupsSection } from "./GroupsSection";
@@ -0,0 +1 @@
export { GroupsTab } from "./GroupsTab";
@@ -0,0 +1,443 @@
import { subject } from "@casl/ability";
import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faClock,
faEllipsisV,
faMagnifyingGlass,
faPlus,
faServer,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
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";
import {
Button,
DeleteActionModal,
EmptyState,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Pagination,
Spinner,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectIdentityOrderBy } from "@app/hooks/api/workspace/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityModal } from "./components/IdentityModal";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
export const IdentityTab = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const {
offset,
limit,
orderBy,
setOrderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination(ProjectIdentityOrderBy.Name);
const workspaceId = currentWorkspace?.id ?? "";
const { data, isPending, isFetching } = useGetWorkspaceIdentityMemberships(
{
workspaceId: currentWorkspace?.id || "",
offset,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ placeholderData: (prevData) => prevData }
);
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"deleteIdentity",
"upgradePlan"
] as const);
const onRemoveIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
const handleSort = (column: ProjectIdentityOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/identities/overview"
>
<span className="flex w-max cursor-pointer items-center rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add Identity
</Button>
)}
</ProjectPermissionCan>
</div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${
orderBy === ProjectIdentityOrderBy.Name ? "" : "opacity-30"
}`}
ariaLabel="sort"
onClick={() => handleSort(ProjectIdentityOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === ProjectIdentityOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">Role</Th>
<Th>Added on</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-identities" />}
{!isPending &&
data &&
data.identityMemberships.length > 0 &&
data.identityMemberships.map((identityMember) => {
const {
identity: { id, name },
roles,
createdAt
} = identityMember;
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`st-v3-${id}`}
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
navigate({
to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const,
params: {
projectId: currentWorkspace.id,
identityId: id
}
});
}
}}
onClick={() =>
navigate({
to: `/${currentWorkspace?.type}/$projectId/identities/$identityId` as const,
params: {
projectId: currentWorkspace.id,
identityId: id
}
})
}
>
<Td>{name}</Td>
<Td>
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{formatRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Timed role expired"
: "Timed role access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() >
new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Access expired"
: "Temporary access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() >
new Date(
temporaryAccessEndTime as string
) && "text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
)}
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end space-x-2 opacity-0 duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<IconButton
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been added to this project"
}
icon={faServer}
/>
)}
</TableContainer>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
</motion.div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
);
@@ -0,0 +1,207 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent,
Spinner
} from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddIdentityToWorkspace,
useGetIdentityMembershipOrgs,
useGetProjectRoles,
useGetWorkspaceIdentityMemberships
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
identity: z.object({ name: z.string(), id: z.string() }),
role: z.object({ name: z.string(), slug: z.string() })
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["identity"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void;
};
const Content = ({ popUp, handlePopUpToggle }: Props) => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const organizationId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: identityMembershipOrgsData, isPending: isMembershipsLoading } =
useGetIdentityMembershipOrgs({
organizationId,
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR
});
const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships;
const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({
workspaceId,
limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above
});
const identityMemberships = identityMembershipsData?.identityMemberships;
const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace();
const filteredIdentityMembershipOrgs = useMemo(() => {
const wsIdentityIds = new Map();
identityMemberships?.forEach((identityMembership) => {
wsIdentityIds.set(identityMembership.identity.id, true);
});
return (identityMembershipOrgs || []).filter(({ identity: i }) => !wsIdentityIds.has(i.id));
}, [identityMembershipOrgs, identityMemberships]);
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
const onFormSubmit = async ({ identity, role }: FormData) => {
try {
await addIdentityToWorkspaceMutateAsync({
workspaceId,
identityId: identity.id,
role: role.slug || undefined
});
createNotification({
text: "Successfully added identity to project",
type: "success"
});
const nextAvailableMembership = filteredIdentityMembershipOrgs.filter(
(membership) => membership.identity.id !== identity.id
)[0];
// prevents combobox from displaying previously added identity
reset({
identity: {
name: nextAvailableMembership?.identity.name,
id: nextAvailableMembership?.identity.id
}
});
handlePopUpToggle("identity", false);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to add identity to project";
createNotification({
text,
type: "error"
});
}
};
if (isMembershipsLoading || isRolesLoading)
return (
<div className="flex w-full items-center justify-center py-10">
<Spinner className="text-mineshaft-400" />
</div>
);
return filteredIdentityMembershipOrgs.length ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select identity..."
options={filteredIdentityMembershipOrgs.map((membership) => membership.identity)}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={roles}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.identity?.data ? "Update" : "Add"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div className="text-sm">
All identities in your organization have already been added to this project.
</div>
<Link to={"/organization/access-management" as const}>
<Button isDisabled={isRolesLoading} isLoading={isRolesLoading} variant="outline_bg">
Create a new identity
</Button>
</Link>
</div>
);
};
export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
return (
<Modal
isOpen={popUp?.identity?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("identity", isOpen);
}}
>
<ModalContent title="Add Identity to Project" bodyClassName="overflow-visible">
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { IdentityTab } from "./IdentityTab";
@@ -0,0 +1,17 @@
import { motion } from "framer-motion";
import { MembersSection } from "./components";
export const MembersTab = () => {
return (
<motion.div
key="panel-project-members"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MembersSection />
</motion.div>
);
};
@@ -0,0 +1,222 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
import { useOrganization, useWorkspace } from "@app/context";
import {
useAddUsersToOrg,
useGetOrgUsers,
useGetProjectRoles,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const addMemberFormSchema = z.object({
orgMemberships: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).min(1),
projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1)
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
type Props = {
popUp: UsePopUpState<["addMember"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
};
export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: members } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId);
const { data: roles } = useGetProjectRoles(currentWorkspace?.id || "");
const {
control,
handleSubmit,
reset,
watch,
formState: { isSubmitting, errors }
} = useForm<TAddMemberForm>({
resolver: zodResolver(addMemberFormSchema),
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
});
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
if (!currentWorkspace) return;
if (!currentOrg?.id) return;
const selectedMembers = orgMemberships.map((orgMembership) =>
orgUsers?.find((orgUser) => orgUser.id === orgMembership.value)
);
if (!selectedMembers) return;
try {
if (currentWorkspace.version === ProjectVersion.V1) {
createNotification({
type: "error",
text: "Please upgrade your project to invite new members to the project."
});
} else {
const inviteeEmails = selectedMembers
.map((member) => member?.user.username as string)
.filter(Boolean);
if (inviteeEmails.length) {
await addMembersToProject({
inviteeEmails,
organizationId: orgId,
organizationRoleSlug: ProjectMembershipRole.Member, // ? This doesn't apply in this case, because we know the users being added are already part of the organization
projects: [
{
slug: currentWorkspace.slug,
id: currentWorkspace.id,
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
}
]
});
}
}
createNotification({
text: "Successfully added user to the project",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to add user to project",
type: "error"
});
}
handlePopUpToggle("addMember", false);
reset();
};
const filteredOrgUsers = useMemo(() => {
const wsUserUsernames = new Map();
members?.forEach((member) => {
wsUserUsernames.set(member.user.username, true);
});
return (orgUsers || [])
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
value: id,
label:
firstName && lastName
? `${firstName} ${lastName}`
: firstName || lastName || email || inviteEmail
}));
}, [orgUsers, members]);
const selectedOrgMemberships = watch("orgMemberships");
const selectedRoleSlugs = watch("projectRoleSlugs");
return (
<Modal
isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
>
<ModalContent
bodyClassName="overflow-visible"
title={t("section.members.add-dialog.add-member-to-project") as string}
subTitle={t("section.members.add-dialog.user-will-email")}
>
{filteredOrgUsers.length ? (
<form onSubmit={handleSubmit(onAddMembers)}>
<div className="flex w-full flex-col items-start gap-2">
<Controller
control={control}
name="orgMemberships"
render={({ field }) => (
<FormControl
className="w-full"
isError={!!errors.orgMemberships?.length}
errorText={errors.orgMemberships?.[0]?.message}
label="Invite users to project"
>
<FilterableSelect
className="w-full"
placeholder="Add one or more users..."
isMulti
name="members"
options={filteredOrgUsers}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="projectRoleSlugs"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Select roles"
tooltipText="Select the roles that you wish to assign to the users"
errorText={error?.message}
isError={Boolean(error)}
>
<FilterableSelect
options={roles}
placeholder="Select roles..."
value={value}
onChange={onChange}
isMulti
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>
</div>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={
isSubmitting ||
selectedOrgMemberships.length === 0 ||
selectedRoleSlugs.length === 0
}
>
Add Members
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("addMember", false)}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col space-y-4">
<div>All the users in your organization are already invited.</div>
<Link to={"/organization/access-management" as const}>
<Button variant="outline_bg">Add users to organization</Button>
</Link>
</div>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,351 @@
/* eslint-disable no-nested-ternary */
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
const roleFormSchema = z.object({
roles: z
.object({
slug: z.string(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
})
.array()
});
type TRoleForm = z.infer<typeof roleFormSchema>;
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isPending: 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.isPending) 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 {
createNotification({ text: "Failed to update role", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<div>
<div className="text-lg font-medium">Roles</div>
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
<div>
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
<div>
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non expiry access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled || selectedRoleList.fields.length === 1}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"cursor-default opacity-0",
roleForm.formState.isDirty && "cursor-pointer opacity-100"
)}
isDisabled={!roleForm.formState.isDirty}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { MemberRbacSection } from "./MemberRbacSection";
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
const { currentWorkspace } = useWorkspace();
return (
<div>
<MemberRbacSection projectMember={projectMember} onOpenUpgradeModal={onOpenUpgradeModal} />
<Alert
title="Additional privileges have been moved and now offer full permission customization."
className="mt-4 border-primary/50 bg-primary/10"
>
<AlertDescription>
<Link
to={`/${currentWorkspace.type}/$projectId/members/$membershipId` as const}
params={{
projectId: currentWorkspace.id,
membershipId: projectMember.id
}}
>
<span className="cursor-pointer text-primary underline underline-offset-2">
Click here to access them now
</span>
</Link>
</AlertDescription>
</Alert>
</div>
);
};
@@ -0,0 +1,572 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
faClock,
faLockOpen,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
DeleteActionModal,
FormControl,
FormLabel,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { removeTrailingSlash } from "@app/helpers/string";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
useCreateAccessRequest,
useDeleteProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
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>;
export const SpecificPrivilegeSecretForm = ({
privilege,
policies,
onClose
}: {
privilege?: TProjectUserPrivilege;
policies?: TAccessApprovalPolicy[];
onClose?: () => void;
}) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege",
"requestAccess"
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled =
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const requestAccess = useCreateAccessRequest();
const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema),
values: {
...(privilege
? {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob
? removeTrailingSlash(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
}
: {
environmentSlug: currentWorkspace.environments?.[0]?.slug,
read: false,
edit: false,
create: false,
delete: false,
temporaryAccess: {
isTemporary: false
}
})
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const selectedEnvironment = privilegeForm.watch("environmentSlug");
const secretPath = privilegeForm.watch("secretPath");
const readAccess = privilegeForm.watch("read");
const createAccess = privilegeForm.watch("create");
const editAccess = privilegeForm.watch("edit");
const deleteAccess = privilegeForm.watch("delete");
const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
const selectablePaths = useMemo(() => {
if (!policies) return [];
const environmentPolicies = policies.filter(
(policy) => policy.environment.slug === selectedEnvironment
);
privilegeForm.setValue("secretPath", "", {
shouldValidate: true
});
return [...environmentPolicies.map((policy) => policy.secretPath)];
}, [policies, selectedEnvironment]);
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleDeletePrivilege = async () => {
if (!privilege) {
createNotification({
type: "error",
text: "No privilege to delete found.",
title: "Error"
});
return;
}
if (deleteUserPrivilege.isPending) return;
try {
await deleteUserPrivilege.mutateAsync({
privilegeId: privilege.id,
projectMembershipId: privilege.projectMembershipId
});
createNotification({
type: "success",
text: "Successfully deleted privilege"
});
} catch {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
// This is used for requesting access additional privileges, not directly creating a privilege!
const handleRequestAccess = async (data: TSecretPermissionForm) => {
if (!policies) return;
if (!currentWorkspace) {
createNotification({
type: "error",
text: "No workspace found.",
title: "Error"
});
return;
}
if (!data.secretPath) {
createNotification({
type: "error",
text: "Please select a secret path",
title: "Error"
});
return;
}
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 requestAccess.mutateAsync({
...data,
...(data.temporaryAccess.isTemporary && {
temporaryRange: data.temporaryAccess.temporaryRange
}),
projectSlug: currentWorkspace.slug,
isTemporary: data.temporaryAccess.isTemporary,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
});
createNotification({
type: "success",
text: "Successfully requested access"
});
privilegeForm.reset();
if (onClose) onClose();
};
const handleSubmit = async (data: TSecretPermissionForm) => {
handleRequestAccess(data);
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime && !policies) {
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
}
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
<div className="mt-4 w-full">
<form onSubmit={privilegeForm.handleSubmit(handleSubmit)}>
<div className={twMerge("flex items-start gap-4", !privilege && "flex-wrap")}>
<Controller
control={privilegeForm.control}
name="environmentSlug"
render={({ field: { onChange, ...field } }) => (
<FormControl label="Environment">
<Select
{...field}
isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)}
>
{currentWorkspace?.environments?.map(({ slug, id, name }) => (
<SelectItem value={slug} key={id}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={privilegeForm.control}
name="secretPath"
render={({ field }) => {
if (policies) {
return (
<Tooltip
isDisabled={!!selectablePaths.length}
content="The selected environment doesn't have any policies."
>
<div>
<FormControl label="Secret Path">
<Select
{...field}
isDisabled={isMemberEditDisabled || !selectablePaths.length}
className="w-48"
onValueChange={(e) => field.onChange(e)}
>
{selectablePaths.map((path) => (
<SelectItem value={path} key={path}>
{path}
</SelectItem>
))}
</Select>
</FormControl>
</div>
</Tooltip>
);
}
return (
<FormControl label="Secret Path">
<SecretPathInput
{...field}
isDisabled={isMemberEditDisabled}
containerClassName="w-48"
environment={selectedEnvironment}
/>
</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-6 flex items-center space-x-2">
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip content={getAccessLabel(true)}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel(false)}
</Button>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={privilegeForm.control}
defaultValue="1h"
name="temporaryAccess.temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = privilegeForm.getValues(
"temporaryAccess.temporaryRange"
);
if (!temporaryRange) {
privilegeForm.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
privilegeForm.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccessField.isTemporary && !policies ? "Restart" : "Grant"}
</Button>
{temporaryAccessField.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
privilegeForm.setValue("temporaryAccess", {
isTemporary: false
});
}}
>
Cancel
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* eslint-disable-next-line no-nested-ternary */}
{privilegeForm.formState.isDirty && privilege ? (
<>
<Tooltip content="Cancel" className="mr-4">
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
</IconButton>
</Tooltip>
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Save"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-3"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
) : (
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
)}
</IconButton>
</Tooltip>
</>
) : // eslint-disable-next-line no-nested-ternary
privilege ? (
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
) : (
<div />
)}
</div>
</div>
{!!policies && (
<Button
type="submit"
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isPending}
isDisabled={
isMemberEditDisabled ||
!policies.length ||
!privilegeForm.formState.isValid ||
!secretPath ||
!accessSelected
}
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Request access
</Button>
)}
</form>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title="Remove user additional privilege"
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onClose={() => handlePopUpClose("deletePrivilege")}
onDeleteApproved={handleDeletePrivilege}
/>
</div>
);
};
@@ -0,0 +1 @@
export { MemberRoleForm } from "./MemberRoleForm";
@@ -0,0 +1,91 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace } from "@app/hooks/api";
import { AddMemberModal } from "./AddMemberModal";
import { MembersTable } from "./MembersTable";
export const MembersSection = () => {
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan"
] as const);
const handleRemoveUser = async () => {
const username = (popUp?.removeMember?.data as { username: string })?.username;
if (!currentOrg?.id) return;
if (!currentWorkspace?.id) return;
try {
await removeUserFromWorkspace({
workspaceId: currentWorkspace.id,
usernames: [username],
orgId: currentOrg.id
});
createNotification({
text: "Successfully removed user from project",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the project",
type: "error"
});
}
handlePopUpClose("removeMember");
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Users</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("addMember")}
isDisabled={!isAllowed}
>
Add Member
</Button>
)}
</ProjectPermissionCan>
</div>
<MembersTable handlePopUpOpen={handlePopUpOpen} />
<AddMemberModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
title="Do you want to remove this user from the project?"
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};
@@ -0,0 +1,375 @@
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faClock,
faEllipsisV,
faMagnifyingGlass,
faSearch,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useUser,
useWorkspace
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>,
data?: object
) => void;
};
enum MembersOrderBy {
Name = "firstName",
Email = "email"
}
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const navigate = useNavigate();
const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || "";
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const filteredUsers = useMemo(
() =>
members
?.filter(
({ user: u, inviteEmail }) =>
u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case MembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case MembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
if (!valueOne) return 1;
if (!valueTwo) return -1;
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
);
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
const handleSort = (column: MembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(MembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
const email = u?.email || inviteEmail;
return (
<Tr
key={`membership-${membershipId}`}
className="group w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
navigate({
to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const,
params: {
projectId: workspaceId,
membershipId
}
});
}
}}
onClick={() =>
navigate({
to: `/${currentWorkspace.type}/$projectId/members/$membershipId` as const,
params: {
projectId: workspaceId,
membershipId
}
})
}
>
<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 className="capitalize">
{formatRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired ? "Timed role expired" : "Timed role access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id,
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>
</Td>
<Td>
{userId !== u?.id && (
<div className="flex items-center space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
handlePopUpOpen("removeMember", { username: u.username });
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
)}
</Td>
</Tr>
);
})}
</TBody>
</Table>
{Boolean(filteredUsers.length) && (
<Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState
title={
members.length ? "No project members match search..." : "No project members found"
}
icon={members.length ? faSearch : faUsers}
/>
)}
</TableContainer>
</div>
);
};
@@ -0,0 +1 @@
export { MembersSection } from "./MembersSection";
@@ -0,0 +1 @@
export { MembersTab } from "./MembersTab";
@@ -0,0 +1,23 @@
import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { ProjectRoleList } from "./components/ProjectRoleList";
export const ProjectRoleListTab = withProjectPermission(
() => {
return (
<motion.div
key="role-list"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: -30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<ProjectRoleList />
</motion.div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
);
@@ -0,0 +1,184 @@
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { RoleModal } from "@app/pages/organization/RoleByIDPage/components";
export const ProjectRoleList = () => {
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"role",
"deleteRole"
] as const);
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(projectId);
const { mutateAsync: deleteRole } = useDeleteProjectRole();
const handleRoleDelete = async () => {
const { id } = popUp?.deleteRole?.data as TProjectRole;
try {
await deleteRole({
projectId,
id
});
createNotification({ type: "success", text: "Successfully removed the role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to delete role" });
}
};
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Project Roles</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Role}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("role")}
isDisabled={!isAllowed}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Slug</Th>
<Th aria-label="actions" className="w-5" />
</Tr>
</THead>
<TBody>
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
{roles?.map((role) => {
const { id, name, slug } = role;
const isNonMutatable = ["admin", "member", "viewer", "no-access"].includes(slug);
return (
<Tr
key={`role-list-${id}`}
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
onClick={() =>
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
})
}
>
<Td>{name}</Td>
<Td>{slug}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
navigate({
to: `/${currentWorkspace?.type}/$projectId/roles/$roleSlug` as const,
params: {
projectId: currentWorkspace.id,
roleSlug: slug
}
});
}}
disabled={!isAllowed}
>
{`${isNonMutatable ? "View" : "Edit"} Role`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
{!isNonMutatable && (
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", role);
}}
disabled={!isAllowed}
>
Delete Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete ${
(popUp?.deleteRole?.data as TProjectRole)?.name || " "
} role?`}
deleteKey={(popUp?.deleteRole?.data as TProjectRole)?.slug || ""}
onClose={() => handlePopUpClose("deleteRole")}
onDeleteApproved={handleRoleDelete}
/>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectRoleList } from "./ProjectRoleList";
@@ -0,0 +1 @@
export { ProjectRoleListTab } from "./ProjectRoleListTab";
@@ -0,0 +1,49 @@
// import { faWarning } from "@fortawesome/free-solid-svg-icons";
// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { ServiceTokenSection } from "./components";
export const ServiceTokenTab = () => {
return (
<motion.div
key="panel-service-token"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="space-y-3">
{/* <div className="flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white">
<FontAwesomeIcon icon={faWarning} className="pr-6 text-4xl text-white/80" />
<div className="flex w-full flex-col text-sm">
<span className="mb-4 text-lg font-semibold">Deprecation Notice</span>
<p>
Service Tokens are being deprecated in favor of Machine Identities.
<br />
They will be removed in the future in accordance with the deprecation notice and
timeline stated{" "}
<a
href="https://infisical.com/blog/deprecating-api-keys"
target="_blank"
className="font-semibold text-primary-400" rel="noreferrer"
>
here
</a>
.
<br />
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
className="font-semibold text-primary-400" rel="noreferrer"
>
Learn more about Machine Identities
</a>
</p>
</div>
</div> */}
<ServiceTokenSection />
</div>
</motion.div>
);
};
@@ -0,0 +1,379 @@
import crypto from "crypto";
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { faCheck, faCopy, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosError } from "axios";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
decryptAssymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import {
Button,
Checkbox,
FormControl,
IconButton,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useToggle } from "@app/hooks";
import { useCreateServiceToken, useGetUserWsKey } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const apiTokenExpiry = [
{ label: "1 Day", value: 86400 },
{ label: "7 Days", value: 604800 },
{ label: "1 Month", value: 2592000 },
{ label: "6 months", value: 15552000 },
{ label: "12 months", value: 31104000 },
{ label: "Never", value: null }
];
const schema = z.object({
name: z.string().max(100),
scopes: z
.object({
environment: z.string().max(50),
secretPath: z
.string()
.default("/")
.transform((val) =>
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
)
})
.array()
.min(1),
expiresIn: z.string().optional(),
permissions: z
.object({
read: z.boolean(),
write: z.boolean()
})
.required()
});
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["createAPIToken"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
scopes: [
{
secretPath: "/",
environment: currentWorkspace?.environments?.[0]?.slug
}
]
}
});
const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" });
const [newToken, setToken] = useState("");
const [isTokenCopied, setIsTokenCopied] = useToggle(false);
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?.id ?? "");
const createServiceToken = useCreateServiceToken();
const hasServiceToken = Boolean(newToken);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isTokenCopied) {
timer = setTimeout(() => setIsTokenCopied.off(), 2000);
}
return () => clearTimeout(timer);
}, [isTokenCopied]);
const copyTokenToClipboard = () => {
navigator.clipboard.writeText(newToken);
setIsTokenCopied.on();
};
const onFormSubmit = async ({ name, scopes, expiresIn, permissions }: FormData) => {
try {
if (!currentWorkspace?.id) return;
if (!latestFileKey) return;
const key = decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: localStorage.getItem("PRIVATE_KEY") as string
});
const randomBytes = crypto.randomBytes(16).toString("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
plaintext: key,
key: randomBytes
});
const { serviceToken } = await createServiceToken.mutateAsync({
encryptedKey: ciphertext,
iv,
tag,
scopes,
expiresIn: Number(expiresIn),
name,
workspaceId: currentWorkspace.id,
randomBytes,
permissions: Object.entries(permissions)
.filter(([, permissionsValue]) => permissionsValue)
.map(([permissionsKey]) => permissionsKey)
});
setToken(serviceToken);
createNotification({
text: "Successfully created a service token",
type: "success"
});
} catch (err) {
console.error(err);
const axiosError = err as AxiosError;
if (axiosError?.response?.status === 401) {
createNotification({
text: "You do not have access to the selected environment/path",
type: "error"
});
} else {
createNotification({
text: "Failed to create a service token",
type: "error"
});
}
}
};
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
title={
t("section.token.add-dialog.title", {
target: currentWorkspace?.name
}) as string
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,88 @@
import { useTranslation } from "react-i18next";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { usePopUp } from "@app/hooks";
import { useDeleteServiceToken } from "@app/hooks/api";
import { AddServiceTokenModal } from "./AddServiceTokenModal";
import { ServiceTokenTable } from "./ServiceTokenTable";
type DeleteModalData = { name: string; id: string };
export const ServiceTokenSection = withProjectPermission(
() => {
const { t } = useTranslation();
const deleteServiceToken = useDeleteServiceToken();
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
"createAPIToken",
"deleteAPITokenConfirmation"
] as const);
const onDeleteApproved = async () => {
try {
deleteServiceToken.mutateAsync(
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id
);
createNotification({
text: "Successfully deleted service token",
type: "success"
});
handlePopUpClose("deleteAPITokenConfirmation");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete service token",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("createAPIToken");
}}
isDisabled={!isAllowed}
>
Create token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteAPITokenConfirmation.isOpen}
title={`Delete ${
(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name || " "
} service token?`}
onChange={(isOpen) => handlePopUpToggle("deleteAPITokenConfirmation", isOpen)}
deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name}
onClose={() => handlePopUpClose("deleteAPITokenConfirmation")}
onDeleteApproved={onDeleteApproved}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens }
);
@@ -0,0 +1,108 @@
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetUserWsServiceTokens } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>,
{
name,
id
}: {
name: string;
id: string;
}
) => void;
};
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetUserWsServiceTokens({
workspaceID: currentWorkspace?.id || ""
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment - Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isPending &&
data &&
data.map((row) => (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>
<div className="mb-2 flex flex-col flex-wrap space-y-1">
{row?.scopes.map(({ secretPath, environment }) => (
<div
key={`${row.id}-${environment}-${secretPath}`}
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
>
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
<FontAwesomeIcon icon={faFolder} size="sm" />
<span className="pl-2">{secretPath}</span>
</div>
))}
</div>
</Td>
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.ServiceTokens}
>
{(isAllowed) => (
<IconButton
onClick={() =>
handlePopUpOpen("deleteAPITokenConfirmation", {
name: row.name,
id: row.id
})
}
colorSchema="danger"
ariaLabel="delete"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrashCan} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
))}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -0,0 +1 @@
export { ServiceTokenSection } from "./ServiceTokenSection";
@@ -0,0 +1 @@
export { ServiceTokenSection } from "./ServiceTokenSection";
@@ -0,0 +1 @@
export { ServiceTokenTab } from "./ServiceTokenTab";
@@ -0,0 +1,5 @@
export { GroupsTab } from "./GroupsTab";
export { IdentityTab } from "./IdentityTab";
export { MembersTab } from "./MembersTab";
export { ProjectRoleListTab } from "./ProjectRoleListTab";
export { ServiceTokenTab } from "./ServiceTokenTab";
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/cert-manager/$projectId/_cert-manager-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema)
});
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/kms/$projectId/_kms-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema)
});
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema)
});
@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { z } from "zod";
import { ProjectAccessControlTabs } from "@app/types/project";
import { AccessControlPage } from "./AccessControlPage";
const AccessControlPageQuerySchema = z.object({
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/ssh/$projectId/_ssh-layout/access-management"
)({
component: AccessControlPage,
validateSearch: zodValidator(AccessControlPageQuerySchema)
});
@@ -0,0 +1,198 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { format } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { getProjectTitle } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import {
useDeleteIdentityFromWorkspace,
useGetWorkspaceIdentityMembershipDetails
} from "@app/hooks/api";
import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection";
import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection";
const Page = () => {
const navigate = useNavigate();
const identityId = useParams({
strict: false,
select: (el) => el.identityId as string
});
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } =
useGetWorkspaceIdentityMembershipDetails(workspaceId, identityId);
const { mutateAsync: deleteMutateAsync, isPending: isDeletingIdentity } =
useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteIdentity",
"upgradePlan"
] as const);
const onRemoveIdentitySubmit = async () => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
navigate({
to: `/${currentWorkspace.type}/$projectId/access-management` as const,
params: {
projectId: workspaceId
},
search: {
selectedTab: "identities"
}
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
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">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: `/${currentWorkspace.type}/$projectId/access-management` as const,
params: {
projectId: workspaceId
},
search: {
selectedTab: "identities"
}
});
}}
className="mb-4"
>
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
Control
</Button>
</div>
{identityMembershipDetails ? (
<>
<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 Identity Access
</h3>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity?.id
})}
renderTooltip
allowedLabel="Remove from project"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={isDeletingIdentity}
onClick={() => handlePopUpOpen("deleteIdentity")}
>
Remove Identity
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex gap-12">
<div>
<span className="text-xs font-semibold text-gray-400">Name</span>
{identityMembershipDetails && (
<p className="text-lg capitalize">
{identityMembershipDetails?.identity?.name}
</p>
)}
</div>
</div>
<div className="mt-4 text-sm text-gray-400">
Joined on{" "}
{identityMembershipDetails?.createdAt &&
format(new Date(identityMembershipDetails?.createdAt || ""), "yyyy-MM-dd")}
</div>
</div>
</div>
<IdentityRoleDetailsSection
identityMembershipDetails={identityMembershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
/>
<IdentityProjectAdditionalPrivilegeSection
identityMembershipDetails={identityMembershipDetails}
/>
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${identityMembershipDetails?.identity?.name} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="remove"
onDeleteApproved={() => onRemoveIdentitySubmit()}
/>
</>
) : (
<EmptyState title="Error: Unable to find the identity." className="py-12" />
)}
</div>
);
};
export const IdentityDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Identity}
passThrough={false}
renderGuardBanner
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,435 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
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,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
FormLabel,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import {
useCreateIdentityProjectAdditionalPrivilege,
useGetIdentityProjectPrivilegeDetails,
useUpdateIdentityProjectAdditionalPrivilege
} from "@app/hooks/api";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies";
import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState";
import {
formRolePermission2API,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
} from "@app/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils";
import { renderConditionalComponents } from "@app/pages/project/RoleDetailsBySlugPage/components/RolePermissionsSection";
type Props = {
privilegeId?: string;
identityId: 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 IdentityProjectAdditionalPrivilegeModifySection = ({
privilegeId = "",
onGoBack,
identityId,
isDisabled
}: Props) => {
const isCreate = !privilegeId;
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: privilegeDetails, isPending } = useGetIdentityProjectPrivilegeDetails({
identityId,
projectId,
privilegeId
});
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
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: updateIdentityProjectAdditionalPrivilege } =
useUpdateIdentityProjectAdditionalPrivilege();
const { mutateAsync: createIdentityProjectAdditionalPrivilege } =
useCreateIdentityProjectAdditionalPrivilege();
const onSubmit = async (el: TFormSchema) => {
const accessType = !el.temporaryAccess.isTemporary
? { isTemporary: false as const }
: {
isTemporary: true as const,
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative,
temporaryRange: el.temporaryAccess.temporaryRange,
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
};
try {
if (isCreate) {
await createIdentityProjectAdditionalPrivilege({
permissions: formRolePermission2API(el.permissions),
identityId,
projectId,
slug: el.slug || undefined,
type: accessType
});
createNotification({ type: "success", text: "Successfully created privilege" });
} else {
if (!projectId || !privilegeDetails?.id) return;
await updateIdentityProjectAdditionalPrivilege({
privilegeId: privilegeDetails.id,
permissions: formRolePermission2API(el.permissions),
projectId,
identityId,
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 privilege" });
}
};
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-Expiring 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"
)}`;
}
}
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[...rootPolicyValue, ...[]],
{ shouldDirty: true, shouldTouch: true }
);
} else {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[{}],
{
shouldDirty: true,
shouldTouch: true
}
);
}
};
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">
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-lg font-semibold text-mineshaft-100"
variant="link"
onClick={onGoBack}
>
Back
</Button>
<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>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((permissionSubject) => (
<DropdownMenuItem
key={`permission-create-${permissionSubject}`}
className="py-3"
onClick={() => onNewPolicy(permissionSubject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
<div className="text-lg">Overview</div>
<p className="mb-4 text-sm text-mineshaft-300">
Additional privileges take precedence over roles when permissions conflict
</p>
<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={isIdentityEditDisabled} asChild>
<div className="w-full max-w-md flex-grow">
<FormLabel label="Duration" />
<Tooltip content={toolTipText}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isIdentityEditDisabled}
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 || !isPending) && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
(permissionSubject) => (
<GeneralPermissionPolicies
subject={permissionSubject}
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
key={`project-permission-${permissionSubject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(permissionSubject, isDisabled)}
</GeneralPermissionPolicies>
)
)}
</div>
</FormProvider>
</form>
);
};
@@ -0,0 +1,255 @@
import { subject } from "@casl/ability";
import { faEllipsisV, 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 } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
import { IdentityProjectAdditionalPrivilegeModifySection } from "./IdentityProjectAdditionalPrivilegeModifySection";
type Props = {
identityMembershipDetails: IdentityMembership;
};
export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDetails }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege",
"modifyPrivilege"
] as const);
const { permission } = useProjectPermission();
const identityId = identityMembershipDetails?.identity?.id;
const projectId = identityMembershipDetails?.project?.id;
const { mutateAsync: deletePrivilege } = useDeleteIdentityProjectAdditionalPrivilege();
const { data: identityProjectPrivileges, isPending } = useListIdentityProjectPrivileges({
identityId: identityMembershipDetails?.identity?.id,
projectId: identityMembershipDetails?.project?.id
});
const handlePrivilegeDelete = async () => {
const { id } = popUp?.deletePrivilege?.data as { id: string };
try {
await deletePrivilege({
privilegeId: id,
projectId,
identityId
});
createNotification({ type: "success", text: "Successfully removed the privilege" });
handlePopUpClose("deletePrivilege");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to delete privilege" });
}
};
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"
>
<IdentityProjectAdditionalPrivilegeModifySection
onGoBack={() => handlePopUpClose("modifyPrivilege")}
identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, {
identityId
})
)}
/>
</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 Privileges
</h3>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Add 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>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && (
<TableSkeleton columns={3} innerKey="user-project-identity-memberships" />
)}
{!isPending &&
identityProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring 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>{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="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handlePopUpOpen("deletePrivilege", {
id: privilegeDetails?.id,
slug: privilegeDetails?.slug
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
<IconButton ariaLabel="more-icon" variant="plain">
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !identityProjectPrivileges?.length && (
<EmptyState title="This identity has no additional privileges" icon={faFolder} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
deleteKey="remove"
title={`Do you want to remove privilege ${
(popUp?.deletePrivilege?.data as { slug: string; id: string })?.slug
}?`}
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
onDeleteApproved={() => handlePrivilegeDelete()}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
@@ -0,0 +1 @@
export { IdentityProjectAdditionalPrivilegeSection } from "./IdentityProjectAdditionalPrivilegeSection";
@@ -0,0 +1,238 @@
import { subject } from "@casl/ability";
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,
EmptyState,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { TProjectRole } from "@app/hooks/api/roles/types";
import { IdentityRoleModify } from "./IdentityRoleModify";
type Props = {
identityMembershipDetails: IdentityMembership;
isMembershipDetailsLoading?: boolean;
};
export const IdentityRoleDetailsSection = ({
identityMembershipDetails,
isMembershipDetailsLoading
}: Props) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deleteRole",
"modifyRole"
] as const);
const { mutateAsync: updateIdentityWorkspaceRole } = useUpdateIdentityWorkspaceRole();
const handleRoleDelete = async () => {
const { id } = popUp?.deleteRole?.data as TProjectRole;
try {
const updatedRoles = identityMembershipDetails?.roles?.filter((el) => el.id !== id);
await updateIdentityWorkspaceRole({
workspaceId: currentWorkspace?.id || "",
identityId: identityMembershipDetails.identity.id,
roles: updatedRoles.map(
({
role,
customRoleSlug,
isTemporary,
temporaryMode,
temporaryRange,
temporaryAccessStartTime,
temporaryAccessEndTime
}) => ({
role: role === "custom" ? customRoleSlug : role,
...(isTemporary
? {
isTemporary,
temporaryMode,
temporaryRange,
temporaryAccessStartTime,
temporaryAccessEndTime
}
: {
isTemporary
})
})
)
});
createNotification({ type: "success", text: "Successfully removed role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to delete role" });
}
};
return (
<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>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Edit Role(s)"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Role</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isMembershipDetailsLoading && (
<TableSkeleton columns={3} innerKey="user-project-identities" />
)}
{!isMembershipDetailsLoading &&
identityMembershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr className="group h-10" key={`user-project-identity-${roleDetails?.id}`}>
<Td className="capitalize">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</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={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails.identity.id
})}
renderTooltip
allowedLabel="Remove Role"
>
{(isAllowed) => (
<IconButton
colorSchema="danger"
ariaLabel="copy icon"
variant="plain"
className="group relative"
isDisabled={!isAllowed}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isMembershipDetailsLoading && !identityMembershipDetails?.roles?.length && (
<EmptyState title="This user has no roles" icon={faFolder} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${(popUp?.deleteRole?.data as TProjectRole)?.slug}?`}
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
onDeleteApproved={() => handleRoleDelete()}
/>
<Modal
isOpen={popUp.modifyRole.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("modifyRole", isOpen)}
>
<ModalContent
title="Roles"
subTitle="Select one or more of the pre-defined or custom roles to configure project permissions."
>
<IdentityRoleModify identityProjectMembership={identityMembershipDetails} />
</ModalContent>
</Modal>
</div>
);
};
@@ -0,0 +1,332 @@
/* eslint-disable no-nested-ternary */
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
const roleFormSchema = z.object({
roles: z
.object({
slug: z.string(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
})
.array()
});
type TRoleForm = z.infer<typeof roleFormSchema>;
type Props = {
identityProjectMembership: IdentityMembership;
};
export const IdentityRoleModify = ({ identityProjectMembership }: Props) => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
);
const roleForm = useForm<TRoleForm>({
resolver: zodResolver(roleFormSchema),
values: {
roles: identityProjectMembership?.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 updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
const handleRoleUpdate = async (data: TRoleForm) => {
if (updateIdentityWorkspaceRole.isPending) 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
};
});
try {
await updateIdentityWorkspaceRole.mutateAsync({
workspaceId,
identityId: identityProjectMembership.identity.id,
roles: sanitizedRoles
});
createNotification({ text: "Successfully updated roles", type: "success" });
} catch {
createNotification({ text: "Failed to update roles", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<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={isIdentityEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
containerClassName="w-1/2"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isIdentityEditDisabled} asChild>
<div className="flex-grow">
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non-Expiring Access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isIdentityEditDisabled}
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"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure Timed Access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isIdentityEditDisabled || selectedRoleList.fields.length === 1}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"cursor-default opacity-0",
roleForm.formState.isDirty && "cursor-pointer opacity-100"
)}
isDisabled={!roleForm.formState.isDirty}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { IdentityRoleDetailsSection } from "./IdentityRoleDetailsSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/cert-manager/$projectId/_cert-manager-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/kms/$projectId/_kms-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/ssh/$projectId/_ssh-layout/identities/$identityId"
)({
component: IdentityDetailsByIDPage
});
@@ -0,0 +1,206 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { format } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, Spinner } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { getProjectTitle } from "@app/helpers/project";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
export const Page = () => {
const navigate = useNavigate();
const membershipId = useParams({
strict: false,
select: (el) => el.membershipId as string
});
const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: membershipDetails, isPending: isMembershipDetailsLoading } =
useGetWorkspaceUserDetails(workspaceId, membershipId);
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
useDeleteUserFromWorkspace();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeMember",
"upgradePlan"
] as const);
const handleRemoveUser = async () => {
if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return;
try {
await removeUserFromWorkspace({
workspaceId: currentWorkspace.id,
usernames: [membershipDetails?.user?.username],
orgId: currentOrg.id
});
createNotification({
text: "Successfully removed user from project",
type: "success"
});
navigate({
to: `/${currentWorkspace.type}/$projectId/access-management` as const,
params: {
projectId: currentWorkspace.id
}
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to remove user from the project",
type: "error"
});
}
handlePopUpClose("removeMember");
};
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">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
navigate({
to: `/${currentWorkspace.type}/$projectId/access-management` as const,
params: {
projectId: currentWorkspace.id
}
});
}}
className="mb-4"
>
{currentWorkspace?.type ? getProjectTitle(currentWorkspace?.type) : "Project"} Access
Control
</Button>
</div>
{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.Delete}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove from project"
>
{(isAllowed) => (
<Button
colorSchema="danger"
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={isRemovingUserFromWorkspace}
onClick={() => handlePopUpOpen("removeMember")}
>
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>
<MemberRoleDetailsSection
membershipDetails={membershipDetails}
isMembershipDetailsLoading={isMembershipDetailsLoading}
onOpenUpgradeModal={() =>
handlePopUpOpen("upgradePlan", {
description:
"You can assign custom roles to members if you upgrade your Infisical plan."
})
}
/>
<MemberProjectAdditionalPrivilegeSection membershipDetails={membershipDetails} />
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
title="Do you want to remove this user from the project?"
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</>
) : (
<EmptyState title="Error: Unable to find the user." className="py-12" />
)}
</div>
);
};
export const MemberDetailsByIDPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<ProjectPermissionCan
passThrough
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Member}
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,259 @@
import { faEllipsisV, 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, isPending } = 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 privilege" });
}
};
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 Privileges
</h3>
{userId !== membershipDetails?.user?.id &&
membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Add 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>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
{!isPending &&
userProjectPrivileges?.map((privilegeDetails) => {
const isTemporary = privilegeDetails?.isTemporary;
const isExpired =
privilegeDetails.isTemporary &&
new Date() > new Date(privilegeDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring 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>{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="flex space-x-2 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="delete-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>
<IconButton
ariaLabel="more-icon"
variant="plain"
className="group relative"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && !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>
);
};
@@ -0,0 +1,429 @@
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,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
FormLabel,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import {
useCreateProjectUserAdditionalPrivilege,
useGetProjectUserPrivilegeDetails,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
import { GeneralPermissionPolicies } from "@app/pages/project/RoleDetailsBySlugPage/components/GeneralPermissionPolicies";
import { PermissionEmptyState } from "@app/pages/project/RoleDetailsBySlugPage/components/PermissionEmptyState";
import {
formRolePermission2API,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
} from "@app/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils";
import { renderConditionalComponents } from "@app/pages/project/RoleDetailsBySlugPage/components/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 projectId = currentWorkspace?.id || "";
const { data: privilegeDetails, isPending } = 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
? { isTemporary: false as const }
: {
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 privilege" });
}
};
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[...rootPolicyValue, ...[]],
{ shouldDirty: true, shouldTouch: true }
);
} else {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[{}],
{
shouldDirty: true,
shouldTouch: true
}
);
}
};
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-Expiring 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">
<Button
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
className="text-lg font-semibold text-mineshaft-100"
variant="link"
onClick={onGoBack}
>
Back
</Button>
<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>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((subject) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="mt-2 border-b border-gray-800 p-4 pt-2 first:rounded-t-md last:rounded-b-md">
<div className="text-lg">Overview</div>
<p className="mb-4 text-sm text-mineshaft-300">
Additional privileges take precedence over roles when permissions conflict
</p>
<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">
<FormLabel label="Duration" />
<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 || !isPending) && <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>
);
};
@@ -0,0 +1 @@
export { MemberProjectAdditionalPrivilegeSection } from "./MemberProjectAdditionalPrivilegeSection";
@@ -0,0 +1,249 @@
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,
EmptyState,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useUser,
useWorkspace
} from "@app/context";
import { formatProjectRoleName } from "@app/helpers/roles";
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 { MemberRoleModify } from "./MemberRoleModify";
type Props = {
membershipDetails: TWorkspaceUser;
isMembershipDetailsLoading?: boolean;
onOpenUpgradeModal: () => void;
};
export const MemberRoleDetailsSection = ({
membershipDetails,
isMembershipDetailsLoading,
onOpenUpgradeModal
}: Props) => {
const { user } = useUser();
const userId = user?.id;
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deleteRole",
"modifyRole"
] as const);
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const isOwnProjectMembershipDetails = userId === membershipDetails?.user?.id;
const handleRoleDelete = async () => {
const { id } = popUp?.deleteRole?.data as TProjectRole;
try {
const updatedRoles = membershipDetails?.roles?.filter((el) => el.id !== id);
await updateUserWorkspaceRole({
workspaceId: currentWorkspace?.id || "",
roles: updatedRoles.map(
({
role,
customRoleSlug,
isTemporary,
temporaryMode,
temporaryRange,
temporaryAccessStartTime,
temporaryAccessEndTime
}) => ({
role: role === "custom" ? customRoleSlug : role,
...(isTemporary
? {
isTemporary,
temporaryMode,
temporaryRange,
temporaryAccessStartTime,
temporaryAccessEndTime
}
: {
isTemporary
})
})
),
membershipId: membershipDetails.id
});
createNotification({ type: "success", text: "Successfully removed role" });
handlePopUpClose("deleteRole");
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to delete role" });
}
};
return (
<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>
{!isOwnProjectMembershipDetails && membershipDetails?.status !== "invited" && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Edit Role(s)"
>
{(isAllowed) => (
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("modifyRole");
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div className="py-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Role</Th>
<Th>Duration</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isMembershipDetailsLoading && (
<TableSkeleton columns={3} innerKey="user-project-memberships" />
)}
{!isMembershipDetailsLoading &&
membershipDetails?.roles?.map((roleDetails) => {
const isTemporary = roleDetails?.isTemporary;
const isExpired =
roleDetails.isTemporary &&
new Date() > new Date(roleDetails.temporaryAccessEndTime || "");
let text = "Permanent";
let toolTipText = "Non-Expiring Access";
if (roleDetails.isTemporary) {
if (isExpired) {
text = "Access Expired";
toolTipText = "Timed Access Expired";
} else {
text = formatDistance(
new Date(roleDetails.temporaryAccessEndTime || ""),
new Date()
);
toolTipText = `Until ${format(
new Date(roleDetails.temporaryAccessEndTime || ""),
"yyyy-MM-dd hh:mm:ss aaa"
)}`;
}
}
return (
<Tr className="group h-10" key={`user-project-membership-${roleDetails?.id}`}>
<Td className="capitalize">
{roleDetails.role === "custom"
? roleDetails.customRoleName
: formatProjectRoleName(roleDetails.role)}
</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();
handlePopUpOpen("deleteRole", {
id: roleDetails?.id,
slug: roleDetails?.customRoleName || roleDetails?.role
});
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isMembershipDetailsLoading && !membershipDetails?.roles?.length && (
<EmptyState title="This user has no roles" icon={faFolder} />
)}
</TableContainer>
</div>
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
deleteKey="remove"
title={`Do you want to remove role ${(popUp?.deleteRole?.data as TProjectRole)?.slug}?`}
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
onDeleteApproved={() => handleRoleDelete()}
/>
<Modal
isOpen={popUp.modifyRole.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("modifyRole", isOpen)}
>
<ModalContent
title="Roles"
subTitle="Select one or more of the pre-defined or custom roles to configure project permissions."
>
<MemberRoleModify
projectMember={membershipDetails}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
</ModalContent>
</Modal>
</div>
);
};
@@ -0,0 +1,346 @@
/* eslint-disable no-nested-ternary */
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
const roleFormSchema = z.object({
roles: z
.object({
slug: z.string(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
})
.array()
});
type TRoleForm = z.infer<typeof roleFormSchema>;
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isPending: 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.isPending) 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" });
} catch {
createNotification({ text: "Failed to update roles", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
containerClassName="w-1/2"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
<div className="flex-grow">
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non-Expiring Access"
}
>
<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"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure Timed Access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled || selectedRoleList.fields.length === 1}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"cursor-default opacity-0",
roleForm.formState.isDirty && "cursor-pointer opacity-100"
)}
isDisabled={!roleForm.formState.isDirty}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
);
};
@@ -0,0 +1 @@
export { MemberRoleDetailsSection } from "./MemberRoleDetailsSection";
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/cert-manager/$projectId/_cert-manager-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/kms/$projectId/_kms-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { MemberDetailsByIDPage } from "./MemberDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/ssh/$projectId/_ssh-layout/members/$membershipId"
)({
component: MemberDetailsByIDPage
});
@@ -0,0 +1,198 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useDeleteProjectRole, useGetProjectRoleBySlug } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ProjectAccessControlTabs } from "@app/types/project";
import { RoleDetailsSection } from "./components/RoleDetailsSection";
import { RoleModal } from "./components/RoleModal";
import { RolePermissionsSection } from "./components/RolePermissionsSection";
const Page = () => {
const navigate = useNavigate();
const roleSlug = useParams({
strict: false,
select: (el) => el.roleSlug as string
});
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data } = useGetProjectRoleBySlug(projectId, roleSlug as string);
const { mutateAsync: deleteProjectRole } = useDeleteProjectRole();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"role",
"deleteRole"
] as const);
const onDeleteRoleSubmit = async () => {
try {
if (!currentWorkspace?.slug || !data?.id) return;
await deleteProjectRole({
projectId,
id: data.id
});
createNotification({
text: "Successfully deleted project role",
type: "success"
});
handlePopUpClose("deleteRole");
navigate({
to: `/${currentWorkspace?.type}/$projectId/access-management` as const,
params: {
projectId
},
search: {
selectedTab: ProjectAccessControlTabs.Roles
}
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete project role";
createNotification({
text,
type: "error"
});
}
};
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? "");
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
{data && (
<div className="mx-auto mb-6 w-full max-w-7xl px-6 py-6">
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() =>
navigate({
to: `/${currentWorkspace?.type}/$projectId/access-management` as const,
params: {
projectId
},
search: {
selectedTab: ProjectAccessControlTabs.Roles
}
})
}
className="mb-4"
>
Roles
</Button>
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">{data.name}</p>
{isCustomRole && (
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() =>
handlePopUpOpen("role", {
roleSlug
})
}
disabled={!isAllowed}
>
Edit Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={() => handlePopUpOpen("deleteRole")}
disabled={!isAllowed}
>
Delete Role
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex">
<div className="mr-4 w-96">
<RoleDetailsSection roleSlug={roleSlug} handlePopUpOpen={handlePopUpOpen} />
</div>
<RolePermissionsSection roleSlug={roleSlug} isDisabled={!isCustomRole} />
</div>
</div>
)}
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteRole.isOpen}
title={`Are you sure want to delete the project role ${data?.name ?? ""}?`}
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onDeleteRoleSubmit()}
/>
</div>
);
};
export const RoleDetailsBySlugPage = () => {
const { t } = useTranslation();
return (
<>
<Helmet>
<title>{t("common.head-title", { title: "Project Settings" })}</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.Role}
renderGuardBanner
passThrough={false}
>
<Page />
</ProjectPermissionCan>
</>
);
};
@@ -0,0 +1,176 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
type:
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports;
};
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${type}.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition =
(watch(`permissions.${type}.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
}) || {};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.[type]?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.[type]?.[position]?.conditions?.message}</span>
</div>
)}
<div>{}</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,188 @@
import { cloneElement } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import {
faChevronDown,
faChevronRight,
faInfoCircle,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks";
import {
isConditionalSubjects,
TFormSchema,
TProjectPermissionObject
} from "./ProjectRoleModifySection.utils";
type Props<T extends ProjectPermissionSub> = {
title: string;
subject: T;
actions: TProjectPermissionObject[T]["actions"];
children?: JSX.Element;
isDisabled?: boolean;
};
export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchema["permissions"]>>({
subject,
actions,
children,
title,
isDisabled
}: Props<T>) => {
const { control } = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${subject}`
});
const [isOpen, setIsOpen] = useToggle();
if (!items.fields.length) return <div />;
return (
<div className="border border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md">
<div
className="flex cursor-pointer items-center space-x-8 px-5 py-4 text-sm text-gray-300"
role="button"
tabIndex={0}
onClick={() => setIsOpen.toggle()}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsOpen.toggle();
}
}}
>
<div>
<FontAwesomeIcon icon={isOpen ? faChevronDown : faChevronRight} />
</div>
<div className="flex-grow">{title}</div>
{items.fields.length > 1 && (
<div>
<Tag size="xs" className="px-2">
{items.fields.length} rules
</Tag>
</div>
)}
</div>
{isOpen && (
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
{items.fields.map((el, rootIndex) => (
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
{isConditionalSubjects(subject) && (
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
</Tooltip>
</div>
</div>
)}
<div className="flex text-gray-300">
<div className="w-1/4">Actions</div>
<div className="flex flex-grow flex-wrap justify-start gap-8">
{actions.map(({ label, value }) => {
if (typeof value !== "string") return undefined;
return (
<Controller
key={`${el.id}-${label}`}
name={`permissions.${subject}.${rootIndex}.${value}` as any}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isDisabled={isDisabled}
isChecked={Boolean(field.value)}
onCheckedChange={field.onChange}
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
>
{label}
</Checkbox>
</div>
)}
/>
);
})}
</div>
</div>
{children &&
cloneElement(children, {
position: rootIndex
})}
<div
className={twMerge(
"mt-4 flex justify-start space-x-4",
isConditionalSubjects(subject) && "justify-end"
)}
>
{!isDisabled && isConditionalSubjects(subject) && (
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-2"
onClick={() => {
items.insert(rootIndex + 1, [
{ read: false, edit: false, create: false, delete: false } as any
]);
}}
isDisabled={isDisabled}
>
Add policy
</Button>
)}
{!isDisabled && (
<Button
leftIcon={<FontAwesomeIcon icon={faTrash} />}
variant="outline_bg"
size="xs"
className="mt-2 hover:border-red"
onClick={() => items.remove(rootIndex)}
isDisabled={isDisabled}
>
Remove policy
</Button>
)}{" "}
</div>
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,171 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const IdentityManagementPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const permissionSubject = ProjectPermissionSub.Identity;
const items = useFieldArray({
control,
name: `permissions.${permissionSubject}.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition =
(watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
}) || {};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value="identityId">Identity ID</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${permissionSubject}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message}</span>
</div>
)}
<div>{}</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "identityId",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,124 @@
import { Controller, useForm, useFormContext } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
Checkbox,
FormControl,
FormLabel,
ModalClose,
Select,
SelectItem
} from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import {
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
TFormSchema
} from "./ProjectRoleModifySection.utils";
type Props = {
onClose: () => void;
};
export const NewPermissionRule = ({ onClose }: Props) => {
const rootForm = useFormContext<TFormSchema>();
const form = useForm<{
type: ProjectPermissionSub;
permissions: NonNullable<TFormSchema["permissions"]>;
}>({
resolver: zodResolver(
projectRoleFormSchema
.pick({ permissions: true })
.extend({ type: z.nativeEnum(ProjectPermissionSub) })
),
defaultValues: {
type: ProjectPermissionSub.Secrets
}
});
const selectedSubject = form.watch("type");
return (
<div>
<Controller
control={form.control}
name="type"
defaultValue={ProjectPermissionSub.Secrets}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Subject" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{Object.keys(PROJECT_PERMISSION_OBJECT).map((subject) => (
<SelectItem value={subject} key={`permission-create-${subject}`}>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<FormLabel label="Actions" className="my-2" />
<div className="flex flex-grow flex-wrap justify-start gap-8">
{PROJECT_PERMISSION_OBJECT?.[selectedSubject]?.actions?.map(({ label, value }) => (
<Controller
key={`create-permission-${selectedSubject}-${label}`}
name={`permissions.${selectedSubject}.0.${value as any}` as any}
control={form.control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`new-permissions.${selectedSubject}.0.${String(value)}`}
>
{label}
</Checkbox>
</div>
)}
/>
))}
</div>
<div className="mt-8 flex space-x-4">
<Button
onClick={form.handleSubmit((el) => {
const rootPolicyValue = rootForm.getValues("permissions")?.[el.type];
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
rootForm.setValue(
`permissions.${el.type}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[...rootPolicyValue, ...(el?.permissions[el.type] || [])],
{ shouldDirty: true, shouldTouch: true }
);
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
rootForm.setValue(`permissions.${el.type}`, el?.permissions?.[el.type], {
shouldDirty: true,
shouldTouch: true
});
}
onClose();
})}
>
Create
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</div>
);
};
@@ -0,0 +1,33 @@
import { GlobPermissionInfo } from "@app/components/permissions";
import { SelectItem } from "@app/components/v2";
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
export const getConditionOperatorHelperInfo = (type: PermissionConditionOperators) => {
switch (type) {
case PermissionConditionOperators.$EQ:
return "Value should equal specified value.";
case PermissionConditionOperators.$NEQ:
return "Value should not equal specified value.";
case PermissionConditionOperators.$IN:
return "List of comma-separated values that match a given value.";
case PermissionConditionOperators.$GLOB:
return <GlobPermissionInfo />;
default:
return "";
}
};
export const renderOperatorSelectItems = (type: string) => {
if (type === "secretTags") {
return <SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>;
}
return (
<>
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob Match</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
</>
);
};
@@ -0,0 +1,18 @@
import { useFormContext } from "react-hook-form";
import { EmptyState } from "@app/components/v2";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
// This is made into seperate component because watch subscribes to all permissions
// thus keeping in top level casues render on all ones
export const PermissionEmptyState = () => {
const { watch } = useFormContext<TFormSchema>();
const isNotEmptyPermissions = Object.entries(watch("permissions") || {}).some(
([key, value]) => key && value?.length > 0
);
if (isNotEmptyPermissions) return <div />;
return <EmptyState title="No policies applied" className="py-8" />;
};
@@ -0,0 +1,674 @@
import { z } from "zod";
import {
ProjectPermissionActions,
ProjectPermissionCmekActions,
ProjectPermissionSub
} from "@app/context";
import {
PermissionConditionOperators,
ProjectPermissionDynamicSecretActions,
TPermissionCondition,
TPermissionConditionOperators
} from "@app/context/ProjectPermissionContext/types";
import { TProjectPermission } from "@app/hooks/api/roles/types";
const GeneralPolicyActionSchema = z.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
delete: z.boolean().optional(),
create: z.boolean().optional()
});
const CmekPolicyActionSchema = z.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
delete: z.boolean().optional(),
create: z.boolean().optional(),
encrypt: z.boolean().optional(),
decrypt: z.boolean().optional()
});
const DynamicSecretPolicyActionSchema = z.object({
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.EditRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.Lease]: z.boolean().optional()
});
const SecretRollbackPolicyActionSchema = z.object({
read: z.boolean().optional(),
create: z.boolean().optional()
});
const WorkspacePolicyActionSchema = z.object({
edit: z.boolean().optional(),
delete: z.boolean().optional()
});
const ConditionSchema = z
.object({
operator: z.string(),
lhs: z.string(),
rhs: z.string().min(1)
})
.array()
.optional()
.default([])
.refine(
(el) => {
const lhsOperatorSet = new Set<string>();
for (let i = 0; i < el.length; i += 1) {
const { lhs, operator } = el[i];
if (lhsOperatorSet.has(`${lhs}-${operator}`)) {
return false;
}
lhsOperatorSet.add(`${lhs}-${operator}`);
}
return true;
},
{ message: "Duplicate operator found for a condition" }
);
export const projectRoleFormSchema = z.object({
name: z.string().trim(),
description: z.string().trim().optional(),
slug: z
.string()
.trim()
.toLowerCase()
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
permissions: z
.object({
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SecretFolders]: GeneralPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.SecretImports]: GeneralPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.DynamicSecrets]: DynamicSecretPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
})
.array()
.default([]),
[ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.ServiceTokens]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Settings]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Environments]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.AuditLogs]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.IpAllowList]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateAuthorities]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Certificates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.PkiAlerts]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.PkiCollections]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateAuthorities]: GeneralPolicyActionSchema.array().default(
[]
),
[ProjectPermissionSub.SshCertificates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SshCertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([])
})
.partial()
.optional()
});
export type TFormSchema = z.infer<typeof projectRoleFormSchema>;
type TConditionalFields =
| ProjectPermissionSub.Secrets
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.Identity;
export const isConditionalSubjects = (
subject: ProjectPermissionSub
): subject is TConditionalFields =>
subject === (ProjectPermissionSub.Secrets as const) ||
subject === ProjectPermissionSub.DynamicSecrets ||
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders ||
subject === ProjectPermissionSub.Identity;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = [];
Object.entries(caslConditions).forEach(([type, condition]) => {
if (typeof condition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: type,
rhs: condition
});
} else {
Object.keys(condition).forEach((conditionOperator) => {
const rhs = condition[conditionOperator as PermissionConditionOperators];
formConditions.push({
operator: conditionOperator,
lhs: type,
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
});
}
});
return formConditions;
};
// convert role permission to form compatible data structure
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const formVal: Partial<TFormSchema["permissions"]> = {};
permissions.forEach((permission) => {
const { subject: caslSub, action, conditions, inverted } = permission;
const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub;
if (!action.length) return;
if (
[
ProjectPermissionSub.Secrets,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports,
ProjectPermissionSub.Member,
ProjectPermissionSub.Groups,
ProjectPermissionSub.Identity,
ProjectPermissionSub.Role,
ProjectPermissionSub.Integrations,
ProjectPermissionSub.Webhooks,
ProjectPermissionSub.ServiceTokens,
ProjectPermissionSub.Settings,
ProjectPermissionSub.Environments,
ProjectPermissionSub.AuditLogs,
ProjectPermissionSub.IpAllowList,
ProjectPermissionSub.CertificateAuthorities,
ProjectPermissionSub.Certificates,
ProjectPermissionSub.PkiAlerts,
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.CertificateTemplates,
ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.Tags,
ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Kms
].includes(subject)
) {
// from above statement we are sure it won't be undefined
if (isConditionalSubjects(subject)) {
if (!formVal[subject]) formVal[subject] = [];
if (subject === ProjectPermissionSub.DynamicSecrets) {
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential);
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential);
const canDelete = action.includes(
ProjectPermissionDynamicSecretActions.DeleteRootCredential
);
const canCreate = action.includes(
ProjectPermissionDynamicSecretActions.CreateRootCredential
);
const canLease = action.includes(ProjectPermissionDynamicSecretActions.Lease);
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: canRead,
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: canCreate,
[ProjectPermissionDynamicSecretActions.EditRootCredential]: canEdit,
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: canDelete,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted,
[ProjectPermissionDynamicSecretActions.Lease]: canLease
});
return;
}
// for other subjects
const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
// remove this condition later
// keeping when old routes create permission with folder read
if (
subject === ProjectPermissionSub.SecretFolders &&
canRead &&
!canEdit &&
!canDelete &&
!canCreate
) {
return;
}
formVal[subject]!.push({
read: canRead,
create: canCreate,
edit: canEdit,
delete: canDelete,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
return;
}
// deduplicate multiple rules for other policies
// because they don't have condition it doesn't make sense for multiple rules
const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
if (!formVal[subject]) formVal[subject] = [{}];
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
return;
}
if (subject === ProjectPermissionSub.Project) {
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canEdit) formVal[subject as ProjectPermissionSub.Project]![0].edit = true;
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
return;
}
if (subject === ProjectPermissionSub.SecretRollback) {
const canRead = action.includes(ProjectPermissionActions.Read);
const canCreate = action.includes(ProjectPermissionActions.Create);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
return;
}
if (subject === ProjectPermissionSub.Cmek) {
const canRead = action.includes(ProjectPermissionCmekActions.Read);
const canEdit = action.includes(ProjectPermissionCmekActions.Edit);
const canDelete = action.includes(ProjectPermissionCmekActions.Delete);
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject]![0].read = true;
if (canEdit) formVal[subject]![0].edit = true;
if (canCreate) formVal[subject]![0].create = true;
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
}
});
return formVal;
};
const convertFormOperatorToCaslCondition = (
conditions: { lhs: string; rhs: string; operator: string }[]
) => {
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
conditions.forEach((el) => {
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
if (
el.operator === PermissionConditionOperators.$IN ||
el.operator === PermissionConditionOperators.$ALL
) {
caslCondition[el.lhs][el.operator] = el.rhs.split(",");
} else {
caslCondition[el.lhs][
el.operator as Exclude<
PermissionConditionOperators,
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN
>
] = el.rhs;
}
});
return caslCondition;
};
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TProjectPermission[] = [];
// other than workspace everything else follows same
// if in future there is a different follow the above on how workspace is done
Object.entries(formVal || {}).forEach(([subject, rules]) => {
rules.forEach((actions) => {
const caslActions = Object.keys(actions).filter(
(el) => actions?.[el as keyof typeof actions] && el !== "conditions" && el !== "inverted"
);
const caslConditions =
"conditions" in actions
? convertFormOperatorToCaslCondition(actions.conditions)
: undefined;
permissions.push({
action: caslActions,
subject,
inverted: (actions as { inverted?: boolean })?.inverted,
conditions: caslConditions
});
});
});
return permissions;
};
export type TProjectPermissionObject = {
[K in ProjectPermissionSub]: {
title: string;
actions: {
label: string;
value: keyof Omit<
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
"conditions" | "inverted"
>;
}[];
};
};
export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
[ProjectPermissionSub.Secrets]: {
title: "Secrets",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretFolders]: {
title: "Secret Folders",
actions: [
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretImports]: {
title: "Secret Imports",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.DynamicSecrets]: {
title: "Dynamic Secrets",
actions: [
{
label: "Read root credentials",
value: ProjectPermissionDynamicSecretActions.ReadRootCredential
},
{
label: "Create root credentials",
value: ProjectPermissionDynamicSecretActions.CreateRootCredential
},
{
label: "Modify root credentials",
value: ProjectPermissionDynamicSecretActions.EditRootCredential
},
{
label: "Remove root credentials",
value: ProjectPermissionDynamicSecretActions.DeleteRootCredential
},
{ label: "Manage Leases", value: ProjectPermissionDynamicSecretActions.Lease }
]
},
[ProjectPermissionSub.Cmek]: {
title: "KMS",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" },
{ label: "Encrypt", value: "encrypt" },
{ label: "Decrypt", value: "decrypt" }
]
},
[ProjectPermissionSub.Kms]: {
title: "Project KMS Configuration",
actions: [{ label: "Modify", value: "edit" }]
},
[ProjectPermissionSub.Integrations]: {
title: "Integrations",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Project]: {
title: "Project",
actions: [
{ label: "Update project details", value: "edit" },
{ label: "Delete project", value: "delete" }
]
},
[ProjectPermissionSub.Role]: {
title: "Roles",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Member]: {
title: "User Management",
actions: [
{ label: "View all members", value: "read" },
{ label: "Invite members", value: "create" },
{ label: "Edit members", value: "edit" },
{ label: "Remove members", value: "delete" }
]
},
[ProjectPermissionSub.Identity]: {
title: "Machine Identity Management",
actions: [
{ label: "Read", value: "read" },
{ label: "Add", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Groups]: {
title: "Group Management",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Webhooks]: {
title: "Webhooks",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.ServiceTokens]: {
title: "Service Tokens",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Settings]: {
title: "Settings",
actions: [
{ label: "Read", value: "read" },
{ label: "Modify", value: "edit" }
]
},
[ProjectPermissionSub.Environments]: {
title: "Environment Management",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Tags]: {
title: "Tags",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.AuditLogs]: {
title: "Audit Logs",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.IpAllowList]: {
title: "IP Allowlist",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.CertificateAuthorities]: {
title: "Certificate Authorities",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.Certificates]: {
title: "Certificates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.CertificateTemplates]: {
title: "Certificate Templates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificateAuthorities]: {
title: "SSH Certificate Authorities",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificates]: {
title: "SSH Certificates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SshCertificateTemplates]: {
title: "SSH Certificate Templates",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.PkiCollections]: {
title: "PKI Collections",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.PkiAlerts]: {
title: "PKI Alerts",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretApproval]: {
title: "Secret Protect policy",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretRotation]: {
title: "Secret Rotation",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretRollback]: {
title: "Secret Rollback",
actions: [
{ label: "Perform rollback", value: "create" },
{ label: "View", value: "read" }
]
}
};
@@ -0,0 +1,95 @@
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { useGetProjectRoleBySlug } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
roleSlug: string;
handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: object) => void;
};
export const RoleDetailsSection = ({ roleSlug, handlePopUpOpen }: Props) => {
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
initialState: "Copy ID to clipboard"
});
const { currentWorkspace } = useWorkspace();
const { data } = useGetProjectRoleBySlug(currentWorkspace?.id ?? "", roleSlug as string);
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(data?.slug ?? "");
return data ? (
<div className="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 Role Details</h3>
{isCustomRole && (
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Role}>
{(isAllowed) => {
return (
<Tooltip content="Edit Role">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("role", {
roleSlug
});
}}
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
</Tooltip>
);
}}
</ProjectPermissionCan>
)}
</div>
<div className="pt-4">
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Role ID</p>
<div className="group flex align-top">
<p className="text-sm text-mineshaft-300">{data.id}</p>
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<Tooltip content={copyTextId}>
<IconButton
ariaLabel="copy icon"
variant="plain"
className="group relative ml-2"
onClick={() => {
navigator.clipboard.writeText(data.id);
setCopyTextId("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
</IconButton>
</Tooltip>
</div>
</div>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">{data.name}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
<p className="text-sm text-mineshaft-300">{data.slug}</p>
</div>
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Description</p>
<p className="text-sm text-mineshaft-300">
{data.description?.length ? data.description : "-"}
</p>
</div>
</div>
</div>
) : (
<div />
);
};
@@ -0,0 +1,204 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
useCreateProjectRole,
useGetProjectRoleBySlug,
useUpdateProjectRole
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas";
const schema = z
.object({
name: z.string(),
description: z.string(),
slug: slugSchema({ min: 1 })
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["role"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void;
};
export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
const navigate = useNavigate();
const popupData = popUp?.role?.data as {
roleSlug: string;
};
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: role } = useGetProjectRoleBySlug(projectId, popupData?.roleSlug ?? "");
const { mutateAsync: createProjectRole } = useCreateProjectRole();
const { mutateAsync: updateProjectRole } = useUpdateProjectRole();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: "",
description: ""
}
});
useEffect(() => {
if (role) {
reset({
name: role.name,
description: role.description,
slug: role.slug
});
} else {
reset({
name: "",
description: "",
slug: ""
});
}
}, [role]);
const onFormSubmit = async ({ name, description, slug }: FormData) => {
try {
if (!projectId) return;
if (role) {
// update
await updateProjectRole({
id: role.id,
projectId,
name,
description,
slug
});
handlePopUpToggle("role", false);
} else {
// create
const newRole = await createProjectRole({
projectId,
name,
description,
slug,
permissions: []
});
navigate({
to: `/${currentWorkspace.type}/$projectId/roles/$roleSlug` as const,
params: {
roleSlug: newRole.slug,
projectId
}
});
handlePopUpToggle("role", false);
}
createNotification({
text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`,
type: "success"
});
reset();
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ??
`Failed to ${popUp?.role?.data ? "update" : "create"} role`;
createNotification({
text,
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.role?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("role", isOpen);
reset();
}}
>
<ModalContent title={`${popUp?.role?.data ? "Update" : "Create"} Role`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Name"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="Billing Team" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="slug"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Slug"
isError={Boolean(error)}
errorText={error?.message}
isRequired
>
<Input {...field} placeholder="billing" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="description"
render={({ field, fieldState: { error } }) => (
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="To manage billing" />
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.role?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("role", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,208 @@
import { FormProvider, useForm } from "react-hook-form";
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
import { PermissionEmptyState } from "./PermissionEmptyState";
import {
formRolePermission2API,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form,
TFormSchema
} from "./ProjectRoleModifySection.utils";
import { SecretPermissionConditions } from "./SecretPermissionConditions";
type Props = {
roleSlug: string;
isDisabled?: boolean;
};
export const renderConditionalComponents = (
subject: ProjectPermissionSub,
isDisabled?: boolean
) => {
if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />;
if (isConditionalSubjects(subject)) {
if (subject === ProjectPermissionSub.Identity) {
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
}
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
return undefined;
};
export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: role, isPending } = useGetProjectRoleBySlug(
currentWorkspace?.id ?? "",
roleSlug as string
);
const form = useForm<TFormSchema>({
values: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : undefined,
resolver: zodResolver(projectRoleFormSchema)
});
const {
handleSubmit,
formState: { isDirty, isSubmitting },
reset
} = form;
const { mutateAsync: updateRole } = useUpdateProjectRole();
const onSubmit = async (el: TFormSchema) => {
try {
if (!projectId || !role?.id) return;
await updateRole({
id: role?.id as string,
projectId,
...el,
permissions: formRolePermission2API(el.permissions)
});
createNotification({ type: "success", text: "Successfully updated role" });
} catch (err) {
console.log(err);
createNotification({ type: "error", text: "Failed to update role" });
}
};
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? "");
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[...rootPolicyValue, ...[]],
{ shouldDirty: true, shouldTouch: true }
);
} else {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[{}],
{
shouldDirty: true,
shouldTouch: true
}
);
}
};
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-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<div className="flex items-center space-x-4">
{isCustomRole && (
<>
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
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}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[
a as keyof typeof PROJECT_PERMISSION_OBJECT
].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((subject) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</div>
<div className="py-4">
{!isPending && <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>
);
};
@@ -0,0 +1,176 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.secrets.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(`permissions.secrets.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.secrets.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.secrets.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
<SelectItem value="secretName">Secret Name</SelectItem>
<SelectItem value="secretTags">Secret Tags</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.secrets.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.secrets.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.secrets?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.secrets?.[position]?.conditions?.message}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/cert-manager/$projectId/_cert-manager-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/kms/$projectId/_kms-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/secret-manager/$projectId/_secret-manager-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage
});
@@ -0,0 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/ssh/$projectId/_ssh-layout/roles/$roleSlug"
)({
component: RoleDetailsBySlugPage
});