mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 06:29:04 +00:00
feat: changed to virtual route for secret-manager
This commit is contained in:
@@ -1,11 +1,31 @@
|
|||||||
import { FunctionComponent, ReactNode } from "react";
|
import { FunctionComponent, ReactNode } from "react";
|
||||||
import { AbilityTuple, MongoAbility } from "@casl/ability";
|
import { AbilityTuple, MongoAbility } from "@casl/ability";
|
||||||
import { Can } from "@casl/react";
|
import { Can } from "@casl/react";
|
||||||
|
import { faLock } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext";
|
import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext";
|
||||||
|
|
||||||
import { Tooltip } from "../v2/Tooltip";
|
import { Tooltip } from "../v2/Tooltip";
|
||||||
|
|
||||||
|
export const ProjectPermissionGuardBanner = () => {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto flex h-full items-center justify-center">
|
||||||
|
<div className="flex items-end space-x-12 rounded-md bg-mineshaft-800 p-16 text-bunker-300">
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon icon={faLock} size="6x" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-4xl font-medium">Access Restricted</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
Your role has limited permissions, please <br /> contact your admin to gain access
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
type Props<T extends AbilityTuple> = {
|
type Props<T extends AbilityTuple> = {
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
// this prop is used when there exist already a tooltip as helper text for users
|
// this prop is used when there exist already a tooltip as helper text for users
|
||||||
@@ -17,6 +37,7 @@ type Props<T extends AbilityTuple> = {
|
|||||||
I: T[0];
|
I: T[0];
|
||||||
a: T[1];
|
a: T[1];
|
||||||
ability?: MongoAbility<T>;
|
ability?: MongoAbility<T>;
|
||||||
|
renderGuardBanner?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>> = ({
|
export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>> = ({
|
||||||
@@ -25,6 +46,7 @@ export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>
|
|||||||
passThrough = true,
|
passThrough = true,
|
||||||
renderTooltip,
|
renderTooltip,
|
||||||
allowedLabel,
|
allowedLabel,
|
||||||
|
renderGuardBanner,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
@@ -39,10 +61,14 @@ export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>
|
|||||||
return <Tooltip content={label}>{finalChild}</Tooltip>;
|
return <Tooltip content={label}>{finalChild}</Tooltip>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAllowed && renderTooltip) {
|
if (isAllowed && renderTooltip && allowedLabel) {
|
||||||
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
|
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isAllowed && renderGuardBanner) {
|
||||||
|
return <ProjectPermissionGuardBanner />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAllowed) return null;
|
if (!isAllowed) return null;
|
||||||
|
|
||||||
return finalChild;
|
return finalChild;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { RolePermissionsSection } from "./RolePermissionsSection";
|
|
||||||
@@ -31,5 +31,13 @@ export const ROUTE_PATHS = Object.freeze({
|
|||||||
OrgRoleByIDPage: setRoute(
|
OrgRoleByIDPage: setRoute(
|
||||||
"/organization/roles/$roleId",
|
"/organization/roles/$roleId",
|
||||||
"/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/"
|
"/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/"
|
||||||
|
),
|
||||||
|
ProductAccessControlPage: setRoute(
|
||||||
|
"/secret-manager/$projectId/access",
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/"
|
||||||
|
),
|
||||||
|
SecretDashboardPage: setRoute(
|
||||||
|
"/secret-manager/$projectId/secrets/$envSlug",
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/secrets/$envSlug/"
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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` 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
+168
@@ -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/members" 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+453
@@ -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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+106
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+202
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { GroupsSection } from "./GroupsSection";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { GroupsSection } from "./GroupsSection";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GroupsTab } from "./GroupsTab";
|
||||||
+443
@@ -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="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||||
|
Documentation{" "}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowUpRightFromSquare}
|
||||||
|
className="mb-[0.06rem] ml-1 text-xs"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</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 }
|
||||||
|
);
|
||||||
+207
@@ -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/members" 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { IdentityTab } from "./IdentityTab";
|
||||||
+17
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+222
@@ -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/members" as const}>
|
||||||
|
<Button variant="outline_bg">Add users to organization</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+351
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+38
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+572
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { MemberRoleForm } from "./MemberRoleForm";
|
||||||
+91
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+375
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { MembersSection } from "./MembersSection";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { MembersTab } from "./MembersTab";
|
||||||
+23
@@ -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 }
|
||||||
|
);
|
||||||
+184
@@ -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/routes/_authenticate/_ctx-org-details/organization/_layout-org/roles/$roleId/-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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectRoleList } from "./ProjectRoleList";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectRoleListTab } from "./ProjectRoleListTab";
|
||||||
+49
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
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 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// TODO(rbr): test this form
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+88
@@ -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 }
|
||||||
|
);
|
||||||
+108
@@ -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, isLoading } = 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>
|
||||||
|
{isLoading && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
|
||||||
|
{!isLoading &&
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
{!isLoading && 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ServiceTokenSection } from "./ServiceTokenSection";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { ServiceTokenSection } from "./ServiceTokenSection";
|
||||||
+1
@@ -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/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/access/"
|
||||||
|
)({
|
||||||
|
component: AccessControlPage,
|
||||||
|
validateSearch: zodValidator(AccessControlPageQuerySchema)
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Helmet } from "react-helmet";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
|
|
||||||
|
import { IPAllowlistSection } from "./components";
|
||||||
|
|
||||||
|
export const IPAllowListPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
|
||||||
|
<link rel="icon" href="/infisical.ico" />
|
||||||
|
</Helmet>
|
||||||
|
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||||
|
<div className="w-full max-w-7xl px-6">
|
||||||
|
<div className="my-6">
|
||||||
|
<p className="text-3xl font-semibold text-gray-200">IP Allowlist</p>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
passThrough={false}
|
||||||
|
renderGuardBanner
|
||||||
|
I={ProjectPermissionActions.Read}
|
||||||
|
a={ProjectPermissionSub.IpAllowList}
|
||||||
|
>
|
||||||
|
<IPAllowlistSection />
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
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 { useAddTrustedIp, useGetMyIp, useUpdateTrustedIp } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const schema = z
|
||||||
|
.object({
|
||||||
|
ipAddress: z.string(),
|
||||||
|
comment: z.string()
|
||||||
|
})
|
||||||
|
.required();
|
||||||
|
|
||||||
|
export type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["trustedIp"]>;
|
||||||
|
handlePopUpClose: (popUpName: keyof UsePopUpState<["trustedIp"]>) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["trustedIp"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IPAllowlistModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||||
|
const { data, isLoading } = useGetMyIp();
|
||||||
|
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const addTrustedIp = useAddTrustedIp();
|
||||||
|
const updateTrustedIp = useUpdateTrustedIp();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting }
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trustedIpData = popUp?.trustedIp?.data as {
|
||||||
|
ipAddress: string;
|
||||||
|
comment: string;
|
||||||
|
prefix: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (popUp?.trustedIp?.data) {
|
||||||
|
reset({
|
||||||
|
ipAddress: `${trustedIpData.ipAddress}${
|
||||||
|
trustedIpData.prefix !== undefined ? `/${trustedIpData.prefix}` : ""
|
||||||
|
}`,
|
||||||
|
comment: trustedIpData.comment
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
ipAddress: "",
|
||||||
|
comment: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [popUp?.trustedIp?.data]);
|
||||||
|
|
||||||
|
const onIPAllowlistModalSubmit = async ({ ipAddress, comment }: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?.id) return;
|
||||||
|
|
||||||
|
if (popUp?.trustedIp?.data) {
|
||||||
|
await updateTrustedIp.mutateAsync({
|
||||||
|
workspaceId: currentWorkspace.id,
|
||||||
|
trustedIpId: (popUp?.trustedIp?.data as { trustedIpId: string })?.trustedIpId,
|
||||||
|
ipAddress,
|
||||||
|
comment,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await addTrustedIp.mutateAsync({
|
||||||
|
workspaceId: currentWorkspace.id,
|
||||||
|
ipAddress,
|
||||||
|
comment,
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `Successfully ${popUp?.trustedIp?.data ? "updated" : "added"} trusted IP`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
handlePopUpClose("trustedIp");
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: `Failed to ${popUp?.trustedIp?.data ? "update" : "add"} trusted IP`,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.trustedIp?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("trustedIp", isOpen);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title={popUp?.trustedIp?.data ? "Update IP" : "Add IP"}>
|
||||||
|
<form onSubmit={handleSubmit(onIPAllowlistModalSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="ipAddress"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="IPv4/IPv6 Address / CIDR Notation"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="123.456.789.0" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isLoading && data && (
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue("ipAddress", data)}
|
||||||
|
className="mb-8"
|
||||||
|
>
|
||||||
|
Add current IP address
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="comment"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Comment" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} placeholder="My IP address" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{popUp?.trustedIp?.data ? "Update" : "Add"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
variant="plain"
|
||||||
|
onClick={() => handlePopUpClose("trustedIp")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
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 { useDeleteTrustedIp } from "@app/hooks/api";
|
||||||
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
import { IPAllowlistModal } from "./IPAllowlistModal";
|
||||||
|
import { IPAllowlistTable } from "./IPAllowlistTable";
|
||||||
|
|
||||||
|
export const IPAllowlistSection = () => {
|
||||||
|
const { mutateAsync } = useDeleteTrustedIp();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
|
"trustedIp",
|
||||||
|
"deleteTrustedIp",
|
||||||
|
"upgradePlan"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const onDeleteTrustedIpSubmit = async (trustedIpId: string) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?.id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
workspaceId: currentWorkspace.id,
|
||||||
|
trustedIpId
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully deleted IP access range",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("deleteTrustedIp");
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to delete IP access range",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-8 flex items-center">
|
||||||
|
<h2 className="flex-1 text-xl font-semibold text-white">IP Allowlist</h2>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Create}
|
||||||
|
a={ProjectPermissionSub.IpAllowList}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription?.ipAllowlisting) {
|
||||||
|
handlePopUpOpen("trustedIp");
|
||||||
|
} else {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
colorSchema="secondary"
|
||||||
|
isLoading={false}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
>
|
||||||
|
Add IP
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
<IPAllowlistTable
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<IPAllowlistModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteTrustedIp.isOpen}
|
||||||
|
title={`Are you sure want to delete ${
|
||||||
|
(popUp?.deleteTrustedIp?.data as { name: string })?.name || " "
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteTrustedIp", isOpen)}
|
||||||
|
deleteKey="confirm"
|
||||||
|
onDeleteApproved={() =>
|
||||||
|
onDeleteTrustedIpSubmit(
|
||||||
|
(popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useSubscription,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { useGetTrustedIps } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["upgradePlan"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>,
|
||||||
|
data?: {
|
||||||
|
trustedIpId: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
comment?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
prefix?: number;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { data, isLoading } = useGetTrustedIps(currentWorkspace?.id ?? "");
|
||||||
|
|
||||||
|
const formatType = (type: string, prefix?: number) => {
|
||||||
|
return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${
|
||||||
|
prefix !== undefined ? "CIDR" : ""
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TableContainer className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="flex-1">IP Address / Range</Th>
|
||||||
|
<Th className="flex-1">Format</Th>
|
||||||
|
<Th className="flex-1">Comment</Th>
|
||||||
|
{/* <Th className="flex-1">Status</Th> */}
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{!isLoading &&
|
||||||
|
data &&
|
||||||
|
data?.length > 0 &&
|
||||||
|
data
|
||||||
|
.sort((a, b) => a.ipAddress.localeCompare(b.ipAddress))
|
||||||
|
.map(({ id, ipAddress, comment, type, prefix, isActive }) => {
|
||||||
|
return (
|
||||||
|
<Tr key={`ip-access-range-${id}`} className="h-10">
|
||||||
|
<Td>{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}</Td>
|
||||||
|
<Td>{formatType(type, prefix)}</Td>
|
||||||
|
<Td>{comment}</Td>
|
||||||
|
{/* <Td>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faCircle}
|
||||||
|
color="#2ecc71"
|
||||||
|
/>
|
||||||
|
<p className="ml-4">Active</p>
|
||||||
|
</div>
|
||||||
|
</Td> */}
|
||||||
|
<Td className="flex items-center">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={ProjectPermissionSub.IpAllowList}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
className="mr-3 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription?.ipAllowlisting) {
|
||||||
|
handlePopUpOpen("trustedIp", {
|
||||||
|
trustedIpId: id,
|
||||||
|
ipAddress,
|
||||||
|
comment,
|
||||||
|
prefix,
|
||||||
|
isActive
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPencil} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.IpAllowList}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
if (subscription?.ipAllowlisting) {
|
||||||
|
handlePopUpOpen("deleteTrustedIp", {
|
||||||
|
trustedIpId: id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="lg"
|
||||||
|
colorSchema="danger"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{isLoading && (
|
||||||
|
<TableSkeleton innerKey="ip-access-table" columns={4} key="ip-access-ranges" />
|
||||||
|
)}
|
||||||
|
{!isLoading && data && data?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5}>
|
||||||
|
<EmptyState title="No IP addresses added" icon={faGlobe} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { IPAllowlistSection } from "./IPAllowlistSection";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { IPAllowListPage } from "./IPAllowListPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/allowlist/"
|
||||||
|
)({
|
||||||
|
component: () => IPAllowListPage
|
||||||
|
});
|
||||||
+198
@@ -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` 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` 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+435
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+255
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { IdentityProjectAdditionalPrivilegeSection } from "./IdentityProjectAdditionalPrivilegeSection";
|
||||||
+238
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+332
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { IdentityRoleDetailsSection } from "./IdentityRoleDetailsSection";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { IdentityDetailsByIDPage } from "./IdentityDetailsByIDPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/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` 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` 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+259
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+429
@@ -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 { GeneralPermissionPolicies } from "@app/components/permissions/ProjectRolePermissionsSection/components/GeneralPermissionPolicies";
|
||||||
|
import { PermissionEmptyState } from "@app/components/permissions/ProjectRolePermissionsSection/PermissionEmptyState";
|
||||||
|
import {
|
||||||
|
formRolePermission2API,
|
||||||
|
isConditionalSubjects,
|
||||||
|
PROJECT_PERMISSION_OBJECT,
|
||||||
|
projectRoleFormSchema,
|
||||||
|
rolePermission2Form
|
||||||
|
} from "@app/components/permissions/ProjectRolePermissionsSection/ProjectRoleModifySection.utils";
|
||||||
|
import { renderConditionalComponents } from "@app/components/permissions/ProjectRolePermissionsSection/RolePermissionsSection";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { MemberProjectAdditionalPrivilegeSection } from "./MemberProjectAdditionalPrivilegeSection";
|
||||||
+249
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+346
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -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/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/members/$membershipId/"
|
||||||
|
)({
|
||||||
|
component: MemberDetailsByIDPage
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
+318
@@ -0,0 +1,318 @@
|
|||||||
|
import { ClipboardEvent, useRef } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||||
|
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||||
|
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||||
|
import { SecretType } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
const typeSchema = z
|
||||||
|
.object({
|
||||||
|
key: z.string().trim().min(1, "Key is required"),
|
||||||
|
value: z.string().optional(),
|
||||||
|
environments: z.object({ name: z.string(), slug: z.string() }).array(),
|
||||||
|
tags: z.array(z.object({ label: z.string().trim(), value: z.string().trim() })).optional()
|
||||||
|
})
|
||||||
|
.refine((data) => data.key !== undefined, {
|
||||||
|
message: "Please enter secret name"
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof typeSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretPath?: string;
|
||||||
|
// modal props
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
formState: { isSubmitting, errors }
|
||||||
|
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||||
|
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const environments = currentWorkspace?.environments || [];
|
||||||
|
|
||||||
|
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||||
|
const { mutateAsync: createFolder } = useCreateFolder();
|
||||||
|
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
|
||||||
|
canReadTags ? workspaceId : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const secretKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { ref: setSecretKeyHookRef, ...secretKeyRegisterRest } = register("key");
|
||||||
|
|
||||||
|
const secretKey = watch("key");
|
||||||
|
|
||||||
|
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
|
||||||
|
const promises = selectedEnv.map(async (env) => {
|
||||||
|
const environment = env.slug;
|
||||||
|
// create folder if not existing
|
||||||
|
if (secretPath !== "/") {
|
||||||
|
// /hello/world -> [hello","world"]
|
||||||
|
const pathSegment = secretPath.split("/").filter(Boolean);
|
||||||
|
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
|
||||||
|
const folderName = pathSegment.at(-1);
|
||||||
|
const canCreateFolder = permission.can(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.SecretFolders, {
|
||||||
|
environment: env.slug,
|
||||||
|
secretPath: parentPath
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (folderName && parentPath && canCreateFolder) {
|
||||||
|
await createFolder({
|
||||||
|
projectId: workspaceId,
|
||||||
|
path: parentPath,
|
||||||
|
environment,
|
||||||
|
name: folderName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add back ability to overwrite - need to fetch secrets by key to check for conflicts as previous method broke with pagination
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(await createSecretV3({
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
secretPath,
|
||||||
|
secretKey: key,
|
||||||
|
secretValue: value || "",
|
||||||
|
secretComment: "",
|
||||||
|
type: SecretType.Shared,
|
||||||
|
tagIds: tags?.map((el) => el.value)
|
||||||
|
})),
|
||||||
|
environment
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
const forApprovalEnvs = results
|
||||||
|
.map((result) =>
|
||||||
|
result.status === "fulfilled" && "approval" in result.value
|
||||||
|
? result.value.environment
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
const updatedEnvs = results
|
||||||
|
.map((result) =>
|
||||||
|
result.status === "fulfilled" && !("approval" in result.value)
|
||||||
|
? result.value.environment
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
if (forApprovalEnvs.length) {
|
||||||
|
createNotification({
|
||||||
|
type: "info",
|
||||||
|
text: `Change request submitted for ${
|
||||||
|
forApprovalEnvs.length > 1 ? "environments" : "environment"
|
||||||
|
}: ${forApprovalEnvs.join(", ")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedEnvs.length) {
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: `Secrets created in ${
|
||||||
|
updatedEnvs.length > 1 ? "environments" : "environment"
|
||||||
|
}: ${updatedEnvs.join(", ")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updatedEnvs.length && !forApprovalEnvs.length) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create secrets"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
const delimitters = [":", "="];
|
||||||
|
const pastedContent = e.clipboardData.getData("text");
|
||||||
|
const { key, value } = getKeyValue(pastedContent, delimitters);
|
||||||
|
|
||||||
|
const isWholeKeyHighlighted =
|
||||||
|
secretKeyInputRef.current &&
|
||||||
|
secretKeyInputRef.current.selectionStart === 0 &&
|
||||||
|
secretKeyInputRef.current.selectionEnd === secretKeyInputRef.current.value.length;
|
||||||
|
|
||||||
|
if (!secretKey || isWholeKeyHighlighted) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
setValue("key", key);
|
||||||
|
if (value) {
|
||||||
|
setValue("value", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createWsTag = useCreateWsTag();
|
||||||
|
const slugSchema = z.string().trim().toLowerCase().min(1);
|
||||||
|
const createNewTag = async (slug: string) => {
|
||||||
|
// TODO: Replace with slugSchema generic
|
||||||
|
try {
|
||||||
|
const parsedSlug = slugSchema.parse(slug);
|
||||||
|
await createWsTag.mutateAsync({
|
||||||
|
workspaceID: workspaceId,
|
||||||
|
tagSlug: parsedSlug,
|
||||||
|
tagColor: ""
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to create new tag"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
|
||||||
|
<FormControl
|
||||||
|
label="Key"
|
||||||
|
isRequired
|
||||||
|
isError={Boolean(errors?.key)}
|
||||||
|
errorText={errors?.key?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...secretKeyRegisterRest}
|
||||||
|
ref={(e) => {
|
||||||
|
setSecretKeyHookRef(e);
|
||||||
|
// @ts-expect-error this is for multiple ref single component
|
||||||
|
secretKeyInputRef.current = e;
|
||||||
|
}}
|
||||||
|
placeholder="Type your secret name"
|
||||||
|
onPaste={handlePaste}
|
||||||
|
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Value"
|
||||||
|
isError={Boolean(errors?.value)}
|
||||||
|
errorText={errors?.value?.message}
|
||||||
|
>
|
||||||
|
<InfisicalSecretInput
|
||||||
|
{...field}
|
||||||
|
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="tags"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Tags"
|
||||||
|
isError={Boolean(errors?.value)}
|
||||||
|
errorText={errors?.value?.message}
|
||||||
|
helperText={
|
||||||
|
!canReadTags ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="text-yellow-400" />
|
||||||
|
<span>You do not have permission to read tags.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CreatableSelect
|
||||||
|
isMulti
|
||||||
|
className="w-full"
|
||||||
|
placeholder="Select tags to assign to secret..."
|
||||||
|
isValidNewOption={(v) => slugSchema.safeParse(v).success}
|
||||||
|
name="tagIds"
|
||||||
|
isDisabled={!canReadTags}
|
||||||
|
isLoading={isTagsLoading && canReadTags}
|
||||||
|
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onCreateOption={createNewTag}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Environments" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<FilterableSelect
|
||||||
|
isMulti
|
||||||
|
options={environments.filter((environment) =>
|
||||||
|
permission.can(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment: environment.slug,
|
||||||
|
secretPath,
|
||||||
|
secretName: "*",
|
||||||
|
secretTags: ["*"]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Select environments to create secret in..."
|
||||||
|
getOptionLabel={(option) => option.name}
|
||||||
|
getOptionValue={(option) => option.slug}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
name="environments"
|
||||||
|
/>
|
||||||
|
<div className="mt-7 flex items-center">
|
||||||
|
<Button
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
key="layout-create-project-submit"
|
||||||
|
className="mr-4"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Create Secret
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
key="layout-cancel-create-project"
|
||||||
|
onClick={onClose}
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { CreateSecretForm } from "./CreateSecretForm";
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
import { faFolderOpen } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretPath: string;
|
||||||
|
onResetSearch: (path: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
|
||||||
|
const navigate = useNavigate({
|
||||||
|
from: "/secret-manager/$projectId/overview"
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFolderCrumbClick = (index: number) => {
|
||||||
|
const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
|
||||||
|
if (secretPath === newSecPath) return;
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({ ...prev, secretPath: newSecPath })
|
||||||
|
}).then(() => onResetSearch(newSecPath));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 py-1 pl-5 pr-2 text-sm hover:bg-mineshaft-600"
|
||||||
|
onClick={() => onFolderCrumbClick(0)}
|
||||||
|
onKeyDown={() => null}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFolderOpen} className="text-primary-700" />
|
||||||
|
</div>
|
||||||
|
{(secretPath || "")
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((path, index, arr) => (
|
||||||
|
<div
|
||||||
|
key={`secret-path-${index + 1}`}
|
||||||
|
className={`breadcrumb relative z-20 ${
|
||||||
|
index + 1 === arr.length ? "cursor-default" : "cursor-pointer"
|
||||||
|
} border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
|
||||||
|
onClick={() => onFolderCrumbClick(index + 1)}
|
||||||
|
onKeyDown={() => null}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { FolderBreadCrumbs } from "./FolderBreadCrumbs";
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { faCheck, faFingerprint, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { Td, Tr } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dynamicSecretName: string;
|
||||||
|
environments: { name: string; slug: string }[];
|
||||||
|
isDynamicSecretInEnv: (name: string, env: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretOverviewDynamicSecretRow = ({
|
||||||
|
dynamicSecretName,
|
||||||
|
environments = [],
|
||||||
|
isDynamicSecretInEnv
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<Tr isHoverable isSelectable className="group">
|
||||||
|
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||||
|
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
|
<div className="text-yellow-700">
|
||||||
|
<FontAwesomeIcon icon={faFingerprint} />
|
||||||
|
</div>
|
||||||
|
<div>{dynamicSecretName}</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
{environments.map(({ slug }, i) => {
|
||||||
|
const isPresent = isDynamicSecretInEnv(dynamicSecretName, slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Td
|
||||||
|
key={`sec-overview-${slug}-${i + 1}-folder`}
|
||||||
|
className={twMerge(
|
||||||
|
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
|
||||||
|
isPresent ? "text-green-600" : "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
icon={isPresent ? faCheck : faXmark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretOverviewDynamicSecretRow } from "./SecretOverviewDynamicSecretRow";
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { Checkbox, IconButton, Td, Tr } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
folderName: string;
|
||||||
|
environments: { name: string; slug: string }[];
|
||||||
|
isFolderPresentInEnv: (name: string, env: string) => boolean;
|
||||||
|
onClick: (path: string) => void;
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggleFolderSelect: (folderName: string) => void;
|
||||||
|
onToggleFolderEdit: (name: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretOverviewFolderRow = ({
|
||||||
|
folderName,
|
||||||
|
environments = [],
|
||||||
|
isFolderPresentInEnv,
|
||||||
|
isSelected,
|
||||||
|
onToggleFolderSelect,
|
||||||
|
onToggleFolderEdit,
|
||||||
|
onClick
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<Tr isHoverable isSelectable className="group" onClick={() => onClick(folderName)}>
|
||||||
|
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||||
|
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
|
<div className="text-yellow-700">
|
||||||
|
<Checkbox
|
||||||
|
id={`checkbox-${folderName}`}
|
||||||
|
isChecked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleFolderSelect(folderName);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||||
|
icon={faFolder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>{folderName}</div>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="edit-folder"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
onToggleFolderEdit(folderName);
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPencil} size="sm" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
{environments.map(({ slug }, i) => {
|
||||||
|
const isPresent = isFolderPresentInEnv(folderName, slug);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Td
|
||||||
|
key={`sec-overview-${slug}-${i + 1}-folder`}
|
||||||
|
className={twMerge(
|
||||||
|
"border-r border-mineshaft-600 py-3 group-hover:bg-mineshaft-700",
|
||||||
|
isPresent ? "text-green-600" : "text-red-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
icon={isPresent ? faCheck : faXmark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretOverviewFolderRow } from "./SecretOverviewFolderRow";
|
||||||
+295
@@ -0,0 +1,295 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faCopy,
|
||||||
|
faProjectDiagram,
|
||||||
|
faTrash,
|
||||||
|
faXmark
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import {
|
||||||
|
hasSecretReference,
|
||||||
|
SecretReferenceTree
|
||||||
|
} from "@app/components/secrets/SecretReferenceDetails";
|
||||||
|
import {
|
||||||
|
DeleteActionModal,
|
||||||
|
IconButton,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalTrigger,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||||
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import { SecretType } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultValue?: string | null;
|
||||||
|
secretName: string;
|
||||||
|
secretId?: string;
|
||||||
|
isOverride?: boolean;
|
||||||
|
isCreatable?: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
|
isImportedSecret: boolean;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
||||||
|
onSecretUpdate: (
|
||||||
|
env: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
type?: SecretType,
|
||||||
|
secretId?: string
|
||||||
|
) => Promise<void>;
|
||||||
|
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretEditRow = ({
|
||||||
|
defaultValue,
|
||||||
|
isCreatable,
|
||||||
|
isOverride,
|
||||||
|
isImportedSecret,
|
||||||
|
onSecretUpdate,
|
||||||
|
secretName,
|
||||||
|
onSecretCreate,
|
||||||
|
onSecretDelete,
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
isVisible,
|
||||||
|
secretId
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
getValues,
|
||||||
|
formState: { isDirty, isSubmitting }
|
||||||
|
} = useForm({
|
||||||
|
values: {
|
||||||
|
value: defaultValue || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [isDeleting, setIsDeleting] = useToggle();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const toggleModal = useCallback(() => {
|
||||||
|
setIsModalOpen((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFormReset = () => {
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopySecretToClipboard = async () => {
|
||||||
|
const { value } = getValues();
|
||||||
|
if (value) {
|
||||||
|
try {
|
||||||
|
await window.navigator.clipboard.writeText(value);
|
||||||
|
createNotification({ type: "success", text: "Copied secret to clipboard" });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
createNotification({ type: "error", text: "Failed to copy secret to clipboard" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = async ({ value }: { value?: string | null }) => {
|
||||||
|
if ((value || value === "") && secretName) {
|
||||||
|
if (isCreatable) {
|
||||||
|
await onSecretCreate(environment, secretName, value);
|
||||||
|
} else {
|
||||||
|
await onSecretUpdate(
|
||||||
|
environment,
|
||||||
|
secretName,
|
||||||
|
value,
|
||||||
|
isOverride ? SecretType.Personal : SecretType.Shared,
|
||||||
|
secretId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reset({ value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSecret = useCallback(async () => {
|
||||||
|
setIsDeleting.on();
|
||||||
|
setIsModalOpen(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSecretDelete(environment, secretName, secretId);
|
||||||
|
reset({ value: null });
|
||||||
|
} finally {
|
||||||
|
setIsDeleting.off();
|
||||||
|
}
|
||||||
|
}, [onSecretDelete, environment, secretName, secretId, reset, setIsDeleting]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex w-full cursor-text items-center space-x-2">
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={toggleModal}
|
||||||
|
title="Do you want to delete the selected secret?"
|
||||||
|
deleteKey="delete"
|
||||||
|
onDeleteApproved={handleDeleteSecret}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-grow border-r border-r-mineshaft-600 pl-1 pr-2">
|
||||||
|
<Controller
|
||||||
|
disabled={isImportedSecret && !defaultValue}
|
||||||
|
control={control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<InfisicalSecretInput
|
||||||
|
{...field}
|
||||||
|
isReadOnly={isImportedSecret}
|
||||||
|
value={field.value as string}
|
||||||
|
key="secret-input"
|
||||||
|
isVisible={isVisible}
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
isImport={isImportedSecret}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"flex w-24 justify-center space-x-3 pl-2 transition-all",
|
||||||
|
isImportedSecret && "pointer-events-none opacity-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDirty ? (
|
||||||
|
<>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={isCreatable ? ProjectPermissionActions.Create : ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: ["*"]
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<div>
|
||||||
|
<Tooltip content="save">
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="submit-value"
|
||||||
|
className="h-full"
|
||||||
|
isDisabled={isSubmitting || !isAllowed}
|
||||||
|
onClick={handleSubmit(handleFormSubmit)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<div>
|
||||||
|
<Tooltip content="cancel">
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="reset-value"
|
||||||
|
className="h-full"
|
||||||
|
onClick={handleFormReset}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="hover:text-red" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip content="Copy Secret">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy-value"
|
||||||
|
onClick={handleCopySecretToClipboard}
|
||||||
|
variant="plain"
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Read}
|
||||||
|
a={ProjectPermissionSub.Secrets}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Modal>
|
||||||
|
<ModalTrigger asChild>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
hasSecretReference(defaultValue || "")
|
||||||
|
? "Secret Reference Tree"
|
||||||
|
: "Secret does not contain references"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="reference-tree"
|
||||||
|
className="h-full"
|
||||||
|
isDisabled={!hasSecretReference(defaultValue || "") || !isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faProjectDiagram} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</ModalTrigger>
|
||||||
|
<ModalContent
|
||||||
|
title="Secret Reference Details"
|
||||||
|
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
|
||||||
|
>
|
||||||
|
<SecretReferenceTree
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
secretKey={secretName}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: ["*"]
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip content="Delete">
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="delete-value"
|
||||||
|
className="h-full"
|
||||||
|
onClick={toggleModal}
|
||||||
|
isDisabled={isDeleting || !isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { faLock } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
environments: { name: string; slug: string }[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Props) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from(Array(count)).map((_, j) => (
|
||||||
|
<Tr key={`no-access-secret-overview-${j + 1}`} isHoverable isSelectable className="group">
|
||||||
|
<Td className="sticky left-0 z-10 bg-mineshaft-800 bg-clip-padding px-0 py-0 group-hover:bg-mineshaft-700">
|
||||||
|
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
|
<Tooltip
|
||||||
|
asChild
|
||||||
|
content="You do not have permission to view this secret"
|
||||||
|
className="max-w-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="text-bunker-300">
|
||||||
|
<FontAwesomeIcon className="block" icon={faLock} />
|
||||||
|
</div>
|
||||||
|
<div className="blur-sm">NO ACCESS</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
{environments.map(({ slug }, i) => {
|
||||||
|
return (
|
||||||
|
<Td
|
||||||
|
key={`sec-overview-${slug}-${i + 1}-value`}
|
||||||
|
className="px-0 py-0 group-hover:bg-mineshaft-700"
|
||||||
|
>
|
||||||
|
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-[0.85rem]">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<FontAwesomeIcon icon={faCircle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
import { faCircle } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import {
|
||||||
|
faAngleDown,
|
||||||
|
faCheck,
|
||||||
|
faEye,
|
||||||
|
faEyeSlash,
|
||||||
|
faFileImport,
|
||||||
|
faKey,
|
||||||
|
faXmark
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||||
|
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
import { SecretEditRow } from "./SecretEditRow";
|
||||||
|
import SecretRenameRow from "./SecretRenameRow";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretKey: string;
|
||||||
|
secretPath: string;
|
||||||
|
environments: { name: string; slug: string }[];
|
||||||
|
isSelected: boolean;
|
||||||
|
onToggleSecretSelect: (key: string) => void;
|
||||||
|
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||||
|
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
|
||||||
|
onSecretUpdate: (
|
||||||
|
env: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
type?: SecretType,
|
||||||
|
secretId?: string
|
||||||
|
) => Promise<void>;
|
||||||
|
onSecretDelete: (env: string, key: string, secretId?: string) => Promise<void>;
|
||||||
|
isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean;
|
||||||
|
getImportedSecretByKey: (
|
||||||
|
env: string,
|
||||||
|
secretName: string
|
||||||
|
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
|
||||||
|
scrollOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretOverviewTableRow = ({
|
||||||
|
secretKey,
|
||||||
|
environments = [],
|
||||||
|
secretPath,
|
||||||
|
getSecretByKey,
|
||||||
|
onSecretUpdate,
|
||||||
|
onSecretCreate,
|
||||||
|
onSecretDelete,
|
||||||
|
isImportedSecretPresentInEnv,
|
||||||
|
getImportedSecretByKey,
|
||||||
|
scrollOffset,
|
||||||
|
onToggleSecretSelect,
|
||||||
|
isSelected
|
||||||
|
}: Props) => {
|
||||||
|
const [isFormExpanded, setIsFormExpanded] = useToggle();
|
||||||
|
const totalCols = environments.length + 1; // secret key row
|
||||||
|
const [isSecretVisible, setIsSecretVisible] = useToggle();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr isHoverable isSelectable onClick={() => setIsFormExpanded.toggle()} className="group">
|
||||||
|
<Td
|
||||||
|
className={`sticky left-0 z-10 bg-mineshaft-800 bg-clip-padding px-0 py-0 group-hover:bg-mineshaft-700 ${
|
||||||
|
isFormExpanded && "border-t-2 border-mineshaft-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
|
<div className="flex items-center space-x-5">
|
||||||
|
<div className="text-bunker-300">
|
||||||
|
<Checkbox
|
||||||
|
id={`checkbox-${secretKey}`}
|
||||||
|
isChecked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
onToggleSecretSelect(secretKey);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
|
||||||
|
/>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
|
||||||
|
icon={isFormExpanded ? faAngleDown : faKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div title={secretKey}>{secretKey}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
{environments.map(({ slug }, i) => {
|
||||||
|
const secret = getSecretByKey(slug, secretKey);
|
||||||
|
|
||||||
|
const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey);
|
||||||
|
|
||||||
|
const isSecretPresent = Boolean(secret);
|
||||||
|
const isSecretEmpty = secret?.value === "";
|
||||||
|
return (
|
||||||
|
<Td
|
||||||
|
key={`sec-overview-${slug}-${i + 1}-value`}
|
||||||
|
className={twMerge(
|
||||||
|
"px-0 py-0 group-hover:bg-mineshaft-700",
|
||||||
|
isFormExpanded && "border-t-2 border-mineshaft-500",
|
||||||
|
(isSecretPresent && !isSecretEmpty) || isSecretImported ? "text-green-600" : "",
|
||||||
|
isSecretPresent && isSecretEmpty && !isSecretImported ? "text-yellow" : "",
|
||||||
|
!isSecretPresent && !isSecretEmpty && !isSecretImported ? "text-red-600" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full border-r border-mineshaft-600 px-5 py-[0.85rem]">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
{!isSecretEmpty && (
|
||||||
|
<Tooltip
|
||||||
|
center
|
||||||
|
content={
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
isSecretPresent
|
||||||
|
? "Present secret"
|
||||||
|
: isSecretImported
|
||||||
|
? "Imported secret"
|
||||||
|
: "Missing secret"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
icon={isSecretPresent ? faCheck : isSecretImported ? faFileImport : faXmark}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{isSecretEmpty && (
|
||||||
|
<Tooltip content="Empty value">
|
||||||
|
<FontAwesomeIcon icon={faCircle} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tr>
|
||||||
|
{isFormExpanded && (
|
||||||
|
<Tr>
|
||||||
|
<Td
|
||||||
|
colSpan={totalCols}
|
||||||
|
className={`bg-bunker-600 px-0 py-0 ${
|
||||||
|
isFormExpanded && "border-b-2 border-mineshaft-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="ml-2 p-2"
|
||||||
|
style={{
|
||||||
|
marginLeft: scrollOffset,
|
||||||
|
width: "calc(100vw - 300px)", // 300px accounts for sidebar and margin
|
||||||
|
maxWidth: "calc(1536px - 50px)" // tw container max width minus padding for uw displays
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SecretRenameRow
|
||||||
|
secretKey={secretKey}
|
||||||
|
environments={environments}
|
||||||
|
secretPath={secretPath}
|
||||||
|
getSecretByKey={getSecretByKey}
|
||||||
|
/>
|
||||||
|
<TableContainer>
|
||||||
|
<table className="secret-table">
|
||||||
|
<thead>
|
||||||
|
<tr className="h-10 border-b-2 border-mineshaft-600">
|
||||||
|
<th
|
||||||
|
style={{ padding: "0.5rem 1rem" }}
|
||||||
|
className="min-table-row min-w-[11rem]"
|
||||||
|
>
|
||||||
|
Environment
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: "0.5rem 1rem" }} className="border-none">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
<div className="absolute right-0 top-0 ml-auto mr-1 mt-1 w-min">
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
className="p-1"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={isSecretVisible ? faEyeSlash : faEye} />}
|
||||||
|
onClick={() => setIsSecretVisible.toggle()}
|
||||||
|
>
|
||||||
|
{isSecretVisible ? "Hide Values" : "Reveal Values"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="border-t-2 border-mineshaft-600">
|
||||||
|
{environments.map(({ name, slug }) => {
|
||||||
|
const secret = getSecretByKey(slug, secretKey);
|
||||||
|
const isCreatable = !secret;
|
||||||
|
|
||||||
|
const isImportedSecret = isImportedSecretPresentInEnv(slug, secretKey);
|
||||||
|
const importedSecret = getImportedSecretByKey(slug, secretKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`secret-expanded-${slug}-${secretKey}`}
|
||||||
|
className="hover:bg-mineshaft-700"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="flex h-full items-center"
|
||||||
|
style={{ padding: "0.25rem 1rem" }}
|
||||||
|
>
|
||||||
|
<div title={name} className="flex h-8 w-[8rem] items-center space-x-2">
|
||||||
|
<span className="truncate">{name}</span>
|
||||||
|
{isImportedSecret && (
|
||||||
|
<Tooltip
|
||||||
|
content={`Imported secret from the '${importedSecret?.environmentInfo?.name}' environment`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFileImport} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="col-span-2 h-8 w-full">
|
||||||
|
<SecretEditRow
|
||||||
|
secretPath={secretPath}
|
||||||
|
isVisible={isSecretVisible}
|
||||||
|
secretName={secretKey}
|
||||||
|
defaultValue={
|
||||||
|
secret?.valueOverride ||
|
||||||
|
secret?.value ||
|
||||||
|
importedSecret?.secret?.value
|
||||||
|
}
|
||||||
|
secretId={secret?.id}
|
||||||
|
isOverride={Boolean(secret?.valueOverride)}
|
||||||
|
isImportedSecret={isImportedSecret}
|
||||||
|
isCreatable={isCreatable}
|
||||||
|
onSecretDelete={onSecretDelete}
|
||||||
|
onSecretCreate={onSecretCreate}
|
||||||
|
onSecretUpdate={onSecretUpdate}
|
||||||
|
environment={slug}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import { faCheck, faClose, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { IconButton, Input, Spinner, Tooltip } from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import { useUpdateSecretV3 } from "@app/hooks/api";
|
||||||
|
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
enum SecretActionType {
|
||||||
|
Created = "created",
|
||||||
|
Modified = "modified",
|
||||||
|
Deleted = "deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretKey: string;
|
||||||
|
secretPath: string;
|
||||||
|
environments: { name: string; slug: string }[];
|
||||||
|
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formSchema = z.object({
|
||||||
|
key: z.string().trim().min(1, { message: "Secret key is required" })
|
||||||
|
});
|
||||||
|
|
||||||
|
type TFormSchema = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }: Props) {
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
const secrets = environments.map((env) => getSecretByKey(env.slug, secretKey));
|
||||||
|
|
||||||
|
const isReadOnly = environments.some((env) => {
|
||||||
|
const environment = env.slug;
|
||||||
|
const secretDetails = getSecretByKey(environment, secretKey);
|
||||||
|
const secretPermissionSubject = subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName: secretKey,
|
||||||
|
secretTags: (secretDetails?.tags || []).map((i) => i.slug)
|
||||||
|
});
|
||||||
|
const isSecretInEnvReadOnly =
|
||||||
|
permission.can(ProjectPermissionActions.Read, secretPermissionSubject) &&
|
||||||
|
permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject);
|
||||||
|
if (isSecretInEnvReadOnly) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOverriden = secrets.some(
|
||||||
|
(secret) =>
|
||||||
|
secret?.overrideAction === SecretActionType.Created ||
|
||||||
|
secret?.overrideAction === SecretActionType.Modified
|
||||||
|
);
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
|
||||||
|
const [isSecNameCopied, setIsSecNameCopied] = useToggle(false);
|
||||||
|
|
||||||
|
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
trigger,
|
||||||
|
getValues,
|
||||||
|
formState: { isDirty, isSubmitting, errors }
|
||||||
|
} = useForm<TFormSchema>({
|
||||||
|
defaultValues: { key: secretKey },
|
||||||
|
values: { key: secretKey },
|
||||||
|
resolver: zodResolver(formSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (isSecNameCopied) {
|
||||||
|
timer = setTimeout(() => setIsSecNameCopied.off(), 2000);
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isSecNameCopied]);
|
||||||
|
|
||||||
|
const handleFormSubmit = async (data: TFormSchema) => {
|
||||||
|
if (!data.key) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Secret name cannot be empty"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = secrets
|
||||||
|
.filter((secret) => !!secret)
|
||||||
|
.map((secret) => {
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
return updateSecretV3({
|
||||||
|
environment: secret?.env,
|
||||||
|
workspaceId,
|
||||||
|
secretPath,
|
||||||
|
secretKey: secret.key,
|
||||||
|
secretValue: secret.value || "",
|
||||||
|
type: SecretType.Shared,
|
||||||
|
tagIds: secret.tags?.map((tag) => tag.id),
|
||||||
|
secretComment: secret.comment,
|
||||||
|
secretReminderRepeatDays: secret.reminderRepeatDays,
|
||||||
|
secretReminderNote: secret.reminderNote,
|
||||||
|
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||||
|
newSecretName: data.key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises)
|
||||||
|
.then(() => {
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully renamed the secret"
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Error renaming the secret"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyTokenToClipboard = () => {
|
||||||
|
const [key] = getValues(["key"]);
|
||||||
|
navigator.clipboard.writeText(key as string);
|
||||||
|
setIsSecNameCopied.on();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(handleFormSubmit)}
|
||||||
|
className="secret-table relative mb-2 flex w-full flex-row items-center justify-between overflow-hidden rounded-lg border border-solid border-mineshaft-700 bg-mineshaft-800 font-inter"
|
||||||
|
>
|
||||||
|
<div className="flex h-11 flex-1 flex-shrink-0 items-center">
|
||||||
|
<span className="flex h-full min-w-[11rem] items-center justify-start border-r-2 border-mineshaft-600 px-4">
|
||||||
|
Key
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
name="key"
|
||||||
|
control={control}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||||
|
variant="plain"
|
||||||
|
isDisabled={isOverriden}
|
||||||
|
placeholder={error?.message}
|
||||||
|
onKeyUp={() => trigger("key")}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
{...field}
|
||||||
|
className="w-full px-2 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReadOnly || isOverriden ? (
|
||||||
|
<span className="mr-5 rounded-md bg-mineshaft-500 px-2">Read Only</span>
|
||||||
|
) : (
|
||||||
|
<div className="group flex w-20 items-center justify-center border-l border-mineshaft-500 py-1">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{!isDirty ? (
|
||||||
|
<motion.div
|
||||||
|
key="options"
|
||||||
|
className="flex flex-shrink-0 items-center space-x-4 px-3"
|
||||||
|
initial={{ x: 0, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 10, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<Tooltip content="Copy secret name">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy-value"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={copyTokenToClipboard}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isSecNameCopied ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="options-save"
|
||||||
|
className="flex flex-shrink-0 items-center space-x-4 px-3"
|
||||||
|
initial={{ x: -10, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: -10, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<Tooltip content={errors.key ? errors.key.message : "Save"}>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="more"
|
||||||
|
variant="plain"
|
||||||
|
type="submit"
|
||||||
|
size="md"
|
||||||
|
className={twMerge(
|
||||||
|
"p-0 text-primary opacity-0 group-hover:opacity-100",
|
||||||
|
isDirty && "opacity-100"
|
||||||
|
)}
|
||||||
|
isDisabled={isSubmitting || Boolean(errors.key)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Spinner className="m-0 h-4 w-4 p-0" />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faCheck}
|
||||||
|
size="lg"
|
||||||
|
className={twMerge("text-primary", errors.key && "text-mineshaft-400")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Cancel">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="more"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className={twMerge(
|
||||||
|
"p-0 opacity-0 group-hover:opacity-100",
|
||||||
|
isDirty && "opacity-100"
|
||||||
|
)}
|
||||||
|
onClick={() => reset()}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SecretRenameRow;
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export { SecretNoAccessOverviewTableRow } from "./SecretNoAccessOverviewTableRow";
|
||||||
|
export { SecretOverviewTableRow } from "./SecretOverviewTableRow";
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Combobox, Transition } from "@headlessui/react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { IconButton, Tooltip } from "@app/components/v2";
|
||||||
|
|
||||||
|
import { QuickSearchModal, QuickSearchModalProps } from "./components";
|
||||||
|
|
||||||
|
type ModalProps = Omit<
|
||||||
|
QuickSearchModalProps,
|
||||||
|
"isOpen" | "onClose" | "onOpenChange" | "initialValue"
|
||||||
|
> & {
|
||||||
|
value: string;
|
||||||
|
onChange: (search: string) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretSearchInput = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
isSingleEnv,
|
||||||
|
...props
|
||||||
|
}: ModalProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const hasSearch = Boolean(value.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={twMerge("relative w-80", className)}>
|
||||||
|
<Combobox
|
||||||
|
// keeps combobox from internally controlling state, hacky use of combobox
|
||||||
|
value={undefined}
|
||||||
|
>
|
||||||
|
{({ activeIndex }) => (
|
||||||
|
<>
|
||||||
|
<div className="flex w-full items-center whitespace-nowrap">
|
||||||
|
<Tooltip content="Search Options">
|
||||||
|
<Combobox.Button className="button user-select-none relative inline-flex h-[2.42rem] cursor-pointer items-center justify-center rounded-md rounded-r-none border border-mineshaft-600 bg-mineshaft-600 p-3 font-inter text-sm font-medium text-bunker-200 transition-all duration-100 hover:border-primary-400/50 hover:bg-primary/[0.1] hover:text-bunker-100">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSearch}
|
||||||
|
size="sm"
|
||||||
|
className={hasSearch ? "text-primary" : ""}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Combobox.Button>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="relative inline-flex w-full items-center rounded-md rounded-l-none border border-mineshaft-500 bg-bunker-800 font-inter text-gray-400">
|
||||||
|
<Combobox.Input
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (activeIndex === 0 && e.key === "Enter") setIsOpen(true);
|
||||||
|
}}
|
||||||
|
autoComplete="off"
|
||||||
|
className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pl-2.5 pr-8 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50"
|
||||||
|
placeholder="Search by secret/folder name..."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
{hasSearch && (
|
||||||
|
<IconButton
|
||||||
|
isRounded
|
||||||
|
variant="plain"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
className="absolute right-2 text-primary"
|
||||||
|
ariaLabel="Clear search"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircleXmark} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Combobox.Options className="absolute z-30 mt-2 w-full min-w-[220px] overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow focus:outline-none">
|
||||||
|
<Combobox.Option
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
value={value}
|
||||||
|
className={({ active }) =>
|
||||||
|
`flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${
|
||||||
|
active ? "bg-mineshaft-500" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFolderTree} className="mr-2 mt-1 text-yellow-700" />
|
||||||
|
{value.trim()
|
||||||
|
? `Search for "${
|
||||||
|
value.length > 10 ? `${value.substring(0, 10)}...` : value
|
||||||
|
}" in all folders`
|
||||||
|
: "Search in all folders"}
|
||||||
|
</Combobox.Option>
|
||||||
|
</Combobox.Options>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
<QuickSearchModal
|
||||||
|
isSingleEnv={isSingleEnv}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
initialValue={value}
|
||||||
|
onClose={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange("");
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
|
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||||
|
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuickSearchDynamicSecretItem = ({
|
||||||
|
dynamicSecretGroup,
|
||||||
|
|
||||||
|
onClose
|
||||||
|
}: Props) => {
|
||||||
|
const navigate = useNavigate({
|
||||||
|
from: "/secret-manager/$projectId/overview"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [groupDynamicSecret] = dynamicSecretGroup;
|
||||||
|
|
||||||
|
const handleNavigate = () => {
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretPath: groupDynamicSecret.path,
|
||||||
|
search: groupDynamicSecret.name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
>
|
||||||
|
<Td className="w-full">
|
||||||
|
<div className="inline-flex max-w-[20rem] flex-col">
|
||||||
|
<span className="truncate">
|
||||||
|
<FontAwesomeIcon className="mr-2 self-center text-yellow-700" icon={faFingerprint} />
|
||||||
|
{groupDynamicSecret.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-mineshaft-400">
|
||||||
|
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
|
||||||
|
<Tooltip className="max-w-7xl" content={groupDynamicSecret.path}>
|
||||||
|
<span>{reverseTruncate(groupDynamicSecret.path)}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td />
|
||||||
|
<Td>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||||
|
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||||
|
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => {
|
||||||
|
const navigate = useNavigate({
|
||||||
|
from: "/secret-manager/$projectId/overview"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [groupFolder] = folderGroup;
|
||||||
|
|
||||||
|
const handleNavigate = () => {
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({ ...prev, secretPath: groupFolder.path })
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
>
|
||||||
|
<Td className="w-full whitespace-nowrap">
|
||||||
|
<FontAwesomeIcon className="text-yellow-700" icon={faFolder} />
|
||||||
|
<Tooltip content={groupFolder.path} className="max-w-7xl">
|
||||||
|
<div className="ml-2 inline-block">{reverseTruncate(groupFolder.path)}</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Td>
|
||||||
|
<Td />
|
||||||
|
<Td>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
faCheckCircle,
|
||||||
|
faChevronLeft,
|
||||||
|
faFilter,
|
||||||
|
faFingerprint,
|
||||||
|
faFolder,
|
||||||
|
faKey,
|
||||||
|
faMagnifyingGlass
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownSubMenu,
|
||||||
|
DropdownSubMenuContent,
|
||||||
|
DropdownSubMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useDebounce } from "@app/hooks";
|
||||||
|
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
|
||||||
|
import { WsTag } from "@app/hooks/api/tags/types";
|
||||||
|
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
import { RowType } from "../../../../secrets.$envSlug/-components/SecretMainPage.types";
|
||||||
|
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
|
||||||
|
import { QuickSearchFolderItem } from "./QuickSearchFolderItem";
|
||||||
|
import { QuickSearchSecretItem } from "./QuickSearchSecretItem";
|
||||||
|
|
||||||
|
export type QuickSearchModalProps = {
|
||||||
|
environments: WorkspaceEnv[];
|
||||||
|
projectId: string;
|
||||||
|
tags?: WsTag[];
|
||||||
|
isSingleEnv?: boolean;
|
||||||
|
initialValue: string;
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
|
||||||
|
|
||||||
|
const Content = ({
|
||||||
|
environments,
|
||||||
|
projectId,
|
||||||
|
onClose,
|
||||||
|
tags,
|
||||||
|
initialValue = "",
|
||||||
|
isSingleEnv
|
||||||
|
}: Omit<QuickSearchModalProps, "isOpen" | "onOpenChange">) => {
|
||||||
|
const [search, setSearch] = useState(initialValue);
|
||||||
|
const [debouncedSearch] = useDebounce(search);
|
||||||
|
const [filterTags, setFilterTags] = useState<Record<string, boolean>>({});
|
||||||
|
const [showFilter, setShowFilter] = useState<Record<ResourceType, boolean>>({
|
||||||
|
[RowType.Secret]: true,
|
||||||
|
[RowType.Folder]: true,
|
||||||
|
[RowType.DynamicSecret]: true
|
||||||
|
});
|
||||||
|
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
|
||||||
|
const { data, isLoading } = useGetProjectSecretsQuickSearch(
|
||||||
|
{
|
||||||
|
secretPath: "/",
|
||||||
|
environments: environments.map((env) => env.slug),
|
||||||
|
projectId,
|
||||||
|
search: debouncedSearch,
|
||||||
|
tags: filterTags
|
||||||
|
},
|
||||||
|
{ enabled: isEnabled }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
|
||||||
|
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
|
||||||
|
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
|
||||||
|
|
||||||
|
const handleToggleTag = (tag: string) => {
|
||||||
|
setFilterTags((prev) => {
|
||||||
|
const updated = { ...prev };
|
||||||
|
if (prev[tag]) delete updated[tag];
|
||||||
|
else updated[tag] = true;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleShowType = (type: ResourceType) => {
|
||||||
|
setShowFilter((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[type]: !prev[type]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[14.6rem]">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||||
|
placeholder="Search by secret, folder or tag name..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={
|
||||||
|
<FontAwesomeIcon icon={faMagnifyingGlass} className={search ? "text-primary" : ""} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<Tooltip content="Search Filters">
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
ariaLabel="Filter secrets by tag(s)"
|
||||||
|
className={twMerge(
|
||||||
|
"transition-all",
|
||||||
|
(Object.keys(filterTags).length ||
|
||||||
|
Object.values(showFilter).some((show) => !show)) &&
|
||||||
|
"border-primary/50 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFilter} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end" className="p-0">
|
||||||
|
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleShowType(RowType.Folder);
|
||||||
|
}}
|
||||||
|
icon={showFilter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
|
||||||
|
<span>Folders</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleShowType(RowType.DynamicSecret);
|
||||||
|
}}
|
||||||
|
icon={showFilter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
|
||||||
|
<span>Dynamic Secrets</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggleShowType(RowType.Secret);
|
||||||
|
}}
|
||||||
|
icon={showFilter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
|
||||||
|
<span>Secrets</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{tags && tags.length > 0 && (
|
||||||
|
<DropdownSubMenu>
|
||||||
|
<DropdownSubMenuTrigger
|
||||||
|
iconPos="left"
|
||||||
|
icon={<FontAwesomeIcon icon={faChevronLeft} size="sm" />}
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</DropdownSubMenuTrigger>
|
||||||
|
<DropdownSubMenuContent
|
||||||
|
collisionPadding={{ right: Infinity }} // forces dropdown to left
|
||||||
|
className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-r-none"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>Filter Secrets by Tag(s)</DropdownMenuLabel>
|
||||||
|
{tags.map(({ id, slug, color }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
handleToggleTag(slug);
|
||||||
|
}}
|
||||||
|
key={id}
|
||||||
|
icon={filterTags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="mr-2 h-2 w-2 rounded-full"
|
||||||
|
style={{ background: color || "#bec2c8" }}
|
||||||
|
/>
|
||||||
|
{slug}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownSubMenuContent>
|
||||||
|
</DropdownSubMenu>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 max-h-[19rem] min-h-[19rem] overflow-auto">
|
||||||
|
{/* eslint-disable-next-line no-nested-ternary */}
|
||||||
|
{isEnabled ? (
|
||||||
|
// eslint-disable-next-line no-nested-ternary
|
||||||
|
isLoading ? (
|
||||||
|
<Spinner size="lg" className="mx-auto mt-24 text-mineshaft-900" />
|
||||||
|
) : isEmpty ? (
|
||||||
|
<EmptyState
|
||||||
|
className="mt-24"
|
||||||
|
title="No results match search."
|
||||||
|
icon={faMagnifyingGlass}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TableContainer className="thin-scrollbar h-full overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
{showFilter[RowType.Folder] &&
|
||||||
|
Object.entries(folders).map(([key, folderGroup]) => (
|
||||||
|
<QuickSearchFolderItem onClose={onClose} folderGroup={folderGroup} key={key} />
|
||||||
|
))}
|
||||||
|
{showFilter[RowType.DynamicSecret] &&
|
||||||
|
Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => (
|
||||||
|
<QuickSearchDynamicSecretItem
|
||||||
|
onClose={onClose}
|
||||||
|
dynamicSecretGroup={dynamicSecretGroup}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showFilter[RowType.Secret] &&
|
||||||
|
Object.entries(secrets).map(([key, secretGroup]) => (
|
||||||
|
<QuickSearchSecretItem
|
||||||
|
search={debouncedSearch}
|
||||||
|
tags={Object.keys(filterTags)}
|
||||||
|
isSingleEnv={isSingleEnv}
|
||||||
|
environments={environments}
|
||||||
|
onClose={onClose}
|
||||||
|
secretGroup={secretGroup}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
className="mt-24"
|
||||||
|
title="Start typing to begin search..."
|
||||||
|
icon={faMagnifyingGlass}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuickSearchModal = ({
|
||||||
|
isOpen,
|
||||||
|
isSingleEnv,
|
||||||
|
onOpenChange,
|
||||||
|
...props
|
||||||
|
}: QuickSearchModalProps) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
title={`Search All Folders${isSingleEnv ? " In Environment" : ""}`}
|
||||||
|
subTitle={`Search the ${
|
||||||
|
isSingleEnv ? "current environment" : "entire project"
|
||||||
|
} to quickly reference secrets and navigate deeply.`}
|
||||||
|
>
|
||||||
|
<Content isSingleEnv={isSingleEnv} {...props} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faChevronRight,
|
||||||
|
faCopy,
|
||||||
|
faEye,
|
||||||
|
faFolder,
|
||||||
|
faKey,
|
||||||
|
faTags
|
||||||
|
} 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 {
|
||||||
|
Badge,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
IconButton,
|
||||||
|
Td,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||||
|
import { useTimedReset } from "@app/hooks";
|
||||||
|
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||||
|
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
environments: WorkspaceEnv[];
|
||||||
|
secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string];
|
||||||
|
onClose: () => void;
|
||||||
|
isSingleEnv?: boolean;
|
||||||
|
tags: string[];
|
||||||
|
search: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QuickSearchSecretItem = ({
|
||||||
|
secretGroup,
|
||||||
|
environments,
|
||||||
|
onClose,
|
||||||
|
tags,
|
||||||
|
isSingleEnv,
|
||||||
|
search
|
||||||
|
}: Props) => {
|
||||||
|
const navigate = useNavigate({ from: "/secret-manager/$projectId/overview" });
|
||||||
|
const envSlugMap = new Map(environments.map((env) => [env.slug, env]));
|
||||||
|
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
||||||
|
initialState: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [groupSecret] = secretGroup;
|
||||||
|
|
||||||
|
const handleNavigate = () => {
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({
|
||||||
|
...prev,
|
||||||
|
secretPath: groupSecret.path,
|
||||||
|
search: groupSecret.key,
|
||||||
|
tags: tags.length ? tags.join(",") : undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (value: string, env: string) => {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
createNotification({
|
||||||
|
type: "info",
|
||||||
|
title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`,
|
||||||
|
text: ""
|
||||||
|
});
|
||||||
|
setIsUrlCopied(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const secretGroupTags = secretGroup.flatMap((secret) => secret.tags);
|
||||||
|
|
||||||
|
const tagMatch =
|
||||||
|
search.trim() &&
|
||||||
|
secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr
|
||||||
|
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||||
|
onClick={handleNavigate}
|
||||||
|
>
|
||||||
|
<Td className="w-full">
|
||||||
|
<div className="inline-flex max-w-[20rem] flex-col">
|
||||||
|
<span className="truncate">
|
||||||
|
<FontAwesomeIcon className="mr-2 self-center text-bunker-300" icon={faKey} />
|
||||||
|
{groupSecret.key}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-mineshaft-400">
|
||||||
|
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
|
||||||
|
<Tooltip className="max-w-7xl" content={groupSecret.path}>
|
||||||
|
<span>{reverseTruncate(groupSecret.path ?? "")}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex w-full items-center justify-end gap-4">
|
||||||
|
{tagMatch && (
|
||||||
|
<Badge variant="primary" className="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<FontAwesomeIcon size="xs" icon={faTags} />
|
||||||
|
{tagMatch.slug}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isSingleEnv ? (
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
ariaLabel="Copy secret value"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = envSlugMap.get(groupSecret.env)?.name;
|
||||||
|
if (el) {
|
||||||
|
handleCopy(groupSecret.value!, el);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
ariaLabel="Copy secret value"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Copy Value From...</DropdownMenuLabel>
|
||||||
|
{secretGroup.map((secret) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = envSlugMap.get(secret.env)?.name;
|
||||||
|
if (el) {
|
||||||
|
handleCopy(secret.value!, el);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={secret.id}
|
||||||
|
>
|
||||||
|
<p className="text-sm">{envSlugMap.get(secret.env)?.name}</p>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
size="md"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
ariaLabel="View secret value"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEye} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Hover to Reveal...</DropdownMenuLabel>
|
||||||
|
{secretGroup.map((secret) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="group"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const el = envSlugMap.get(secret.env)?.name;
|
||||||
|
if (el) {
|
||||||
|
handleCopy(secret.value!, el);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={secret.id}
|
||||||
|
>
|
||||||
|
<Tooltip side="left" sideOffset={18} content="Click to copy to clipboard">
|
||||||
|
<div>
|
||||||
|
{!isSingleEnv && (
|
||||||
|
<span className="text-xs text-mineshaft-400">
|
||||||
|
{envSlugMap.get(secret.env)?.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={twMerge(
|
||||||
|
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
|
||||||
|
!secret.value && "text-mineshaft-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{secret.value || "EMPTY"}
|
||||||
|
</p>
|
||||||
|
<p className="w-[12rem] text-sm group-hover:hidden">
|
||||||
|
***************************
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<FontAwesomeIcon icon={faChevronRight} />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./QuickSearchDynamicSecretItem";
|
||||||
|
export * from "./QuickSearchFolderItem";
|
||||||
|
export * from "./QuickSearchModal";
|
||||||
|
export * from "./QuickSearchSecretItem";
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretSearchInput } from "./SecretSearchInput";
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
import { faFileImport, faFingerprint, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Tooltip } from "@app/components/v2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
folderCount?: number;
|
||||||
|
importCount?: number;
|
||||||
|
secretCount?: number;
|
||||||
|
dynamicSecretCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretTableResourceCount = ({
|
||||||
|
folderCount = 0,
|
||||||
|
dynamicSecretCount = 0,
|
||||||
|
secretCount = 0,
|
||||||
|
importCount = 0
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 divide-x divide-mineshaft-500 text-sm text-mineshaft-400">
|
||||||
|
{importCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
className="max-w-sm"
|
||||||
|
content={
|
||||||
|
<p className="whitespace-nowrap text-center">
|
||||||
|
Total import count{" "}
|
||||||
|
<span className="text-center text-mineshaft-400">(matching filters)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
|
||||||
|
<span>{importCount}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{folderCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
className="max-w-sm"
|
||||||
|
content={
|
||||||
|
<p className="whitespace-nowrap text-center">
|
||||||
|
Total folder count{" "}
|
||||||
|
<span className="text-center text-mineshaft-400">(matching filters)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 pl-2">
|
||||||
|
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
|
||||||
|
<span>{folderCount}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{dynamicSecretCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
className="max-w-sm"
|
||||||
|
content={
|
||||||
|
<p className="whitespace-nowrap text-center">
|
||||||
|
Total dynamic secret count{" "}
|
||||||
|
<span className="text-center text-mineshaft-400">(matching filters)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 pl-2">
|
||||||
|
<FontAwesomeIcon icon={faFingerprint} className="text-yellow-700" />
|
||||||
|
<span>{dynamicSecretCount}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{secretCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
className="max-w-sm"
|
||||||
|
content={
|
||||||
|
<p className="whitespace-nowrap text-center">
|
||||||
|
Total secret count{" "}
|
||||||
|
<span className="text-center text-mineshaft-400">(matching filters)</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 pl-2">
|
||||||
|
<FontAwesomeIcon icon={faKey} className="text-bunker-300" />
|
||||||
|
<span>{secretCount}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretTableResourceCount } from "./SecretTableResourceCount";
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, Checkbox, Modal, ModalContent, Spinner } from "@app/components/v2";
|
||||||
|
import { useProjectPermission, useWorkspace } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useGetWorkspaceById, useMigrateProjectToV3, workspaceKeys } from "@app/hooks/api";
|
||||||
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
|
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
enum ProjectUpgradeStatus {
|
||||||
|
InProgress = "IN_PROGRESS",
|
||||||
|
// Completed -> Will be null if completed. So a completed status is not needed
|
||||||
|
Failed = "FAILED"
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
isCLIChecked: z.literal(true),
|
||||||
|
isOperatorChecked: z.literal(true),
|
||||||
|
shouldCloseOpenApprovals: z.literal(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SecretV2MigrationSection = () => {
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["migrationInfo"] as const);
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: workspaceDetails, refetch } = useGetWorkspaceById(
|
||||||
|
// if v3 no need to fetch
|
||||||
|
currentWorkspace?.version === ProjectVersion.V3 ? "" : currentWorkspace?.id || "",
|
||||||
|
{
|
||||||
|
refetchInterval:
|
||||||
|
currentWorkspace?.upgradeStatus === ProjectUpgradeStatus.InProgress ? 2000 : false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const { membership } = useProjectPermission();
|
||||||
|
const migrateProjectToV3 = useMigrateProjectToV3();
|
||||||
|
const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!popUp.migrationInfo.isOpen) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [popUp.migrationInfo.isOpen]);
|
||||||
|
|
||||||
|
const isProjectUpgraded = workspaceDetails?.version === ProjectVersion.V3;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isProjectUpgraded && migrateProjectToV3.data) {
|
||||||
|
createNotification({ type: "success", text: "Project upgrade completed successfully" });
|
||||||
|
migrateProjectToV3.reset();
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: workspaceKeys.getAllUserWorkspace(ProjectType.SecretManager)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isProjectUpgraded, Boolean(migrateProjectToV3.data)]);
|
||||||
|
|
||||||
|
if (isProjectUpgraded || currentWorkspace?.version === ProjectVersion.V3) return null;
|
||||||
|
|
||||||
|
const isUpgrading = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.InProgress;
|
||||||
|
const didProjectUpgradeFailed = workspaceDetails?.upgradeStatus === ProjectUpgradeStatus.Failed;
|
||||||
|
|
||||||
|
const handleMigrationSecretV2 = async () => {
|
||||||
|
try {
|
||||||
|
handlePopUpToggle("migrationInfo");
|
||||||
|
await migrateProjectToV3.mutateAsync({ workspaceId: currentWorkspace?.id || "" });
|
||||||
|
refetch();
|
||||||
|
createNotification({
|
||||||
|
text: "Project upgrade started",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to upgrade project",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = membership?.roles.includes(ProjectMembershipRole.Admin);
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex max-w-2xl flex-col rounded-lg border border-primary/50 bg-primary/10 px-6 py-5">
|
||||||
|
{isUpgrading && (
|
||||||
|
<div className="absolute left-0 top-0 z-50 flex h-screen w-screen items-center justify-center bg-bunker-500 bg-opacity-80">
|
||||||
|
<Spinner size="lg" className="text-primary" />
|
||||||
|
<div className="ml-4 flex flex-col space-y-1">
|
||||||
|
<div className="text-3xl font-medium">Please wait</div>
|
||||||
|
<span className="inline-block">Upgrading secrets engine...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4 flex items-start gap-2">
|
||||||
|
<FontAwesomeIcon icon={faWarning} size="xl" className="mt-1 text-primary" />
|
||||||
|
<p className="text-xl font-semibold">Upgrade secrets engine version</p>
|
||||||
|
</div>
|
||||||
|
<p className="mx-1 mb-4 leading-7 text-mineshaft-100">
|
||||||
|
Your existing workflows to fetch secrets will continue to work. However, viewing secrets on
|
||||||
|
the UI requires you to upgrade your project's secrets engine version.
|
||||||
|
</p>
|
||||||
|
<p className="mx-1 mb-4 leading-7 text-mineshaft-100">
|
||||||
|
Upgrading is free and enables the use of Infisical's new secrets engine, which is 10x
|
||||||
|
faster and allows you to encrypt secrets with your own KMS provider.
|
||||||
|
</p>
|
||||||
|
<p className="mx-1 mb-6 leading-7 text-mineshaft-100">
|
||||||
|
The upgrade takes only 1-2 minutes and will not cause any downtime.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => handlePopUpOpen("migrationInfo")}
|
||||||
|
isDisabled={!isAdmin || isUpgrading}
|
||||||
|
isLoading={migrateProjectToV3.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isAdmin ? "Upgrade Secrets Engine" : "Upgrade requires admin privilege"}
|
||||||
|
</Button>
|
||||||
|
{didProjectUpgradeFailed && (
|
||||||
|
<p className="mt-2 text-sm leading-7 text-red-400">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="mr-2" />
|
||||||
|
Secrets engine upgrade unsuccessful. For assistance, please contact the Infisical support
|
||||||
|
team.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.migrationInfo.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("migrationInfo", isOpen)}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Upgrade Checklist"
|
||||||
|
subTitle="To ensure smooth transition, please ensure the following requirements are met before upgrading this project."
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleMigrationSecretV2)}>
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="isCLIChecked"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
|
||||||
|
<Checkbox
|
||||||
|
id="is-cli-checked"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
>
|
||||||
|
Infisical CLI version is v0.25.0 or above.
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="isOperatorChecked"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
|
||||||
|
<Checkbox
|
||||||
|
id="is-operator-checked"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
>
|
||||||
|
Infisical Kubernetes Operator version is v0.7.0 or above.
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="shouldCloseOpenApprovals"
|
||||||
|
defaultValue={false}
|
||||||
|
render={({ field: { onBlur, value, onChange }, fieldState: { error } }) => (
|
||||||
|
<Checkbox
|
||||||
|
id="is-approvals-checked"
|
||||||
|
isChecked={value}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
onBlur={onBlur}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
>
|
||||||
|
Close/merge all open approval/access requests.
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex space-x-4">
|
||||||
|
<Button type="submit">Confirm Upgrade</Button>
|
||||||
|
<Button variant="outline_bg" onClick={() => handlePopUpToggle("migrationInfo")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { SecretV2MigrationSection } from "./SecretV2MigrationSection";
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import { subject } from "@casl/ability";
|
||||||
|
import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
ProjectPermissionActions,
|
||||||
|
ProjectPermissionSub,
|
||||||
|
useProjectPermission,
|
||||||
|
useWorkspace
|
||||||
|
} from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||||
|
import {
|
||||||
|
SecretType,
|
||||||
|
SecretV3RawSanitized,
|
||||||
|
TDeleteSecretBatchDTO,
|
||||||
|
TSecretFolder
|
||||||
|
} from "@app/hooks/api/types";
|
||||||
|
|
||||||
|
export enum EntryType {
|
||||||
|
FOLDER = "folder",
|
||||||
|
SECRET = "secret"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
secretPath: string;
|
||||||
|
resetSelectedEntries: () => void;
|
||||||
|
selectedEntries: {
|
||||||
|
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||||
|
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
|
||||||
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
|
"bulkDeleteEntries"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
|
||||||
|
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
|
||||||
|
const selectedCount = selectedFolderCount + selectedKeysCount;
|
||||||
|
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const userAvailableEnvs = currentWorkspace?.environments || [];
|
||||||
|
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||||
|
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||||
|
|
||||||
|
const isMultiSelectActive = selectedCount > 0;
|
||||||
|
|
||||||
|
// user should have the ability to delete secrets/folders in at least one of the envs
|
||||||
|
const shouldShowDelete = userAvailableEnvs.some((env) =>
|
||||||
|
permission.can(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment: env.slug,
|
||||||
|
secretPath,
|
||||||
|
secretName: "*",
|
||||||
|
secretTags: ["*"]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getDeleteModalTitle = () => {
|
||||||
|
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
|
||||||
|
return "Do you want to delete the selected secrets and folders across environments?";
|
||||||
|
}
|
||||||
|
if (selectedKeysCount > 0) {
|
||||||
|
return "Do you want to delete the selected secrets across environments?";
|
||||||
|
}
|
||||||
|
return "Do you want to delete the selected folders across environments?";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkDelete = async () => {
|
||||||
|
let processedEntries = 0;
|
||||||
|
|
||||||
|
const promises = userAvailableEnvs.map(async (env) => {
|
||||||
|
// additional check: ensure that bulk delete is only executed on envs that user has access to
|
||||||
|
|
||||||
|
if (
|
||||||
|
permission.can(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath })
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
await Promise.all(
|
||||||
|
Object.values(selectedEntries.folder).map(async (folderRecord) => {
|
||||||
|
const folder = folderRecord[env.slug];
|
||||||
|
if (folder) {
|
||||||
|
processedEntries += 1;
|
||||||
|
await deleteFolder({
|
||||||
|
folderId: folder?.id,
|
||||||
|
path: secretPath,
|
||||||
|
environment: env.slug,
|
||||||
|
projectId: workspaceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
|
||||||
|
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
|
||||||
|
const entry = secretRecord[env.slug];
|
||||||
|
const canDeleteSecret = permission.can(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment: env.slug,
|
||||||
|
secretPath,
|
||||||
|
secretName: entry.key,
|
||||||
|
secretTags: (entry?.tags || []).map((i) => i.slug)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entry && canDeleteSecret) {
|
||||||
|
return [
|
||||||
|
...accum,
|
||||||
|
{
|
||||||
|
secretKey: entry.key,
|
||||||
|
type: SecretType.Shared
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return accum;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (secretsToDelete.length > 0) {
|
||||||
|
processedEntries += secretsToDelete.length;
|
||||||
|
await deleteBatchSecretV3({
|
||||||
|
secretPath,
|
||||||
|
workspaceId,
|
||||||
|
environment: env.slug,
|
||||||
|
secrets: secretsToDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
const areEntriesDeleted = results.some((result) => result.status === "fulfilled");
|
||||||
|
if (processedEntries === 0) {
|
||||||
|
handlePopUpClose("bulkDeleteEntries");
|
||||||
|
createNotification({
|
||||||
|
type: "info",
|
||||||
|
text: "You don't have access to delete selected items"
|
||||||
|
});
|
||||||
|
} else if (areEntriesDeleted) {
|
||||||
|
handlePopUpClose("bulkDeleteEntries");
|
||||||
|
resetSelectedEntries();
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully deleted selected secrets and folders"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to delete selected secrets and folders"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
"h-0 flex-shrink-0 overflow-hidden transition-all",
|
||||||
|
isMultiSelectActive && "h-16"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||||
|
<Tooltip content="Clear">
|
||||||
|
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedEntries}>
|
||||||
|
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||||
|
{shouldShowDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
colorSchema="danger"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
className="ml-4"
|
||||||
|
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={getDeleteModalTitle()}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
|
onDeleteApproved={handleBulkDelete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { OverviewPage } from "./OverviewPage";
|
||||||
|
|
||||||
|
const SecretOverviewPageQuerySchema = z.object({
|
||||||
|
search: z.string().catch(""),
|
||||||
|
secretPath: z.string().catch("/")
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/overview/"
|
||||||
|
)({
|
||||||
|
component: OverviewPage,
|
||||||
|
validateSearch: zodValidator(SecretOverviewPageQuerySchema)
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
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` 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` 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+95
@@ -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,9 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { RoleDetailsBySlugPage } from "./RoleDetailsBySlugPage";
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
"/_authenticate/_ctx-org-details/secret-manager/$projectId/_layout-secret-manager/roles/$roleSlug/"
|
||||||
|
)({
|
||||||
|
component: RoleDetailsBySlugPage
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user