mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 01:05:40 +00:00
Merge remote-tracking branch 'origin' into ssh-certs
This commit is contained in:
@@ -1,18 +1,60 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify";
|
||||
import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { CopyButton } from "../v2/CopyButton";
|
||||
|
||||
export type TNotification = {
|
||||
title?: string;
|
||||
text: ReactNode;
|
||||
children?: ReactNode;
|
||||
callToAction?: ReactNode;
|
||||
copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[];
|
||||
};
|
||||
|
||||
export const NotificationContent = ({ title, text, children }: TNotification) => {
|
||||
export const NotificationContent = ({
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
callToAction,
|
||||
copyActions
|
||||
}: TNotification) => {
|
||||
return (
|
||||
<div className="msg-container">
|
||||
{title && <div className="text-md mb-1 font-medium">{title}</div>}
|
||||
<div className={title ? "text-sm text-neutral-400" : "text-md"}>{text}</div>
|
||||
{children && <div className="mt-2">{children}</div>}
|
||||
{(callToAction || copyActions) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-2 flex h-7 w-full flex-row items-end gap-2",
|
||||
callToAction ? "justify-between" : "justify-end"
|
||||
)}
|
||||
>
|
||||
{callToAction}
|
||||
|
||||
{copyActions && (
|
||||
<div className="flex h-7 flex-row items-center gap-2">
|
||||
{copyActions.map((action) => (
|
||||
<div className="flex flex-row items-center gap-2" key={`copy-${action.name}`}>
|
||||
{action.label && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">{action.label}</span>
|
||||
)}
|
||||
<CopyButton
|
||||
value={action.value}
|
||||
name={action.name}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
color="text-mineshaft-400"
|
||||
icon={action.icon ?? faCopy}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateWsTag } from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
export const secretTagsColors = [
|
||||
{
|
||||
@@ -88,13 +88,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const createTagSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Invalid slug. Slug can only contain alphanumeric characters and hyphens."
|
||||
}),
|
||||
slug: slugSchema({ min: 1, field: "Tag Slug" }),
|
||||
color: z.string().trim()
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
export type CopyButtonProps = {
|
||||
value: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
variant?: "solid" | "outline" | "plain" | "star" | "outline_bg";
|
||||
color?: string;
|
||||
name?: string;
|
||||
icon?: IconDefinition;
|
||||
};
|
||||
|
||||
export const CopyButton = ({
|
||||
value,
|
||||
size = "sm",
|
||||
variant = "solid",
|
||||
color,
|
||||
name,
|
||||
icon = faCopy
|
||||
}: CopyButtonProps) => {
|
||||
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
|
||||
initialState: name ? `Copy ${name}` : "Copy to clipboard"
|
||||
});
|
||||
|
||||
async function handleCopyText() {
|
||||
setCopyText("Copied");
|
||||
navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip content={copyText} size={size === "xs" || size === "sm" ? "sm" : "md"}>
|
||||
<IconButton
|
||||
ariaLabel={copyText}
|
||||
variant={variant}
|
||||
className={twMerge("group relative", color)}
|
||||
size={size}
|
||||
onClick={() => {
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopying ? faCheck : icon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CopyButton.displayName = "CopyButton";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CopyButtonProps } from "./CopyButton";
|
||||
export { CopyButton } from "./CopyButton";
|
||||
@@ -40,32 +40,39 @@ export const FilterableSelect = <T,>({
|
||||
...props.components
|
||||
}}
|
||||
classNames={{
|
||||
container: () => "w-full font-inter",
|
||||
control: ({ isFocused }) =>
|
||||
container: ({ isDisabled }) =>
|
||||
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
|
||||
control: ({ isFocused, isDisabled }) =>
|
||||
twMerge(
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
|
||||
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
|
||||
`border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
|
||||
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
|
||||
} `
|
||||
),
|
||||
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
|
||||
input: () => "pl-1 py-0.5",
|
||||
placeholder: () =>
|
||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||
input: () => "pl-1",
|
||||
valueContainer: () =>
|
||||
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`,
|
||||
`px-1 max-h-[8.2rem] ${
|
||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||
} gap-1`,
|
||||
singleValue: () => "leading-7 ml-1",
|
||||
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValueLabel: () => "leading-6 text-sm",
|
||||
multiValueRemove: () => "hover:text-red text-bunker-400",
|
||||
indicatorsContainer: () => "p-1 gap-1",
|
||||
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
|
||||
indicatorSeparator: () => "bg-bunker-400",
|
||||
dropdownIndicator: () => "text-bunker-200 p-1",
|
||||
menuList: () => "flex flex-col gap-1",
|
||||
menu: () =>
|
||||
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
"my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
|
||||
option: ({ isFocused, isSelected }) =>
|
||||
twMerge(
|
||||
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
|
||||
isSelected && "text-mineshaft-200",
|
||||
"hover:cursor-pointer mb-1 rounded text-xs px-3 py-2"
|
||||
"hover:cursor-pointer rounded text-xs px-3 py-2"
|
||||
),
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
}}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const Pagination = ({
|
||||
)}
|
||||
>
|
||||
{startAdornment}
|
||||
<div className="ml-auto mr-6 flex items-center space-x-2">
|
||||
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
|
||||
<div className="text-xs">
|
||||
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
||||
</div>
|
||||
|
||||
@@ -36,10 +36,12 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => {
|
||||
export const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
{children}
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="truncate">{children}</p>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
isDisabled?: boolean;
|
||||
center?: boolean;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -26,6 +27,7 @@ export const Tooltip = ({
|
||||
asChild = true,
|
||||
isDisabled,
|
||||
position = "top",
|
||||
size = "md",
|
||||
...props
|
||||
}: TooltipProps) =>
|
||||
// just render children if tooltip content is empty
|
||||
@@ -43,7 +45,7 @@ export const Tooltip = ({
|
||||
sideOffset={5}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
`z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md
|
||||
`z-50 max-w-[15rem] select-none border border-mineshaft-600 bg-mineshaft-800 font-light text-bunker-200 shadow-md
|
||||
data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade
|
||||
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
|
||||
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade
|
||||
@@ -51,6 +53,8 @@ export const Tooltip = ({
|
||||
`,
|
||||
isDisabled && "!hidden",
|
||||
center && "text-center",
|
||||
size === "sm" && "rounded-sm py-1 px-2 text-xs",
|
||||
size === "md" && "rounded-md py-2 px-4 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
fetchOrgUsers,
|
||||
useAddUserToWsNonE2EE,
|
||||
useCreateWorkspace,
|
||||
useGetExternalKmsList
|
||||
useGetExternalKmsList,
|
||||
useGetUserWorkspaces
|
||||
} from "@app/hooks/api";
|
||||
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
|
||||
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
|
||||
@@ -68,6 +69,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
||||
const { permission } = useOrgPermission();
|
||||
const { user } = useUser();
|
||||
const createWs = useCreateWorkspace();
|
||||
const { refetch: refetchWorkspaces } = useGetUserWorkspaces();
|
||||
const addUsersToProject = useAddUserToWsNonE2EE();
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
@@ -137,8 +139,8 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => {
|
||||
orgId: currentOrg.id
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
|
||||
await refetchWorkspaces();
|
||||
|
||||
createNotification({ text: "Project created", type: "success" });
|
||||
reset();
|
||||
|
||||
@@ -33,6 +33,10 @@ export enum PermissionConditionOperators {
|
||||
$GLOB = "$glob"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||
[PermissionConditionOperators.$EQ]: "equal to",
|
||||
[PermissionConditionOperators.$IN]: "contains",
|
||||
@@ -154,7 +158,13 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.Identity
|
||||
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
export const getMemberLabel = (member: TWorkspaceUser) => {
|
||||
const {
|
||||
inviteEmail,
|
||||
user: { firstName, lastName, username, email }
|
||||
} = member;
|
||||
|
||||
return firstName || lastName
|
||||
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||
: username || email || inviteEmail;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
enum OrgMembershipRole {
|
||||
Admin = "admin",
|
||||
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
|
||||
|
||||
export const isCustomProjectRole = (slug: string) =>
|
||||
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
||||
|
||||
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
|
||||
isCustomOrgRole(roleIdOrSlug)
|
||||
? roles.find((r) => r.id === roleIdOrSlug)
|
||||
: roles.find((r) => r.slug === roleIdOrSlug);
|
||||
|
||||
@@ -18,15 +18,15 @@ export type TAccessApprovalPolicy = {
|
||||
approvers?: Approver[];
|
||||
};
|
||||
|
||||
export enum ApproverType{
|
||||
export enum ApproverType {
|
||||
User = "user",
|
||||
Group = "group"
|
||||
}
|
||||
|
||||
export type Approver ={
|
||||
export type Approver = {
|
||||
id: string;
|
||||
type: ApproverType;
|
||||
}
|
||||
};
|
||||
|
||||
export type TAccessApprovalRequest = {
|
||||
id: string;
|
||||
@@ -70,6 +70,7 @@ export type TAccessApprovalRequest = {
|
||||
secretPath?: string | null;
|
||||
envId: string;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
deletedAt: Date | null;
|
||||
};
|
||||
|
||||
reviewers: {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const eventToNameMap: { [K in EventType]: string } = {
|
||||
[EventType.DELETE_SECRET]: "Delete secret",
|
||||
[EventType.GET_WORKSPACE_KEY]: "Read project key",
|
||||
[EventType.AUTHORIZE_INTEGRATION]: "Authorize integration",
|
||||
[EventType.UPDATE_INTEGRATION_AUTH]: "Update integration auth",
|
||||
[EventType.UNAUTHORIZE_INTEGRATION]: "Unauthorize integration",
|
||||
[EventType.CREATE_INTEGRATION]: "Create integration",
|
||||
[EventType.DELETE_INTEGRATION]: "Delete integration",
|
||||
|
||||
@@ -23,6 +23,7 @@ export enum EventType {
|
||||
DELETE_SECRET = "delete-secret",
|
||||
GET_WORKSPACE_KEY = "get-workspace-key",
|
||||
AUTHORIZE_INTEGRATION = "authorize-integration",
|
||||
UPDATE_INTEGRATION_AUTH = "update-integration-auth",
|
||||
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
|
||||
CREATE_INTEGRATION = "create-integration",
|
||||
DELETE_INTEGRATION = "delete-integration",
|
||||
|
||||
@@ -177,11 +177,21 @@ export const useGetProjectSecretsOverview = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -270,11 +280,21 @@ export const useGetProjectSecretsDetails = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -355,11 +375,21 @@ export const useGetProjectSecretsQuickSearch = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secrets deep search",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export {
|
||||
useAddUserToGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUpdateGroup} from "./mutations";
|
||||
export {
|
||||
useListGroupUsers
|
||||
} from "./queries";
|
||||
useAddUserToGroup,
|
||||
useCreateGroup,
|
||||
useDeleteGroup,
|
||||
useRemoveUserFromGroup,
|
||||
useUpdateGroup
|
||||
} from "./mutations";
|
||||
export { useGetGroupById, useListGroupUsers } from "./queries";
|
||||
|
||||
@@ -56,8 +56,9 @@ export const useUpdateGroup = () => {
|
||||
|
||||
return group;
|
||||
},
|
||||
onSuccess: ({ orgId }) => {
|
||||
onSuccess: ({ orgId, id: groupId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
|
||||
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -70,8 +71,9 @@ export const useDeleteGroup = () => {
|
||||
|
||||
return group;
|
||||
},
|
||||
onSuccess: ({ orgId }) => {
|
||||
onSuccess: ({ orgId, id: groupId }) => {
|
||||
queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId));
|
||||
queryClient.invalidateQueries(groupKeys.getGroupById(groupId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { EFilterReturnedUsers, TGroup, TGroupUser } from "./types";
|
||||
|
||||
export const groupKeys = {
|
||||
getGroupById: (groupId: string) => [{ groupId }, "group"] as const,
|
||||
allGroupUserMemberships: () => ["group-user-memberships"] as const,
|
||||
forGroupUserMemberships: (slug: string) =>
|
||||
[...groupKeys.allGroupUserMemberships(), slug] as const,
|
||||
@@ -10,22 +13,27 @@ export const groupKeys = {
|
||||
slug,
|
||||
offset,
|
||||
limit,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
slug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const
|
||||
filter?: EFilterReturnedUsers;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const
|
||||
};
|
||||
|
||||
type TUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isPartOfGroup: boolean;
|
||||
export const useGetGroupById = (groupId: string) => {
|
||||
return useQuery({
|
||||
enabled: Boolean(groupId),
|
||||
queryKey: groupKeys.getGroupById(groupId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TGroup>(`/api/v1/groups/${groupId}`);
|
||||
|
||||
return { group: data };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useListGroupUsers = ({
|
||||
@@ -33,20 +41,23 @@ export const useListGroupUsers = ({
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search: string;
|
||||
filter?: EFilterReturnedUsers;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupUserMemberships({
|
||||
slug: groupSlug,
|
||||
offset,
|
||||
limit,
|
||||
search
|
||||
search,
|
||||
filter
|
||||
}),
|
||||
enabled: Boolean(groupSlug),
|
||||
keepPreviousData: true,
|
||||
@@ -54,10 +65,11 @@ export const useListGroupUsers = ({
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
search
|
||||
search,
|
||||
...(filter && { filter })
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>(
|
||||
const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>(
|
||||
`/api/v1/groups/${id}/users`,
|
||||
{
|
||||
params
|
||||
|
||||
@@ -11,7 +11,7 @@ export type TGroup = {
|
||||
name: string;
|
||||
slug: string;
|
||||
orgId: string;
|
||||
createAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
role: string;
|
||||
};
|
||||
@@ -41,3 +41,18 @@ export type TGroupWithProjectMemberships = {
|
||||
slug: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TGroupUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isPartOfGroup: boolean;
|
||||
joinedGroupAt: Date;
|
||||
};
|
||||
|
||||
export enum EFilterReturnedUsers {
|
||||
EXISTING_MEMBERS = "existingMembers",
|
||||
NON_MEMBERS = "nonMembers"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
useAddExternalKms,
|
||||
useExternalKmsFetchGcpKeys,
|
||||
useLoadProjectKmsBackup,
|
||||
useRemoveExternalKms,
|
||||
useUpdateExternalKms,
|
||||
|
||||
@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { kmsKeys } from "./queries";
|
||||
import { AddExternalKmsType, KmsType } from "./types";
|
||||
import {
|
||||
AddExternalKmsType,
|
||||
ExternalKmsGcpSchemaType,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsType,
|
||||
UpdateExternalKmsType
|
||||
} from "./types";
|
||||
|
||||
export const useAddExternalKms = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -33,7 +39,7 @@ export const useUpdateExternalKms = (orgId: string) => {
|
||||
provider
|
||||
}: {
|
||||
kmsId: string;
|
||||
} & AddExternalKmsType) => {
|
||||
} & UpdateExternalKmsType) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, {
|
||||
name,
|
||||
description,
|
||||
@@ -96,3 +102,44 @@ export const useLoadProjectKmsBackup = (projectId: string) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useExternalKmsFetchGcpKeys = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
gcpRegion,
|
||||
...rest
|
||||
}: Pick<ExternalKmsGcpSchemaType, "gcpRegion"> &
|
||||
(
|
||||
| (Pick<ExternalKmsGcpSchemaType, KmsGcpKeyFetchAuthType.Credential> & {
|
||||
[KmsGcpKeyFetchAuthType.Kms]?: never;
|
||||
})
|
||||
| {
|
||||
[KmsGcpKeyFetchAuthType.Kms]: string;
|
||||
[KmsGcpKeyFetchAuthType.Credential]?: never;
|
||||
}
|
||||
)): Promise<{ keys: string[] }> => {
|
||||
const {
|
||||
[KmsGcpKeyFetchAuthType.Credential]: credential,
|
||||
[KmsGcpKeyFetchAuthType.Kms]: kmsId
|
||||
} = rest;
|
||||
|
||||
if ((credential && kmsId) || (!credential && !kmsId)) {
|
||||
throw new Error(
|
||||
`Either '${KmsGcpKeyFetchAuthType.Credential}' or '${KmsGcpKeyFetchAuthType.Kms}' must be provided, but not both.`
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", {
|
||||
authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms,
|
||||
region: gcpRegion,
|
||||
...rest
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(kmsKeys.getExternalKmsList(orgId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
export type Kms = {
|
||||
id: string;
|
||||
description: string;
|
||||
@@ -35,7 +37,8 @@ export enum KmsType {
|
||||
}
|
||||
|
||||
export enum ExternalKmsProvider {
|
||||
AWS = "aws"
|
||||
Aws = "aws",
|
||||
Gcp = "gcp"
|
||||
}
|
||||
|
||||
export const INTERNAL_KMS_KEY_ID = "internal";
|
||||
@@ -44,6 +47,10 @@ export enum KmsAwsCredentialType {
|
||||
AssumeRole = "assume-role",
|
||||
AccessKey = "access-key"
|
||||
}
|
||||
// Google uses snake_case for their enum values and we need to match that
|
||||
export enum KmsGcpCredentialType {
|
||||
ServiceAccount = "service_account"
|
||||
}
|
||||
|
||||
export const ExternalKmsAwsSchema = z.object({
|
||||
credential: z
|
||||
@@ -83,11 +90,54 @@ export const ExternalKmsAwsSchema = z.object({
|
||||
)
|
||||
});
|
||||
|
||||
export const ExternalKmsGcpCredentialSchema = z.object({
|
||||
type: z.literal(KmsGcpCredentialType.ServiceAccount),
|
||||
project_id: z.string().min(1),
|
||||
private_key_id: z.string().min(1),
|
||||
private_key: z.string().min(1),
|
||||
client_email: z.string().min(1),
|
||||
client_id: z.string().min(1),
|
||||
auth_uri: z.string().min(1),
|
||||
token_uri: z.string().min(1),
|
||||
auth_provider_x509_cert_url: z.string().min(1),
|
||||
client_x509_cert_url: z.string().min(1),
|
||||
universe_domain: z.string().min(1)
|
||||
});
|
||||
|
||||
export type ExternalKmsGcpCredentialSchemaType = z.infer<typeof ExternalKmsGcpCredentialSchema>;
|
||||
|
||||
export const ExternalKmsGcpSchema = z.object({
|
||||
credential: ExternalKmsGcpCredentialSchema.describe(
|
||||
"GCP Service Account JSON credential to connect"
|
||||
),
|
||||
gcpRegion: z.string().min(1).trim().describe("GCP region where the KMS key is located"),
|
||||
keyName: z.string().min(1).trim().describe("GCP key name")
|
||||
});
|
||||
export type ExternalKmsGcpSchemaType = z.infer<typeof ExternalKmsGcpSchema>;
|
||||
|
||||
export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ExternalKmsProvider.AWS), inputs: ExternalKmsAwsSchema })
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema })
|
||||
]);
|
||||
|
||||
export const AddExternalKmsSchema = z.object({
|
||||
name: slugSchema({ min: 1, field: "Alias" }),
|
||||
description: z.string().trim().optional(),
|
||||
provider: ExternalKmsInputSchema
|
||||
});
|
||||
|
||||
export type AddExternalKmsType = z.infer<typeof AddExternalKmsSchema>;
|
||||
|
||||
// we need separate schema for update because the credential field is not required on GCP
|
||||
export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({
|
||||
type: z.literal(ExternalKmsProvider.Gcp),
|
||||
inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })
|
||||
})
|
||||
]);
|
||||
|
||||
export const UpdateExternalKmsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -96,7 +146,52 @@ export const AddExternalKmsSchema = z.object({
|
||||
message: "Alias must be a valid slug"
|
||||
}),
|
||||
description: z.string().trim().optional(),
|
||||
provider: ExternalKmsInputSchema
|
||||
provider: ExternalKmsUpdateInputSchema
|
||||
});
|
||||
|
||||
export type AddExternalKmsType = z.infer<typeof AddExternalKmsSchema>;
|
||||
export type UpdateExternalKmsType = z.infer<typeof UpdateExternalKmsSchema>;
|
||||
|
||||
const GCP_CREDENTIAL_MAX_FILE_SIZE = 8 * 1024; // 8KB
|
||||
const GCP_CREDENTIAL_ACCEPTED_FILE_TYPES = ["application/json"];
|
||||
|
||||
const AddExternalKmsGcpFormSchemaStandardInputs = z.object({
|
||||
keyObject: z
|
||||
.object({ label: z.string().trim(), value: z.string().trim() })
|
||||
.describe("GCP key name"),
|
||||
gcpRegion: z.object({ label: z.string().trim(), value: z.string().trim() }).describe("GCP Region")
|
||||
});
|
||||
|
||||
export const AddExternalKmsGcpFormSchema = z.discriminatedUnion("formType", [
|
||||
z
|
||||
.object({
|
||||
formType: z.literal("newGcpKms"),
|
||||
// `FileList` is a browser-only (window-specific) type, so we need to handle it differently on the server to avoid SSR errors
|
||||
credentialFile:
|
||||
typeof window === "undefined"
|
||||
? z.any()
|
||||
: z
|
||||
.instanceof(FileList)
|
||||
.refine((files) => files?.length === 1, "Image is required.")
|
||||
.refine(
|
||||
(files) => files?.[0]?.size <= GCP_CREDENTIAL_MAX_FILE_SIZE,
|
||||
"Max file size is 8KB."
|
||||
)
|
||||
.refine(
|
||||
(files) => GCP_CREDENTIAL_ACCEPTED_FILE_TYPES.includes(files?.[0]?.type),
|
||||
"Only .json files are accepted."
|
||||
)
|
||||
})
|
||||
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
|
||||
.merge(AddExternalKmsSchema.pick({ name: true, description: true })),
|
||||
z
|
||||
.object({ formType: z.literal("updateGcpKms") })
|
||||
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
|
||||
.merge(AddExternalKmsSchema.pick({ name: true, description: true }))
|
||||
]);
|
||||
|
||||
export type AddExternalKmsGcpFormSchemaType = z.infer<typeof AddExternalKmsGcpFormSchema>;
|
||||
|
||||
export enum KmsGcpKeyFetchAuthType {
|
||||
Credential = "credential",
|
||||
Kms = "kmsId"
|
||||
}
|
||||
|
||||
@@ -117,11 +117,21 @@ export const useGetProjectSecrets = ({
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secrets",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -148,15 +158,24 @@ export const useGetProjectSecretsAllEnv = ({
|
||||
enabled: Boolean(workspaceId && environment),
|
||||
onError: (error: unknown) => {
|
||||
if (axios.isAxiosError(error) && !isErrorHandled) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
if (message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
|
||||
createNotification({
|
||||
title: "Error fetching secrets",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
setIsErrorHandled.on();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,26 +51,26 @@ export enum ApiErrorTypes {
|
||||
|
||||
export type TApiErrors =
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.ValidationError;
|
||||
message: ZodIssue[];
|
||||
statusCode: 401;
|
||||
statusCode: 422;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.UnauthorizedError;
|
||||
message: string;
|
||||
statusCode: 401;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.ForbiddenError;
|
||||
message: string;
|
||||
details: PureAbility["rules"];
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { components, MenuProps, OptionProps } from "react-select";
|
||||
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faEye, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -93,7 +93,7 @@ const ProjectOption = ({
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="mr-2 text-mineshaft-300" icon={faEye} size="sm" />
|
||||
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
|
||||
)}
|
||||
<p className="truncate">{children}</p>
|
||||
{data.isFavorite ? (
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
export const slugSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Invalid slug format"
|
||||
});
|
||||
interface SlugSchemaInputs {
|
||||
min?: number;
|
||||
max?: number;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export const slugSchema = ({ min = 1, max = 32, field = "Slug" }: SlugSchemaInputs = {}) => {
|
||||
return z
|
||||
.string()
|
||||
.trim()
|
||||
.min(min, {
|
||||
message: `${field} field must be at least ${min} lowercase character${min === 1 ? "" : "s"}`
|
||||
})
|
||||
.max(max, {
|
||||
message: `${field} field must be at most ${max} lowercase character${max === 1 ? "" : "s"}`
|
||||
})
|
||||
.refine((v) => slugify(v, { lowercase: true }) === v, {
|
||||
message: `${field} field can only contain lowercase letters, numbers, and hyphens`
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2";
|
||||
|
||||
@@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`;
|
||||
window.location.assign(link);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
enum AuthMethod {
|
||||
APP = "APP",
|
||||
@@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() {
|
||||
if (selectedAuthMethod === AuthMethod.APP) {
|
||||
router.push("/integrations/select-integration-auth?integrationSlug=github");
|
||||
} else {
|
||||
if (!githubIntegration?.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(
|
||||
"githubactions",
|
||||
"cicd",
|
||||
"connecting-with-github-oauth"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2";
|
||||
|
||||
@@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() {
|
||||
|
||||
if (!integrationOption) return;
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
(gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useGetOrgIntegrationAuths
|
||||
} from "@app/hooks/api";
|
||||
import { IntegrationAuth } from "@app/hooks/api/types";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
export default function SelectIntegrationAuthPage() {
|
||||
const router = useRouter();
|
||||
@@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() {
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (integrationSlug === "github") {
|
||||
if (!currentIntegration?.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification("githubactions", "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
// for now we only handle Github apps
|
||||
window.location.assign(
|
||||
`https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { GroupPage } from "@app/views/Org/GroupPage";
|
||||
|
||||
export default function Group() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<GroupPage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Group.requireAuth = true;
|
||||
@@ -876,7 +876,7 @@ const OrganizationPage = () => {
|
||||
<Pagination
|
||||
className={
|
||||
projectsViewMode === ProjectsViewMode.GRID
|
||||
? "col-span-full border-transparent bg-transparent"
|
||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||
: "rounded-b-md border border-mineshaft-600"
|
||||
}
|
||||
perPage={perPage}
|
||||
|
||||
+29
-14
@@ -32,13 +32,8 @@ export const queryClient = new QueryClient({
|
||||
{
|
||||
title: "Validation Error",
|
||||
type: "error",
|
||||
text: (
|
||||
<div>
|
||||
<p>Please check the input and try again.</p>
|
||||
<p className="mt-2 text-xs">Request ID: {serverResponse.requestId}</p>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
text: "Please check the input and try again.",
|
||||
callToAction: (
|
||||
<Modal>
|
||||
<ModalTrigger>
|
||||
<Button variant="outline_bg" size="xs">
|
||||
@@ -66,7 +61,14 @@ export const queryClient = new QueryClient({
|
||||
</TableContainer>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
),
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{ closeOnClick: false }
|
||||
);
|
||||
@@ -77,9 +79,8 @@ export const queryClient = new QueryClient({
|
||||
{
|
||||
title: "Forbidden Access",
|
||||
type: "error",
|
||||
|
||||
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`,
|
||||
children: serverResponse?.details?.length ? (
|
||||
text: `${serverResponse.message}.`,
|
||||
callToAction: serverResponse?.details?.length ? (
|
||||
<Modal>
|
||||
<ModalTrigger>
|
||||
<Button variant="outline_bg" size="xs">
|
||||
@@ -92,7 +93,7 @@ export const queryClient = new QueryClient({
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{serverResponse.details?.map((el, index) => {
|
||||
const hasConditions = Object.keys(el.conditions || {}).length;
|
||||
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
|
||||
return (
|
||||
<div
|
||||
key={`Forbidden-error-details-${index + 1}`}
|
||||
@@ -165,7 +166,14 @@ export const queryClient = new QueryClient({
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
) : undefined
|
||||
) : undefined,
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{ closeOnClick: false }
|
||||
);
|
||||
@@ -174,7 +182,14 @@ export const queryClient = new QueryClient({
|
||||
createNotification({
|
||||
title: "Bad Request",
|
||||
type: "error",
|
||||
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`
|
||||
text: `${serverResponse.message}.`,
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import {
|
||||
@@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) =
|
||||
return { encryptedKey: ciphertext, nonce };
|
||||
};
|
||||
|
||||
export const createIntegrationMissingEnvVarsNotification = (
|
||||
slug: string,
|
||||
type: "cloud" | "cicd" = "cloud",
|
||||
hashtag?: string
|
||||
) =>
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: (
|
||||
<a
|
||||
href={`https://infisical.com/docs/integrations/${type}/${slug}${
|
||||
hashtag ? `#${hashtag}` : ""
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Click here to view docs
|
||||
</a>
|
||||
),
|
||||
title: "Missing Environment Variables"
|
||||
});
|
||||
|
||||
export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => {
|
||||
try {
|
||||
// generate CSRF token for OAuth2 code-token exchange integrations
|
||||
@@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`;
|
||||
break;
|
||||
case "azure-key-vault":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "azure-app-configuration":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "aws-parameter-store":
|
||||
@@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/aws-secret-manager/authorize`;
|
||||
break;
|
||||
case "heroku":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
|
||||
break;
|
||||
case "vercel":
|
||||
if (!integrationOption.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
|
||||
break;
|
||||
case "netlify":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
|
||||
break;
|
||||
case "github":
|
||||
@@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/cloudflare-workers/authorize`;
|
||||
break;
|
||||
case "bitbucket":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
|
||||
break;
|
||||
case "codefresh":
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import {
|
||||
@@ -28,11 +30,17 @@ type Props = {
|
||||
}>;
|
||||
};
|
||||
|
||||
enum IntegrationView {
|
||||
List = "list",
|
||||
New = "new"
|
||||
}
|
||||
|
||||
export const IntegrationsPage = withProjectPermission(
|
||||
({ frameworkIntegrations, infrastructureIntegrations }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
|
||||
|
||||
const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
|
||||
useGetCloudIntegrations();
|
||||
@@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission(
|
||||
const {
|
||||
data: integrations,
|
||||
isLoading: isIntegrationLoading,
|
||||
isFetching: isIntegrationFetching
|
||||
isFetching: isIntegrationFetching,
|
||||
isFetched: isIntegrationsFetched
|
||||
} = useGetWorkspaceIntegrations(workspaceId);
|
||||
|
||||
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
|
||||
@@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission(
|
||||
isIntegrationsEmpty
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
|
||||
}, [isIntegrationsFetched]);
|
||||
|
||||
const handleProviderIntegration = async (provider: string) => {
|
||||
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
|
||||
if (!selectedCloudIntegration) return;
|
||||
@@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission(
|
||||
}
|
||||
};
|
||||
|
||||
if (isIntegrationLoading || isCloudIntegrationsLoading)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ContentLoader text={["Loading integrations..."]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl pb-12 text-white">
|
||||
<IntegrationsSection
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<CloudIntegrationSection
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
|
||||
<div className="relative">
|
||||
{view === IntegrationView.List ? (
|
||||
<motion.div
|
||||
key="view-integrations"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<IntegrationsSection
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
onAddIntegration={() => setView(IntegrationView.New)}
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="add-integration"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<CloudIntegrationSection
|
||||
onViewActiveIntegrations={
|
||||
integrations?.length ? () => setView(IntegrationView.List) : undefined
|
||||
}
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Integrations
|
||||
}
|
||||
);
|
||||
|
||||
+65
-16
@@ -1,11 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faChevronLeft,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
@@ -22,6 +35,7 @@ type Props = {
|
||||
onIntegrationStart: (slug: string) => void;
|
||||
// cb: handle popUpClose child->parent communication pattern
|
||||
onIntegrationRevoke: (slug: string, cb: () => void) => void;
|
||||
onViewActiveIntegrations?: () => void;
|
||||
};
|
||||
|
||||
type TRevokeIntegrationPopUp = { provider: string };
|
||||
@@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegrations = [],
|
||||
integrationAuths = {},
|
||||
onIntegrationStart,
|
||||
onIntegrationRevoke
|
||||
onIntegrationRevoke,
|
||||
onViewActiveIntegrations
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({
|
||||
return sortedIntegrations;
|
||||
}, [cloudIntegrations, currentWorkspace?.environments]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) =>
|
||||
cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5">
|
||||
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({
|
||||
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
|
||||
{onViewActiveIntegrations && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onViewActiveIntegrations}
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
>
|
||||
Back to Integrations
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search cloud integrations..."
|
||||
containerClassName="flex-1 h-min text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
|
||||
))}
|
||||
{!isLoading &&
|
||||
sortedCloudIntegrations?.map((cloudIntegration) => (
|
||||
|
||||
{!isLoading && filteredIntegrations.length ? (
|
||||
filteredIntegrations.map((cloudIntegration) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
@@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegration.isAvailable
|
||||
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
|
||||
: "opacity-50"
|
||||
} flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
onClick={() => {
|
||||
if (!cloudIntegration.isAvailable) return;
|
||||
if (
|
||||
@@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({
|
||||
>
|
||||
<img
|
||||
src={`/images/integrations/${cloudIntegration.image}`}
|
||||
height={70}
|
||||
width={70}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
@@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
|
||||
title="No cloud integrations match search..."
|
||||
icon={faSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">
|
||||
|
||||
+22
-33
@@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="mx-6 mt-4 grid grid-flow-dense gap-3"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{sortedFrameworks.map((framework) => (
|
||||
<a
|
||||
key={`framework-integration-${framework.slug}`}
|
||||
href={framework.docsLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 ${
|
||||
framework?.name?.split(" ").length > 1 ? "px-1 text-sm" : "px-2 text-xl"
|
||||
} w-full max-w-xs text-center`}
|
||||
>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={framework?.name ? 60 : 90}
|
||||
width={framework?.name ? 60 : 90}
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && framework?.image && <div className="h-2" />}
|
||||
{framework?.name && framework.name}
|
||||
</div>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && (
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{framework.name}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
@@ -58,13 +53,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/cli/commands/run"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-2 text-white/90" icon={faKeyboard} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
CLI
|
||||
</div>
|
||||
</a>
|
||||
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-1 text-white/90" icon={faComputer} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
SDKs
|
||||
</div>
|
||||
</a>
|
||||
|
||||
-291
@@ -1,291 +0,0 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowRight,
|
||||
faCalendarCheck,
|
||||
faEllipsis,
|
||||
faRefresh,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { integrationSlugNameMapping } from "public/data/frequentConstants";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
};
|
||||
|
||||
export const ConfiguredIntegrationItem = ({
|
||||
integration,
|
||||
environments,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
key={`integration-${integration?.id.toString()}`}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{environments.find((e) => e.id === integration.envId)?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Secret Path" />
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.secretPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex h-full items-center">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel
|
||||
tooltipText={
|
||||
integration.integration === "github" ? (
|
||||
<div className="text-xs">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "Syncing to all private repositories in the organization"
|
||||
: "Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
label="Integration"
|
||||
/>
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integrationSlugNameMapping[integration.integration]}
|
||||
</div>
|
||||
</div>
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Space" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<div className="flex flex-row">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Org" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.owner || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Project" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetService || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Env" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetEnvironment || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
|
||||
{(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
|
||||
`${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") &&
|
||||
`${integration.owner}/${integration.app}`) ||
|
||||
integration.app}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="min-w-[8rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Workspace" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Group" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Organization" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.owner}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Category" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.metadata?.secretSuffix || "-"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex cursor-default space-x-3">
|
||||
{integration.isSynced != null && integration.lastUsed != null && (
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<Tooltip
|
||||
center
|
||||
className="max-w-xs whitespace-normal break-words"
|
||||
content={
|
||||
<div className="flex max-h-[10rem] flex-col overflow-auto ">
|
||||
<div className="flex self-start">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
|
||||
<div className="text-sm">Last successful sync</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">
|
||||
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
{!integration.isSynced && (
|
||||
<>
|
||||
<div className="mt-2 flex self-start">
|
||||
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
|
||||
<div className="text-sm">Fail reason</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full items-center space-x-2">
|
||||
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
|
||||
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-x-1.5">
|
||||
<Tooltip className="text-center" content="Manually sync integration secrets">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<Tooltip content="View details">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+38
-45
@@ -1,13 +1,16 @@
|
||||
import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem";
|
||||
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { IntegrationsTable } from "./components";
|
||||
|
||||
type Props = {
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
isLoading?: boolean;
|
||||
onIntegrationDelete: (
|
||||
integrationId: string,
|
||||
@@ -15,6 +18,7 @@ type Props = {
|
||||
cb: () => void
|
||||
) => Promise<void>;
|
||||
workspaceId: string;
|
||||
onAddIntegration: () => void;
|
||||
};
|
||||
|
||||
export const IntegrationsSection = ({
|
||||
@@ -22,58 +26,47 @@ export const IntegrationsSection = ({
|
||||
environments = [],
|
||||
isLoading,
|
||||
onIntegrationDelete,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
onAddIntegration,
|
||||
cloudIntegrations = []
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteConfirmation",
|
||||
"deleteSecretsConfirmation"
|
||||
] as const);
|
||||
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Current Integrations</h1>
|
||||
<div className="mx-6 mb-8">
|
||||
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Integrations</h1>
|
||||
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="p-6 pt-0">
|
||||
<Skeleton className="h-28" />
|
||||
<div className="w-full 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">Active Integrations</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={onAddIntegration}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !integrations.length && (
|
||||
<div className="mx-6">
|
||||
<EmptyState
|
||||
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
|
||||
title="No integrations found. Click on one of the below providers to sync secrets."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
|
||||
{integrations?.map((integration) => (
|
||||
<ConfiguredIntegrationItem
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
integration={integration}
|
||||
environments={environments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<IntegrationsTable
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrations={integrations}
|
||||
isLoading={isLoading}
|
||||
workspaceId={workspaceId}
|
||||
environments={environments}
|
||||
onDeleteIntegration={(integration) => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteConfirmation.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { FormLabel } from "@app/components/v2";
|
||||
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
type Props = {
|
||||
integration: TIntegration;
|
||||
};
|
||||
|
||||
const FIELD_CLASSNAME =
|
||||
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
|
||||
|
||||
export const getIntegrationDestination = (integration: TIntegration) =>
|
||||
(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||
|
||||
integration.app ||
|
||||
"-";
|
||||
|
||||
export const IntegrationDetails = ({ integration }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div>
|
||||
<FormLabel label="Space" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<>
|
||||
<div>
|
||||
<FormLabel label="Org" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Project" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Env" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div>
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div>
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div>
|
||||
<FormLabel label="Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<FormLabel label="Workspace" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Group" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div>
|
||||
<FormLabel label="Organization" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Category" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") &&
|
||||
integration?.metadata?.secretSuffix && (
|
||||
<div>
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
|
||||
<div className="mt-2 text-xs text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "* Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "* Syncing to all private repositories in the organization"
|
||||
: "* Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faCalendarCheck,
|
||||
faCheck,
|
||||
faInfoCircle,
|
||||
faRefresh,
|
||||
faTrash,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TCloudIntegration } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environment?: { name: string; slug: string; id: string };
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
cloudIntegration: TCloudIntegration;
|
||||
};
|
||||
|
||||
export const IntegrationRow = ({
|
||||
integration,
|
||||
environment,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration,
|
||||
cloudIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { id, secretPath, syncMessage, isSynced } = integration;
|
||||
|
||||
const failureMessage = useMemo(() => {
|
||||
if (isSynced === false) {
|
||||
if (syncMessage)
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(syncMessage), null, 2);
|
||||
} catch (e) {
|
||||
return syncMessage;
|
||||
}
|
||||
|
||||
return "An Unknown Error Occurred.";
|
||||
}
|
||||
return null;
|
||||
}, [isSynced, syncMessage]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
className={twMerge(
|
||||
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
isSynced === false && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`integration-${id}`}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegration?.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegration?.image}`}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
|
||||
<p className="truncate">{secretPath}</p>
|
||||
</Tooltip>{" "}
|
||||
</Td>
|
||||
<Td>{environment?.name ?? "-"}</Td>
|
||||
<Td className="!min-w-[5rem] max-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate">{getIntegrationDestination(integration)}</p>
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="min-w-[20rem] max-w-lg"
|
||||
content={<IntegrationDetails integration={integration} />}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{" "}
|
||||
{typeof integration.isSynced !== "boolean" ? (
|
||||
<Badge variant="primary" key={integration.id}>
|
||||
Pending Sync
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="max-w-sm"
|
||||
content={
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{integration.lastUsed && (
|
||||
<div>
|
||||
<div
|
||||
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCalendarCheck}
|
||||
className="ml-1 pt-0.5 pr-1.5 text-sm"
|
||||
/>
|
||||
<div className="text-xs">Last Synced</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">
|
||||
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failureMessage && (
|
||||
<div>
|
||||
<div className="mb-2 flex self-start text-red">
|
||||
<FontAwesomeIcon icon={faXmark} className="ml-1 pt-0.5 pr-1.5 text-sm" />
|
||||
<div className="text-xs">Failure Reason</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="w-min whitespace-nowrap">
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
|
||||
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex gap-2 whitespace-nowrap">
|
||||
<Tooltip className="max-w-sm text-center" content="Manually Sync">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheck,
|
||||
faClock,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlug,
|
||||
faSearch,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
import { getIntegrationDestination } from "./IntegrationDetails";
|
||||
import { IntegrationRow } from "./IntegrationRow";
|
||||
|
||||
type Props = {
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
workspaceId: string;
|
||||
isLoading?: boolean;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onDeleteIntegration: (integration: TIntegration) => void;
|
||||
};
|
||||
|
||||
enum IntegrationsOrderBy {
|
||||
App = "app",
|
||||
Status = "status",
|
||||
SecretPath = "secretPath",
|
||||
Environment = "environment",
|
||||
Destination = "destination"
|
||||
}
|
||||
|
||||
enum IntegrationStatus {
|
||||
Synced = "synced",
|
||||
NotSynced = "not-synced",
|
||||
PendingSync = "pending-sync"
|
||||
}
|
||||
|
||||
type IntegrationFilters = {
|
||||
environmentIds: string[];
|
||||
integrations: string[];
|
||||
status: IntegrationStatus[];
|
||||
};
|
||||
|
||||
const STATUS_ICON_MAP = {
|
||||
[IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" },
|
||||
[IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" },
|
||||
[IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" }
|
||||
};
|
||||
|
||||
export const IntegrationsTable = ({
|
||||
integrations = [],
|
||||
cloudIntegrations = [],
|
||||
workspaceId,
|
||||
environments,
|
||||
onDeleteIntegration,
|
||||
isLoading
|
||||
}: Props) => {
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
|
||||
const initialFilters = useMemo(
|
||||
() => ({
|
||||
environmentIds: environments.map((env) => env.id),
|
||||
integrations: [...new Set(integrations.map(({ integration }) => integration))],
|
||||
status: Object.values(IntegrationStatus)
|
||||
}),
|
||||
[environments, integrations]
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
|
||||
|
||||
const cloudIntegrationMap = useMemo(() => {
|
||||
return new Map(
|
||||
cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration])
|
||||
);
|
||||
}, [cloudIntegrations]);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
|
||||
|
||||
useEffect(() => {
|
||||
if (integrations?.some((integration) => integration.isSynced === false))
|
||||
setOrderBy(IntegrationsOrderBy.Status);
|
||||
}, []);
|
||||
|
||||
const environmentMap = new Map(environments.map((env) => [env.id, env]));
|
||||
|
||||
const filteredIntegrations = useMemo(
|
||||
() =>
|
||||
integrations
|
||||
.filter((integration) => {
|
||||
const { secretPath, envId, isSynced } = integration;
|
||||
|
||||
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
|
||||
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
|
||||
return false;
|
||||
if (
|
||||
!filters.status.includes(IntegrationStatus.PendingSync) &&
|
||||
typeof isSynced !== "boolean"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (!filters.integrations.includes(integration.integration)) return false;
|
||||
|
||||
if (!filters.environmentIds.includes(envId)) return false;
|
||||
|
||||
return (
|
||||
integration.integration
|
||||
.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) ||
|
||||
getIntegrationDestination(integration)
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
environmentMap
|
||||
.get(envId)
|
||||
?.name.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [integrationOne, integrationTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case IntegrationsOrderBy.SecretPath:
|
||||
return integrationOne.secretPath
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.secretPath.toLowerCase());
|
||||
case IntegrationsOrderBy.Environment:
|
||||
return (environmentMap.get(integrationOne.envId)?.name ?? "-")
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
(environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase()
|
||||
);
|
||||
case IntegrationsOrderBy.Destination:
|
||||
return getIntegrationDestination(integrationOne)
|
||||
.toLowerCase()
|
||||
.localeCompare(getIntegrationDestination(integrationTwo).toLowerCase());
|
||||
case IntegrationsOrderBy.Status:
|
||||
if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end
|
||||
if (typeof integrationTwo.isSynced !== "boolean") return -1;
|
||||
|
||||
return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced);
|
||||
case IntegrationsOrderBy.App:
|
||||
default:
|
||||
return integrationOne.integration
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.integration.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[integrations, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredIntegrations.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: IntegrationsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: IntegrationsOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: IntegrationsOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered =
|
||||
filters.integrations.length !== initialFilters.integrations.length ||
|
||||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
|
||||
filters.status.length !== initialFilters.status.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search integrations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Environments"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<Tooltip content="Filter Integrations" className="mb-2">
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{Object.values(IntegrationStatus).map((status) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((s) => s !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
}}
|
||||
key={status}
|
||||
icon={
|
||||
filters.status.includes(status) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={STATUS_ICON_MAP[status].icon}
|
||||
className={STATUS_ICON_MAP[status].className}
|
||||
/>
|
||||
<span className="capitalize">{status.replace("-", " ")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Integration</DropdownMenuLabel>
|
||||
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
integrations: prev.integrations.includes(integration)
|
||||
? prev.integrations.filter((i) => i !== integration)
|
||||
: [...prev.integrations, integration]
|
||||
}));
|
||||
}}
|
||||
key={integration}
|
||||
icon={
|
||||
filters.integrations.includes(integration) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Environment</DropdownMenuLabel>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
environmentIds: prev.environmentIds.includes(env.id)
|
||||
? prev.environmentIds.filter((i) => i !== env.id)
|
||||
: [...prev.environmentIds, env.id]
|
||||
}));
|
||||
}}
|
||||
key={env.id}
|
||||
icon={
|
||||
filters.environmentIds.includes(env.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<span className="capitalize">{env.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-[25%]">
|
||||
<div className="flex items-center">
|
||||
Integration
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.App)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.App)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Path
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.SecretPath)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Environment
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Environment)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Destination
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Destination)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Status)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Status)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
|
||||
<IntegrationRow
|
||||
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => onDeleteIntegration(integration)}
|
||||
integration={integration}
|
||||
environment={environmentMap.get(integration.envId)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredIntegrations.length) && (
|
||||
<Pagination
|
||||
count={filteredIntegrations.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredIntegrations?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
integrations.length
|
||||
? "No integrations match search..."
|
||||
: "This project has no integrations configured"
|
||||
}
|
||||
icon={integrations.length ? faSearch : faPlug}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./IntegrationsTable";
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { useGetGroupById } from "@app/hooks/api/groups/queries";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { TabSections } from "@app/views/Org/Types";
|
||||
|
||||
import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal";
|
||||
import { GroupMembersSection } from "./components/GroupMembersSection";
|
||||
import { GroupDetailsSection } from "./components";
|
||||
|
||||
export const GroupPage = withPermission(
|
||||
() => {
|
||||
const router = useRouter();
|
||||
const groupId = router.query.groupId as string;
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data, isLoading } = useGetGroupById(groupId);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteGroup();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"groupCreateUpdate",
|
||||
"deleteGroup",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
|
||||
const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
id
|
||||
});
|
||||
createNotification({
|
||||
text: `Successfully deleted the ${name} group`,
|
||||
type: "success"
|
||||
});
|
||||
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to delete the ${name} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
|
||||
handlePopUpClose("deleteGroup");
|
||||
};
|
||||
|
||||
if (isLoading) return <Spinner size="sm" className="mt-2 ml-2" />;
|
||||
|
||||
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 py-6 px-6">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
router.push(`/org/${orgId}/members?selectedTab=${TabSections.Groups}`);
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">{data.group.name}</p>
|
||||
<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">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("groupCreateUpdate", {
|
||||
groupId,
|
||||
name: data.group.name,
|
||||
slug: data.group.slug,
|
||||
role: data.group.role
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Edit Group
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
id: groupId,
|
||||
name: data.group.name
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Group
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<GroupDetailsSection groupId={groupId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<GroupMembersSection groupId={groupId} groupSlug={data.group.slug} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<GroupCreateUpdateModal
|
||||
popUp={popUp}
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to delete the group named ${
|
||||
(popUp?.deleteGroup?.data as { name: string })?.name || ""
|
||||
}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string })
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Groups }
|
||||
);
|
||||
+32
-33
@@ -22,21 +22,22 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useDebounce, useResetPageHelper } from "@app/hooks";
|
||||
import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["groupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
|
||||
popUp: UsePopUpState<["addGroupMembers"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchMemberFilter);
|
||||
|
||||
const popUpData = popUp?.groupMembers?.data as {
|
||||
const popUpData = popUp?.addGroupMembers?.data as {
|
||||
groupId: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -47,7 +48,8 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
groupSlug: popUpData?.slug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search: debouncedSearch
|
||||
search: debouncedSearch,
|
||||
filter: EFilterReturnedUsers.NON_MEMBERS
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
@@ -58,36 +60,31 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
|
||||
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
|
||||
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
|
||||
|
||||
const handleAssignment = async (username: string, assign: boolean) => {
|
||||
const handleAddMember = async (username: string) => {
|
||||
try {
|
||||
if (!popUpData?.slug) return;
|
||||
|
||||
if (assign) {
|
||||
await assignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
} else {
|
||||
await unassignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
if (!popUpData?.slug) {
|
||||
createNotification({
|
||||
text: "Some data is missing, please refresh the page and try again",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await addUserToGroupMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${assign ? "assigned" : "removed"} user ${
|
||||
assign ? "to" : "from"
|
||||
} group`,
|
||||
text: "Successfully assigned user to the group",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`,
|
||||
text: "Failed to assign user to the group",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
@@ -95,12 +92,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.groupMembers?.isOpen}
|
||||
isOpen={popUp?.addGroupMembers?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("groupMembers", isOpen);
|
||||
handlePopUpToggle("addGroupMembers", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Manage Group Members">
|
||||
<ModalContent title="Add Group Members">
|
||||
<Input
|
||||
value={searchMemberFilter}
|
||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||
@@ -118,7 +115,7 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
|
||||
{!isLoading &&
|
||||
data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => {
|
||||
data?.users?.map(({ id, firstName, lastName, username }) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
@@ -138,9 +135,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
type="submit"
|
||||
onClick={() => handleAssignment(username, !isPartOfGroup)}
|
||||
onClick={() => handleAddMember(username)}
|
||||
>
|
||||
{isPartOfGroup ? "Unassign" : "Assign"}
|
||||
Assign
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
@@ -162,7 +159,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
)}
|
||||
{!isLoading && !data?.users?.length && (
|
||||
<EmptyState
|
||||
title={debouncedSearch ? "No users match search" : "No users found"}
|
||||
title={
|
||||
debouncedSearch ? "No users match search" : "All users are already in the group"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
@@ -0,0 +1,192 @@
|
||||
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,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const GroupFormSchema = z.object({
|
||||
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
|
||||
slug: z
|
||||
.string()
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["groupCreateUpdate"]>;
|
||||
handlePopUpClose: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["groupCreateUpdate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const GroupCreateUpdateModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { data: roles } = useGetOrgRoles(currentOrg?.id || "");
|
||||
const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup();
|
||||
const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<TGroupFormData>({
|
||||
resolver: zodResolver(GroupFormSchema)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const group = popUp?.groupCreateUpdate?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
role: string;
|
||||
customRole: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (!roles?.length) return;
|
||||
|
||||
if (group) {
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.groupCreateUpdate?.data, roles]);
|
||||
|
||||
const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const group = popUp?.groupCreateUpdate?.data as {
|
||||
groupId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
if (group) {
|
||||
await updateMutateAsync({
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("groupCreateUpdate", false);
|
||||
reset();
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to ${popUp?.groupCreateUpdate?.data ? "updated" : "created"} group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.groupCreateUpdate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("groupCreateUpdate", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.groupCreateUpdate?.data ? "Update" : "Create"} Group`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="Engineering" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Slug" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input {...field} placeholder="engineering" />
|
||||
</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
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={createIsLoading || updateIsLoading}
|
||||
>
|
||||
{!popUp?.groupCreateUpdate?.data ? "Create" : "Update"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpClose("groupCreateUpdate")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { faPencil } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Spinner, Tooltip } from "@app/components/v2";
|
||||
import { CopyButton } from "@app/components/v2/CopyButton";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useGetGroupById } from "@app/hooks/api/";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["groupCreateUpdate"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
export const GroupDetailsSection = ({ groupId, handlePopUpOpen }: Props) => {
|
||||
const { data, isLoading } = useGetGroupById(groupId);
|
||||
|
||||
if (isLoading) return <Spinner size="sm" className="mt-2 ml-2" />;
|
||||
|
||||
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">Group Details</h3>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Edit Group">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="edit group button"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("groupCreateUpdate", {
|
||||
groupId,
|
||||
name: data.group.name,
|
||||
slug: data.group.slug,
|
||||
role: data.group.role
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Group ID</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{data.group.id}</p>
|
||||
<CopyButton value={data.group.id} name="Group ID" size="xs" variant="plain" />
|
||||
</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.group.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
|
||||
<div className="group flex items-center gap-2">
|
||||
<p className="text-sm text-mineshaft-300">{data.group.slug}</p>
|
||||
<CopyButton value={data.group.slug} name="Slug" size="xs" variant="plain" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Organization Role</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.group.role}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Created At</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{new Date(data.group.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<p className="text-mineshaft-300">Group data not found</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, IconButton } from "@app/components/v2";
|
||||
import { useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { AddGroupMembersModal } from "../AddGroupMemberModal";
|
||||
import { GroupMembersTable } from "./GroupMembersTable";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
};
|
||||
|
||||
export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"addGroupMembers",
|
||||
"removeMemberFromGroup"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup();
|
||||
const handleRemoveUserFromGroup = async (username: string) => {
|
||||
try {
|
||||
await removeUserFromGroupMutateAsync({
|
||||
groupId,
|
||||
username,
|
||||
slug: groupSlug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully removed user ${username} from the group`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("removeMemberFromGroup", false);
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
text: `Failed to remove user ${username} from the group`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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">Group Members</h3>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<GroupMembersTable
|
||||
groupId={groupId}
|
||||
groupSlug={groupSlug}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
</div>
|
||||
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.removeMemberFromGroup.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
|
||||
} from the group?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => {
|
||||
const userData = popUp?.removeMemberFromGroup?.data as {
|
||||
username: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
return handleRemoveUserFromGroup(userData.username);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolder,
|
||||
faMagnifyingGlass,
|
||||
faSearch
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useListGroupUsers } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { GroupMembershipRow } from "./GroupMembershipRow";
|
||||
|
||||
type Props = {
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["removeMemberFromGroup", "addGroupMembers"]>,
|
||||
data?: {}
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum GroupMembersOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(GroupMembersOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const { data: groupMemberships, isLoading } = useListGroupUsers({
|
||||
id: groupId,
|
||||
groupSlug,
|
||||
offset,
|
||||
limit: perPage,
|
||||
search,
|
||||
filter: EFilterReturnedUsers.EXISTING_MEMBERS
|
||||
});
|
||||
|
||||
const filteredGroupMemberships = useMemo(() => {
|
||||
return groupMemberships && groupMemberships?.users
|
||||
? groupMemberships?.users
|
||||
?.filter((membership) => {
|
||||
const userSearchString = `${membership.firstName && membership.firstName} ${
|
||||
membership.lastName && membership.lastName
|
||||
} ${membership.email && membership.email} ${
|
||||
membership.username && membership.username
|
||||
}`;
|
||||
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
const membershipOneComparisonString = membershipOne.firstName
|
||||
? membershipOne.firstName
|
||||
: membershipOne.email;
|
||||
|
||||
const membershipTwoComparisonString = membershipTwo.firstName
|
||||
? membershipTwo.firstName
|
||||
: membershipTwo.email;
|
||||
|
||||
const comparison = membershipOneComparisonString
|
||||
.toLowerCase()
|
||||
.localeCompare(membershipTwoComparisonString.toLowerCase());
|
||||
|
||||
return comparison;
|
||||
})
|
||||
: [];
|
||||
}, [groupMemberships, orderDirection, search]);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships?.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<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>Email</Th>
|
||||
<Th>Added On</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
|
||||
{!isLoading &&
|
||||
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
|
||||
return (
|
||||
<GroupMembershipRow
|
||||
key={`user-group-membership-${userGroupMembership.id}`}
|
||||
user={userGroupMembership}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships?.users.length
|
||||
? "No users match this search..."
|
||||
: "This group does not have any members yet"
|
||||
}
|
||||
icon={groupMemberships?.users.length ? faSearch : faFolder}
|
||||
/>
|
||||
)}
|
||||
{!groupMemberships?.users.length && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => (
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addGroupMembers", {
|
||||
groupId,
|
||||
slug: groupSlug
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add members
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { faUserMinus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { TGroupUser } from "@app/hooks/api/groups/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
user: TGroupUser;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
export const GroupMembershipRow = ({
|
||||
user: { firstName, lastName, username, joinedGroupAt, email, id },
|
||||
handlePopUpOpen
|
||||
}: Props) => {
|
||||
return (
|
||||
<Tr className="items-center" key={`group-user-${id}`}>
|
||||
<Td>
|
||||
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<p>{email}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
|
||||
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td className="justify-end">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Groups}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Remove user from group">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="Remove user from group"
|
||||
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserMinus} className="cursor-pointer" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupMembersSection } from "./GroupMembersSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupDetailsSection } from "./GroupDetailsSection";
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupPage } from "./GroupPage";
|
||||
+21
-23
@@ -6,14 +6,14 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
|
||||
.string()
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.string()
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole?.slug ?? group.role
|
||||
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.group?.data, roles]);
|
||||
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("group", false);
|
||||
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`org-group-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
-2
@@ -8,7 +8,6 @@ import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@a
|
||||
import { useDeleteGroup } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { OrgGroupMembersModal } from "./OrgGroupMembersModal";
|
||||
import { OrgGroupModal } from "./OrgGroupModal";
|
||||
import { OrgGroupsTable } from "./OrgGroupsTable";
|
||||
|
||||
@@ -78,7 +77,6 @@ export const OrgGroupsSection = () => {
|
||||
handlePopUpClose={handlePopUpClose}
|
||||
handlePopUpToggle={handlePopUpToggle}
|
||||
/>
|
||||
<OrgGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteGroup.isOpen}
|
||||
title={`Are you sure want to delete the group named ${
|
||||
|
||||
+25
-25
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
@@ -61,6 +62,7 @@ enum GroupsOrderBy {
|
||||
}
|
||||
|
||||
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const router = useRouter();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
|
||||
@@ -223,7 +225,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
.slice(offset, perPage * page)
|
||||
.map(({ id, name, slug, role, customRole }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`org-group-${id}`}>
|
||||
<Tr
|
||||
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
|
||||
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
|
||||
key={`org-group-${id}`}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
@@ -277,30 +283,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("groupMembers", {
|
||||
groupId: id,
|
||||
slug
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Manage Users
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
@@ -324,6 +307,23 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={() => router.push(`/org/${orgId}/groups/${id}`)}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Manage Members
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Groups}
|
||||
|
||||
+23
-28
@@ -9,27 +9,24 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import {
|
||||
// IdentityAuthMethod,
|
||||
useAddIdentityUniversalAuth
|
||||
} from "@app/hooks/api/identities";
|
||||
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
name: z.string().min(1, "Required"),
|
||||
role: z.object({ slug: z.string(), name: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
if (identity) {
|
||||
reset({
|
||||
name: identity.name,
|
||||
role: identity?.customRole?.slug ?? identity.role,
|
||||
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
|
||||
metadata: identity.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.identity?.data, roles]);
|
||||
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
await updateMutateAsync({
|
||||
identityId: identity.identityId,
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
const { id: createdId } = await createMutateAsync({
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+18
-22
@@ -15,7 +15,7 @@ import {
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
@@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({
|
||||
)
|
||||
.default([]),
|
||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
|
||||
organizationRole: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({
|
||||
useEffect(() => {
|
||||
if (organizationRoles) {
|
||||
reset({
|
||||
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
|
||||
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
|
||||
: currentOrg?.defaultMembershipRole
|
||||
organizationRole: findOrgMembershipRole(
|
||||
organizationRoles,
|
||||
currentOrg?.defaultMembershipRole!
|
||||
)
|
||||
});
|
||||
}
|
||||
}, [organizationRoles]);
|
||||
|
||||
const onAddMembers = async ({
|
||||
emails,
|
||||
organizationRoleSlug,
|
||||
organizationRole,
|
||||
projects: selectedProjects,
|
||||
projectRoleSlug
|
||||
}: TAddMemberForm) => {
|
||||
@@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({
|
||||
const { data } = await addUsersMutateAsync({
|
||||
organizationId: currentOrg?.id,
|
||||
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||
organizationRoleSlug,
|
||||
organizationRoleSlug: organizationRole.slug,
|
||||
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
|
||||
});
|
||||
|
||||
@@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="organizationRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
name="organizationRole"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which organization role you want to assign to the user."
|
||||
label="Assign organization role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
className="w-full"
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{organizationRoles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.slug}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={organizationRoles}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -296,7 +296,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
status,
|
||||
isActive
|
||||
}) => {
|
||||
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const name =
|
||||
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
const username = u?.username ?? inviteEmail ?? "-";
|
||||
return (
|
||||
|
||||
@@ -9,12 +9,13 @@ import { Button, FormControl, Input, Modal, ModalContent } from "@app/components
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } 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: z.string()
|
||||
slug: slugSchema({ min: 1 })
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export const UserPage = withPermission(
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName}`
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
{userId !== membership.user.id && (
|
||||
@@ -148,7 +148,8 @@ export const UserPage = withPermission(
|
||||
onClick={() =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role
|
||||
role: membership.role,
|
||||
roleId: membership.roleId
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
|
||||
@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role,
|
||||
roleId: membership.roleId,
|
||||
metadata: membership.metadata
|
||||
});
|
||||
}}
|
||||
@@ -117,7 +118,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName}`
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -8,21 +9,21 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
role: z.string(),
|
||||
role: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const { data: roles = [] } = useGetOrgRoles(orgId);
|
||||
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const popUpData = popUp?.orgMembership?.data as {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
roleId?: string;
|
||||
metadata: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
|
||||
if (popUpData) {
|
||||
reset({
|
||||
role: popUpData.role,
|
||||
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
|
||||
metadata: popUpData.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
|
||||
});
|
||||
}
|
||||
}, [popUp?.orgMembership?.data, roles]);
|
||||
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId: popUpData.membershipId,
|
||||
role,
|
||||
role: role.slug,
|
||||
metadata
|
||||
});
|
||||
|
||||
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Update Membership">
|
||||
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Update Organization Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(e);
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={(newValue) => {
|
||||
const role = newValue as SingleValue<(typeof roles)[number]>;
|
||||
|
||||
if (!role) return;
|
||||
|
||||
const isCustomRole = isCustomOrgRole(role.slug);
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(e);
|
||||
onChange(role);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+121
-25
@@ -1,6 +1,27 @@
|
||||
import { faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
@@ -12,31 +33,106 @@ type Props = {
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
enum UserGroupsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username);
|
||||
const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships(
|
||||
orgMembership.user.username
|
||||
);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const filteredGroupMemberships = useMemo(
|
||||
() =>
|
||||
groupMemberships
|
||||
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
|
||||
}),
|
||||
[groupMemberships, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{groups?.map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && !groups?.length && (
|
||||
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-full">
|
||||
<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 className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships.length
|
||||
? "No groups match search..."
|
||||
: "This user has not been assigned to any groups"
|
||||
}
|
||||
icon={groupMemberships.length ? faSearch : faUser}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
@@ -94,7 +95,9 @@ export const IdentityDetailsPage = withProjectPermission(
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails?.identity?.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove from project"
|
||||
>
|
||||
|
||||
+71
-30
@@ -1,4 +1,5 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCaretDown,
|
||||
faChevronLeft,
|
||||
@@ -17,12 +18,13 @@ import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@@ -35,7 +37,6 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useGetIdentityProjectPrivilegeDetails,
|
||||
@@ -43,10 +44,10 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
|
||||
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
|
||||
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
@@ -88,7 +89,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
}: Props) => {
|
||||
const isCreate = !privilegeId;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: privilegeDetails, isLoading } = useGetIdentityProjectPrivilegeDetails({
|
||||
identityId,
|
||||
@@ -98,7 +98,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
const { permission } = useProjectPermission();
|
||||
const isIdentityEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
@@ -194,6 +194,30 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
}
|
||||
}
|
||||
|
||||
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)}
|
||||
@@ -232,24 +256,39 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={popUp.createPolicy.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
New Policy
|
||||
New policy
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
|
||||
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</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>
|
||||
@@ -376,17 +415,19 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-lg">Policies</div>
|
||||
{(isCreate || !isLoading) && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
{(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>
|
||||
|
||||
+10
-3
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -83,7 +84,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
|
||||
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
|
||||
isDisabled={permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -103,7 +106,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
|
||||
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Add Privilege"
|
||||
>
|
||||
@@ -192,7 +197,9 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
|
||||
<div className="flex space-x-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
|
||||
+7
-2
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -93,7 +94,9 @@ export const IdentityRoleDetailsSection = ({
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Project Roles</h3>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails.identity.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Edit Role(s)"
|
||||
>
|
||||
@@ -175,7 +178,9 @@ export const IdentityRoleDetailsSection = ({
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.Member}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails.identity.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove Role"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -17,16 +16,10 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.toLowerCase()
|
||||
.max(32)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Name must be in slug format"
|
||||
}),
|
||||
name: slugSchema({ min: 1, max: 32, field: "Name" }),
|
||||
description: z.string().max(500).optional(),
|
||||
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
|
||||
});
|
||||
|
||||
+57
-20
@@ -17,12 +17,13 @@ import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateProjectUserAdditionalPrivilege,
|
||||
useGetProjectUserPrivilegeDetails,
|
||||
@@ -43,14 +43,13 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types";
|
||||
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
|
||||
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
rolePermission2Form} from "@app/views/Project/RolePage/components/RolePermissionsSection/ProjectRoleModifySection.utils";
|
||||
import { renderConditionalComponents } from "@app/views/Project/RolePage/components/RolePermissionsSection/RolePermissionsSection";
|
||||
|
||||
type Props = {
|
||||
@@ -88,7 +87,6 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
|
||||
}: Props) => {
|
||||
const isCreate = !privilegeId;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: privilegeDetails, isLoading } = useGetProjectUserPrivilegeDetails(
|
||||
privilegeId || ""
|
||||
@@ -167,6 +165,30 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 =
|
||||
@@ -229,24 +251,39 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={popUp.createPolicy.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
New Policy
|
||||
New policy
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
|
||||
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</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>
|
||||
|
||||
+83
-92
@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddGroupToWorkspace,
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
role: z.string()
|
||||
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>;
|
||||
@@ -27,7 +27,9 @@ type Props = {
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
// TODO: update backend to support adding multiple roles at once
|
||||
|
||||
const Content = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
@@ -59,12 +61,12 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
resolver: zodResolver(schema)
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ id, role }: FormData) => {
|
||||
const onFormSubmit = async ({ group, role }: FormData) => {
|
||||
try {
|
||||
await addGroupToWorkspaceMutateAsync({
|
||||
projectId: currentWorkspace?.id || "",
|
||||
groupId: id,
|
||||
role: role || undefined
|
||||
groupId: group.id,
|
||||
role: role.slug || undefined
|
||||
});
|
||||
|
||||
reset();
|
||||
@@ -82,95 +84,84 @@ export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
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 href={`/org/${currentWorkspace?.orgId}/members`}>
|
||||
<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);
|
||||
reset();
|
||||
}}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("group", isOpen)}
|
||||
>
|
||||
<ModalContent title="Add Group to Project">
|
||||
{filteredGroupMembershipOrgs.length ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="id"
|
||||
defaultValue={filteredGroupMembershipOrgs?.[0]?.id}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Group" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-600"
|
||||
placeholder="Select group..."
|
||||
>
|
||||
{filteredGroupMembershipOrgs.map(({ name, id }) => (
|
||||
<SelectItem value={id} key={`org-group-${id}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
placeholder="Select role..."
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.group?.data ? "Update" : "Create"}
|
||||
</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 href={`/org/${currentWorkspace?.orgId}/members`}>
|
||||
<Button variant="outline_bg">Create a new group</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<ModalContent bodyClassName="overflow-visible" title="Add Group to Project">
|
||||
<Content popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
@@ -349,7 +350,9 @@ export const IdentityTab = withProjectPermission(
|
||||
<Td className="flex justify-end space-x-2 opacity-0 duration-300 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: id
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
|
||||
-354
@@ -1,354 +0,0 @@
|
||||
/* 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, 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 = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { subscription } = useSubscription();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
|
||||
const { permission } = useProjectPermission();
|
||||
const isMemberEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
);
|
||||
|
||||
const roleForm = useForm<TRoleForm>({
|
||||
resolver: zodResolver(roleFormSchema),
|
||||
values: {
|
||||
roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
|
||||
slug: customRoleSlug || role,
|
||||
temporaryAccess: dto.isTemporary
|
||||
? {
|
||||
isTemporary: true,
|
||||
temporaryRange: dto.temporaryRange,
|
||||
temporaryAccessEndTime: dto.temporaryAccessEndTime,
|
||||
temporaryAccessStartTime: dto.temporaryAccessStartTime
|
||||
}
|
||||
: {
|
||||
isTemporary: dto.isTemporary
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
const selectedRoleList = useFieldArray({
|
||||
name: "roles",
|
||||
control: roleForm.control
|
||||
});
|
||||
|
||||
const formRoleField = roleForm.watch("roles");
|
||||
|
||||
const updateMembershipRole = useUpdateIdentityWorkspaceRole();
|
||||
|
||||
const handleRoleUpdate = async (data: TRoleForm) => {
|
||||
if (updateMembershipRole.isLoading) return;
|
||||
|
||||
const sanitizedRoles = data.roles.map((el) => {
|
||||
const { isTemporary } = el.temporaryAccess;
|
||||
if (!isTemporary) {
|
||||
return { role: el.slug, isTemporary: false as const };
|
||||
}
|
||||
return {
|
||||
role: el.slug,
|
||||
isTemporary: true as const,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: el.temporaryAccess.temporaryRange,
|
||||
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
|
||||
};
|
||||
});
|
||||
|
||||
const hasCustomRoleSelected = sanitizedRoles.some(
|
||||
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
|
||||
);
|
||||
|
||||
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
|
||||
onOpenUpgradeModal(
|
||||
"You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMembershipRole.mutateAsync({
|
||||
workspaceId,
|
||||
identityId: identityProjectMember.identity.id,
|
||||
roles: sanitizedRoles
|
||||
});
|
||||
createNotification({ text: "Successfully updated roles", type: "success" });
|
||||
roleForm.reset(undefined, { keepValues: true });
|
||||
} catch (err) {
|
||||
createNotification({ text: "Failed to update role", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (isRolesLoading)
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-lg font-medium">Roles</div>
|
||||
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
|
||||
<div>
|
||||
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{selectedRoleList.fields.map(({ id }, index) => {
|
||||
const { temporaryAccess } = formRoleField[index];
|
||||
const isTemporary = temporaryAccess?.isTemporary;
|
||||
const isExpired =
|
||||
temporaryAccess.isTemporary &&
|
||||
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
|
||||
|
||||
return (
|
||||
<div key={id} className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={roleForm.control}
|
||||
name={`roles.${index}.slug`}
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
isDisabled={isMemberEditDisabled}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full bg-mineshaft-600"
|
||||
>
|
||||
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
|
||||
<SelectItem value={slug} key={projectRoleId}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger disabled={isMemberEditDisabled}>
|
||||
<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.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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Alert, AlertDescription } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
|
||||
import { IdentityRbacSection } from "./IdentityRbacSection";
|
||||
|
||||
type Props = {
|
||||
identityProjectMember: IdentityMembership;
|
||||
onOpenUpgradeModal: (title: string) => void;
|
||||
};
|
||||
export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IdentityRbacSection
|
||||
identityProjectMember={identityProjectMember}
|
||||
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
|
||||
href={`/project/${currentWorkspace?.id || ""}/identities/${
|
||||
identityProjectMember?.identity?.id
|
||||
}`}
|
||||
>
|
||||
<span className="cursor-pointer text-primary underline underline-offset-2">
|
||||
Click here to access them now
|
||||
</span>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { IdentityRoleForm } from "./IdentityRoleForm";
|
||||
+16
-84
@@ -2,24 +2,11 @@ import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { faCheckCircle, faChevronDown } 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,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
@@ -33,7 +20,7 @@ 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.string().trim().min(1)).min(1)
|
||||
projectRoleSlugs: z.array(z.object({ slug: z.string().trim(), name: z.string().trim() })).min(1)
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -64,7 +51,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TAddMemberForm>({
|
||||
resolver: zodResolver(addMemberFormSchema),
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [ProjectMembershipRole.Member] }
|
||||
defaultValues: { orgMemberships: [], projectRoleSlugs: [] }
|
||||
});
|
||||
|
||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||
@@ -94,7 +81,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
{
|
||||
slug: currentWorkspace.slug,
|
||||
id: currentWorkspace.id,
|
||||
projectRoleSlug: projectRoleSlugs
|
||||
projectRoleSlug: projectRoleSlugs.map((role) => role.slug)
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -172,78 +159,23 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectRoleSlugs"
|
||||
render={({ field }) => (
|
||||
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)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{roles && roles.length > 0 ? (
|
||||
<div className="inline-flex w-full cursor-pointer items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{selectedRoleSlugs.length === 1
|
||||
? roles.find((role) => role.slug === selectedRoleSlugs[0])?.name
|
||||
: selectedRoleSlugs.length === 0
|
||||
? "Select at least one role"
|
||||
: `${selectedRoleSlugs.length} roles selected`}
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
className={twMerge("ml-2 text-xs")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex w-full cursor-default items-center justify-between rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-mineshaft-200">
|
||||
No roles found
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="thin-scrollbar z-[100] max-h-80"
|
||||
>
|
||||
{roles && roles.length > 0 ? (
|
||||
roles.map((role) => {
|
||||
const isSelected = selectedRoleSlugs.includes(role.slug);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={(event) => roles.length > 1 && event.preventDefault()}
|
||||
onClick={() => {
|
||||
if (selectedRoleSlugs.includes(String(role.slug))) {
|
||||
field.onChange(
|
||||
selectedRoleSlugs.filter(
|
||||
(roleSlug: string) => roleSlug !== String(role.slug)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
field.onChange([...selectedRoleSlugs, role.slug]);
|
||||
}
|
||||
}}
|
||||
key={`role-slug-${role.slug}`}
|
||||
icon={
|
||||
isSelected ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faCheckCircle}
|
||||
className="pr-0.5 text-primary"
|
||||
/>
|
||||
) : (
|
||||
<div className="pl-[1.01rem]" />
|
||||
)
|
||||
}
|
||||
iconPos="left"
|
||||
className="w-[28.4rem] text-sm"
|
||||
>
|
||||
{role.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select roles..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
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: z.string()
|
||||
slug: slugSchema({ min: 1 })
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
+16
-9
@@ -105,9 +105,14 @@ export const projectRoleFormSchema = z.object({
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.extend({
|
||||
inverted: z.boolean().optional(),
|
||||
conditions: ConditionSchema
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
[ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Role]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Integrations]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Webhooks]: GeneralPolicyActionSchema.array().default([]),
|
||||
@@ -144,7 +149,8 @@ type TConditionalFields =
|
||||
| ProjectPermissionSub.Secrets
|
||||
| ProjectPermissionSub.SecretFolders
|
||||
| ProjectPermissionSub.SecretImports
|
||||
| ProjectPermissionSub.DynamicSecrets;
|
||||
| ProjectPermissionSub.DynamicSecrets
|
||||
| ProjectPermissionSub.Identity;
|
||||
|
||||
export const isConditionalSubjects = (
|
||||
subject: ProjectPermissionSub
|
||||
@@ -152,7 +158,8 @@ export const isConditionalSubjects = (
|
||||
subject === (ProjectPermissionSub.Secrets as const) ||
|
||||
subject === ProjectPermissionSub.DynamicSecrets ||
|
||||
subject === ProjectPermissionSub.SecretImports ||
|
||||
subject === ProjectPermissionSub.SecretFolders;
|
||||
subject === ProjectPermissionSub.SecretFolders ||
|
||||
subject === ProjectPermissionSub.Identity;
|
||||
|
||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||
const formConditions: z.infer<typeof ConditionSchema> = [];
|
||||
@@ -491,17 +498,17 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
{ label: "Remove members", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Groups]: {
|
||||
title: "Group Management",
|
||||
[ProjectPermissionSub.Identity]: {
|
||||
title: "Machine Identity Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Add", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Identity]: {
|
||||
title: "Machine Identity Management",
|
||||
[ProjectPermissionSub.Groups]: {
|
||||
title: "Group Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
@@ -535,7 +542,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Environments]: {
|
||||
title: "Environments",
|
||||
title: "Environment Management",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
|
||||
+63
-21
@@ -5,14 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Alert, Button, Modal, ModalContent, ModalTrigger } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
|
||||
|
||||
import { GeneralPermissionConditions } from "./components/GeneralPermissionConditions";
|
||||
import { GeneralPermissionPolicies } from "./components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "./components/NewPermissionRule";
|
||||
import { IdentityManagementPermissionConditions } from "./components/IdentityManagementPermissionConditions";
|
||||
import { SecretPermissionConditions } from "./components/SecretPermissionConditions";
|
||||
import { PermissionEmptyState } from "./PermissionEmptyState";
|
||||
import {
|
||||
@@ -37,6 +42,10 @@ export const renderConditionalComponents = (
|
||||
return <SecretPermissionConditions isDisabled={isDisabled} />;
|
||||
|
||||
if (isConditionalSubjects(subject)) {
|
||||
if (subject === ProjectPermissionSub.Identity) {
|
||||
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
|
||||
}
|
||||
|
||||
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
||||
}
|
||||
|
||||
@@ -45,7 +54,6 @@ export const renderConditionalComponents = (
|
||||
|
||||
export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: role, isLoading } = useGetProjectRoleBySlug(
|
||||
currentWorkspace?.id ?? "",
|
||||
@@ -83,6 +91,30 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
|
||||
const isCustomRole = !["admin", "member", "viewer", "no-access"].includes(role?.slug ?? "");
|
||||
|
||||
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
|
||||
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...[]],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[{}],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
@@ -116,11 +148,8 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={popUp.createPolicy.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
|
||||
>
|
||||
<ModalTrigger asChild disabled={isDisabled}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
@@ -129,23 +158,36 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
|
||||
>
|
||||
New policy
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
title="New Policy"
|
||||
subTitle="Policies grant additional permissions."
|
||||
>
|
||||
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</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>
|
||||
<Alert
|
||||
title="Infisical's permissions are now more granular. You can set separate permissions for folders, dynamic secrets, and imports. No changes required for existing APIs and clients."
|
||||
className="mt-4 border-primary/50 bg-primary/10"
|
||||
/>
|
||||
<div className="py-4">
|
||||
{!isLoading && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex, [
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
import { TFormSchema } from "../ProjectRoleModifySection.utils";
|
||||
import { getConditionOperatorHelperInfo } from "./PermissionConditionHelpers";
|
||||
|
||||
type Props = {
|
||||
position?: number;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const IdentityManagementPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||
const {
|
||||
control,
|
||||
watch,
|
||||
formState: { errors }
|
||||
} = useFormContext<TFormSchema>();
|
||||
const permissionSubject = ProjectPermissionSub.Identity;
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${permissionSubject}.${position}.conditions`
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
|
||||
<p className="mt-2 text-gray-300">Conditions</p>
|
||||
<p className="mb-2 text-sm text-mineshaft-400">
|
||||
When this policy should apply (always if no conditions are added).
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
{items.fields.map((el, index) => {
|
||||
const condition =
|
||||
(watch(`permissions.${permissionSubject}.${position}.conditions.${index}`) as {
|
||||
lhs: string;
|
||||
rhs: string;
|
||||
operator: string;
|
||||
}) || {};
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
<div className="w-1/4">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.lhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value="identityId">Identity ID</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-36 items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.operator`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => field.onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={getConditionOperatorHelperInfo(
|
||||
condition?.operator as PermissionConditionOperators
|
||||
)}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name={`permissions.${permissionSubject}.${position}.conditions.${index}.rhs`}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
className="mb-0 flex-grow"
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
ariaLabel="plus"
|
||||
variant="outline_bg"
|
||||
className="p-2.5"
|
||||
onClick={() => items.remove(index)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message && (
|
||||
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
|
||||
<FontAwesomeIcon icon={faWarning} className="text-red" />
|
||||
<span>{errors?.permissions?.[permissionSubject]?.[position]?.conditions?.message}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>{}</div>
|
||||
<div>
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-3"
|
||||
isDisabled={isDisabled}
|
||||
onClick={() =>
|
||||
items.append({
|
||||
lhs: "identityId",
|
||||
operator: PermissionConditionOperators.$EQ,
|
||||
rhs: ""
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Condition
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+2
-1
@@ -21,12 +21,13 @@ export const renderOperatorSelectItems = (type: string) => {
|
||||
if (type === "secretTags") {
|
||||
return <SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob Match</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>
|
||||
<SelectItem value={PermissionConditionOperators.$IN}>In</SelectItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -130,12 +130,14 @@ export const AccessApprovalRequest = ({
|
||||
if (statusFilter === "open")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
!request.policy.deletedAt &&
|
||||
!request.isApproved &&
|
||||
!request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
if (statusFilter === "close")
|
||||
return requests?.filter(
|
||||
(request) =>
|
||||
request.policy.deletedAt ||
|
||||
request.isApproved ||
|
||||
request.reviewers.some((reviewer) => reviewer.status === ApprovalStatus.REJECTED)
|
||||
);
|
||||
@@ -144,8 +146,6 @@ export const AccessApprovalRequest = ({
|
||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||
|
||||
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
||||
console.log(request);
|
||||
|
||||
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
|
||||
const isRejectedByAnyone = request.reviewers.some(
|
||||
({ status }) => status === ApprovalStatus.REJECTED
|
||||
|
||||
+3
-5
@@ -175,7 +175,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create policy
|
||||
Create Policy
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
@@ -188,8 +188,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
<Th>Name</Th>
|
||||
<Th>Environment</Th>
|
||||
<Th>Secret Path</Th>
|
||||
<Th>Eligible Approvers</Th>
|
||||
<Th>Eligible Group Approvers</Th>
|
||||
<Th className="w-[18%]">Eligible Approvers</Th>
|
||||
<Th className="w-[18%]">Eligible Group Approvers</Th>
|
||||
<Th>Approval Required</Th>
|
||||
<Th>
|
||||
<DropdownMenu>
|
||||
@@ -256,9 +256,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
|
||||
{!!currentWorkspace &&
|
||||
filteredPolicies?.map((policy) => (
|
||||
<ApprovalPolicyRow
|
||||
projectSlug={currentWorkspace.slug}
|
||||
policy={policy}
|
||||
workspaceId={workspaceId}
|
||||
key={policy.id}
|
||||
members={members}
|
||||
groups={groups}
|
||||
|
||||
+246
-260
@@ -1,18 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle } 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,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -21,6 +15,7 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import {
|
||||
useCreateSecretApprovalPolicy,
|
||||
@@ -46,21 +41,34 @@ type Props = {
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
environment: z.string(),
|
||||
environment: z.object({ slug: z.string(), name: z.string() }),
|
||||
name: z.string().optional(),
|
||||
secretPath: z.string().optional(),
|
||||
approvals: z.number().min(1),
|
||||
approvers: z
|
||||
.object({ type: z.nativeEnum(ApproverType), id: z.string() })
|
||||
userApprovers: z
|
||||
.object({ type: z.literal(ApproverType.User), id: z.string() })
|
||||
.array()
|
||||
.default([]),
|
||||
groupApprovers: z
|
||||
.object({ type: z.literal(ApproverType.Group), id: z.string() })
|
||||
.array()
|
||||
.min(1)
|
||||
.default([]),
|
||||
policyType: z.nativeEnum(PolicyType),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel)
|
||||
})
|
||||
.refine((data) => data.approvers, {
|
||||
path: ["approvers"],
|
||||
message: "At least one approver should be provided."
|
||||
.superRefine((data, ctx) => {
|
||||
if (!(data.groupApprovers.length || data.userApprovers.length)) {
|
||||
ctx.addIssue({
|
||||
path: ["userApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
ctx.addIssue({
|
||||
path: ["groupApprovers"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one approver should be provided"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -84,8 +92,15 @@ export const AccessPolicyForm = ({
|
||||
values: editValues
|
||||
? {
|
||||
...editValues,
|
||||
environment: editValues.environment.slug,
|
||||
approvers: editValues?.approvers || [],
|
||||
environment: editValues.environment,
|
||||
userApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.User })) || [],
|
||||
groupApprovers:
|
||||
editValues?.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [],
|
||||
approvals: editValues?.approvals
|
||||
}
|
||||
: undefined
|
||||
@@ -110,18 +125,27 @@ export const AccessPolicyForm = ({
|
||||
|
||||
const approversRequired = watch("approvals") || 1;
|
||||
|
||||
const handleCreatePolicy = async (data: TFormSchema) => {
|
||||
const handleCreatePolicy = async ({
|
||||
environment,
|
||||
groupApprovers,
|
||||
userApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
if (data.policyType === PolicyType.ChangePolicy) {
|
||||
await createSecretApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await createAccessApprovalPolicy({
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
@@ -139,7 +163,12 @@ export const AccessPolicyForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePolicy = async (data: TFormSchema) => {
|
||||
const handleUpdatePolicy = async ({
|
||||
environment,
|
||||
userApprovers,
|
||||
groupApprovers,
|
||||
...data
|
||||
}: TFormSchema) => {
|
||||
if (!projectId || !projectSlug) return;
|
||||
if (!editValues?.id) return;
|
||||
|
||||
@@ -148,12 +177,15 @@ export const AccessPolicyForm = ({
|
||||
await updateSecretApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
workspaceId: currentWorkspace?.id || ""
|
||||
});
|
||||
} else {
|
||||
await updateAccessApprovalPolicy({
|
||||
id: editValues?.id,
|
||||
...data,
|
||||
approvers: [...userApprovers, ...groupApprovers],
|
||||
environment: environment.slug,
|
||||
projectSlug
|
||||
});
|
||||
}
|
||||
@@ -179,150 +211,178 @@ export const AccessPolicyForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const memberOptions = useMemo(
|
||||
() =>
|
||||
members.map((member) => ({
|
||||
id: member.user.id,
|
||||
type: ApproverType.User
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
groups?.map(({ group }) => ({
|
||||
id: group.id,
|
||||
type: ApproverType.Group
|
||||
})),
|
||||
[groups]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
<ModalContent title={isEditMode ? `Edit ${policyName}` : "Create Policy"}>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
bodyClassName="overflow-visible"
|
||||
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
|
||||
>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<div className="grid grid-cols-2 gap-x-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="policyType"
|
||||
defaultValue={PolicyType.ChangePolicy}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Type"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
tooltipText="Change polices govern secret changes within a given environment and secret path. Access polices allow underprivileged user to request access to environment/secret path."
|
||||
errorText={error?.message}
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments[0]?.slug}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
className="mt-4"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<Select
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val as PolicyType)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(PolicyType).map((policyType) => {
|
||||
return (
|
||||
<SelectItem value={policyType} key={`policy-type-${policyType}`}>
|
||||
{policyDetails[policyType].name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`azure-key-vault-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvals"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Minimum Approvals Required"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enforcement Level"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="Determines the level of enforcement for required approvers of a request"
|
||||
helperText={
|
||||
<div className="ml-1">
|
||||
{field.value === EnforcementLevel.Hard
|
||||
? `Hard enforcement requires at least ${approversRequired} approver(s) to approve the request.`
|
||||
: `At least ${approversRequired} approver(s) must approve the request; however, the requester can bypass approval requirements in emergencies.`}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Policy Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="enforcementLevel"
|
||||
defaultValue={EnforcementLevel.Hard}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Enforcement Level"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText={
|
||||
<>
|
||||
<p>
|
||||
Determines the level of enforcement for required approvers of a request:
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Hard</span> enforcement requires at least{" "}
|
||||
<span className="font-bold"> {approversRequired}</span> approver(s) to
|
||||
approve the request.`
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
<span className="font-bold">Soft</span> enforcement At least{" "}
|
||||
<span className="font-bold">{approversRequired}</span> approver(s) must
|
||||
approve the request; however, the requester can bypass approval
|
||||
requirements in emergencies.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(val) => field.onChange(val as EnforcementLevel)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
>
|
||||
{Object.values(EnforcementLevel).map((level) => {
|
||||
return (
|
||||
<SelectItem value={level} key={`enforcement-level-${level}`}>
|
||||
<span className="capitalize">{level}</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Environment"
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
isDisabled={isEditMode}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder="Select environment..."
|
||||
options={environments}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Secret paths support glob patterns. For example, '/**' will match all paths."
|
||||
label="Secret Path"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<p>Approvers</p>
|
||||
<p className="font-inter text-xs text-mineshaft-300 opacity-90">
|
||||
@@ -331,127 +391,53 @@ export const AccessPolicyForm = ({
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvers"
|
||||
name="userApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="User Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={
|
||||
value?.filter((e) => e.type === ApproverType.User).length
|
||||
? `${value.filter((e) => e.type === ApproverType.User).length} selected`
|
||||
: "None"
|
||||
}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select members that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{members.map(({ user }) => {
|
||||
const { id: userId } = user;
|
||||
const isChecked =
|
||||
value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === userId && el.type === ApproverType.User
|
||||
).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== userId && el.type !== ApproverType.User
|
||||
)
|
||||
: [...(value || []), { id: userId, type: ApproverType.User }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${userId}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select members that are allowed to approve requests..."
|
||||
options={memberOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) => {
|
||||
const member = members?.find((m) => m.user.id === option.id);
|
||||
|
||||
if (!member) return option.id;
|
||||
|
||||
return getMemberLabel(member);
|
||||
}}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="approvers"
|
||||
name="groupApprovers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Group Approvers"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={
|
||||
value?.filter((e) => e.type === ApproverType.Group).length
|
||||
? `${
|
||||
value?.filter((e) => e.type === ApproverType.Group).length
|
||||
} selected`
|
||||
: "None"
|
||||
}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups &&
|
||||
groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked =
|
||||
value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id === id && el.type === ApproverType.Group
|
||||
).length > 0;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter(
|
||||
(el: { id: string; type: ApproverType }) =>
|
||||
el.id !== id && el.type !== ApproverType.Group
|
||||
)
|
||||
: [...(value || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{group.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<FilterableSelect
|
||||
menuPlacement="top"
|
||||
isMulti
|
||||
placeholder="Select groups that are allowed to approve requests..."
|
||||
options={groupOptions}
|
||||
getOptionValue={(option) => option.id}
|
||||
getOptionLabel={(option) =>
|
||||
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+50
-169
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { faCheckCircle, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import { faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -8,19 +8,19 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { Badge } from "@app/components/v2/Badge";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { getMemberLabel } from "@app/helpers/members";
|
||||
import { policyDetails } from "@app/helpers/policies";
|
||||
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
|
||||
import { Approver, ApproverType } from "@app/hooks/api/accessApproval/types";
|
||||
import { Approver } from "@app/hooks/api/accessApproval/types";
|
||||
import { TGroupMembership } from "@app/hooks/api/groups/types";
|
||||
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
|
||||
import { ApproverType } from "@app/hooks/api/secretApproval/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/types";
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
@@ -35,14 +35,12 @@ interface IPolicy {
|
||||
updatedAt: Date;
|
||||
policyType: PolicyType;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
policy: IPolicy;
|
||||
members?: TWorkspaceUser[];
|
||||
groups?: TGroupMembership[];
|
||||
projectSlug: string;
|
||||
workspaceId: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
@@ -51,175 +49,58 @@ export const ApprovalPolicyRow = ({
|
||||
policy,
|
||||
members = [],
|
||||
groups = [],
|
||||
projectSlug,
|
||||
workspaceId,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props) => {
|
||||
const [selectedApprovers, setSelectedApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<Approver[]>(policy.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy();
|
||||
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy();
|
||||
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
|
||||
const labels = useMemo(() => {
|
||||
const usersInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.User)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const groupsInPolicy = policy.approvers
|
||||
?.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map((approver) => approver.id);
|
||||
|
||||
const memberLabels = usersInPolicy?.length
|
||||
? members
|
||||
.filter((member) => usersInPolicy?.includes(member.user.id))
|
||||
.map((member) => getMemberLabel(member))
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
const groupLabels = groupsInPolicy?.length
|
||||
? groups
|
||||
.filter(({ group }) => groupsInPolicy?.includes(group.id))
|
||||
.map(({ group }) => group.name)
|
||||
.join(", ")
|
||||
: null;
|
||||
|
||||
return {
|
||||
members: memberLabels,
|
||||
groups: groupLabels
|
||||
};
|
||||
}, [policy, members, groups]);
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{policy.name}</Td>
|
||||
<Td>{policy.environment.slug}</Td>
|
||||
<Td>{policy.secretPath || "*"}</Td>
|
||||
<Td>
|
||||
<DropdownMenu
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (policy.policyType === PolicyType.AccessPolicy) {
|
||||
updateAccessApprovalPolicy(
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
updateSecretApprovalPolicy(
|
||||
{
|
||||
workspaceId,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.User) || []);
|
||||
}
|
||||
}}
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.members ?? "No users are assigned as approvers for this policy"}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={
|
||||
isLoading ||
|
||||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={selectedApprovers.length ? `${selectedApprovers.length} selected` : "None"}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select members that are allowed to approve changes
|
||||
</DropdownMenuLabel>
|
||||
{members?.map(({ user }) => {
|
||||
const userId = user.id;
|
||||
const isChecked = selectedApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === userId && el.type === ApproverType.User).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setSelectedApprovers((state) =>
|
||||
isChecked ? state.filter((el) => el.id !== userId || el.type !== ApproverType.User) : [...state, { id: userId, type: ApproverType.User }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${userId}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.username}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="truncate">{labels.members ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
if (policy.policyType === PolicyType.AccessPolicy) {
|
||||
updateAccessApprovalPolicy(
|
||||
{
|
||||
projectSlug,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
updateSecretApprovalPolicy(
|
||||
{
|
||||
workspaceId,
|
||||
id: policy.id,
|
||||
approvers: selectedApprovers.concat(selectedGroupApprovers),
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedGroupApprovers(policy?.approvers?.filter((approver) => approver.type === ApproverType.Group) || []);
|
||||
}
|
||||
}}
|
||||
<Td className="max-w-0">
|
||||
<Tooltip
|
||||
side="left"
|
||||
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Input
|
||||
isReadOnly
|
||||
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
Select groups that are allowed to approve requests
|
||||
</DropdownMenuLabel>
|
||||
{groups && groups.map(({ group }) => {
|
||||
const { id } = group;
|
||||
const isChecked = selectedGroupApprovers?.filter((el: { id: string, type: ApproverType }) => el.id === id && el.type === ApproverType.Group).length > 0;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
setSelectedGroupApprovers(
|
||||
isChecked
|
||||
? selectedGroupApprovers?.filter((el) => el.id !== id || el.type !== ApproverType.Group)
|
||||
: [...(selectedGroupApprovers || []), { id, type: ApproverType.Group }]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-groups-${id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{group.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="truncate">{labels.groups ?? "-"}</p>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td>{policy.approvals}</Td>
|
||||
<Td>
|
||||
@@ -229,12 +110,12 @@ export const ApprovalPolicyRow = ({
|
||||
</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg cursor-pointer">
|
||||
<div className="flex justify-center items-center hover:text-primary-400 data-[state=open]:text-primary-400 hover:scale-125 data-[state=open]:scale-125 transition-transform duration-300 ease-in-out">
|
||||
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
|
||||
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="p-1 min-w-[100%]">
|
||||
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={ProjectPermissionSub.SecretApproval}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalContent,
|
||||
@@ -17,7 +18,7 @@ import { useSubscription, useWorkspace } from "@app/context";
|
||||
import { useCreateSecretImport } from "@app/hooks/api";
|
||||
|
||||
const typeSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -80,7 +81,7 @@ export const CreateSecretImportForm = ({
|
||||
path: secretPath,
|
||||
isReplication,
|
||||
import: {
|
||||
environment: importedEnv,
|
||||
environment: importedEnv.slug,
|
||||
path: importedSecPath
|
||||
}
|
||||
});
|
||||
@@ -88,8 +89,9 @@ export const CreateSecretImportForm = ({
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : ""
|
||||
}`
|
||||
text: `Successfully linked. ${
|
||||
isReplication ? "Please refresh the dashboard to view changes" : ""
|
||||
}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -111,6 +113,7 @@ export const CreateSecretImportForm = ({
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Add Secret Link"
|
||||
subTitle="To inherit secrets from another environment or folder"
|
||||
>
|
||||
@@ -118,21 +121,16 @@ export const CreateSecretImportForm = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
options={environments}
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
placeholder="Select environment..."
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -142,7 +140,7 @@ export const CreateSecretImportForm = ({
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<SecretPathInput {...field} environment={selectedEnvironment} />
|
||||
<SecretPathInput {...field} environment={selectedEnvironment?.slug} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
+72
-111
@@ -1,14 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClone,
|
||||
faFileImport,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareCheck,
|
||||
faSquareXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { faClone, faFileImport, faSquareCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -16,17 +9,13 @@ import { z } from "zod";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
|
||||
@@ -35,14 +24,17 @@ import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
environment: z.object({ name: z.string(), slug: z.string() }),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z.record(z.string().optional().nullable())
|
||||
secrets: z
|
||||
.object({ key: z.string(), value: z.string().optional() })
|
||||
.array()
|
||||
.min(1, "Select one or more secrets to copy")
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -68,7 +60,6 @@ export const CopySecretsFromBoard = ({
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
@@ -80,7 +71,7 @@ export const CopySecretsFromBoard = ({
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0] }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
@@ -89,7 +80,7 @@ export const CopySecretsFromBoard = ({
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
environment: selectedEnvSlug.slug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
options: {
|
||||
enabled:
|
||||
@@ -101,29 +92,22 @@ export const CopySecretsFromBoard = ({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
setValue("secrets", []);
|
||||
}, [debouncedEnvCopySecretPath, selectedEnvSlug]);
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
setValue("secrets", secrets, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
data.secrets.forEach(({ key, value }) => {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && value) || "",
|
||||
comments: [""]
|
||||
};
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
@@ -136,7 +120,6 @@ export const CopySecretsFromBoard = ({
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
@@ -165,6 +148,7 @@ export const CopySecretsFromBoard = ({
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
@@ -176,22 +160,14 @@ export const CopySecretsFromBoard = ({
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
<FilterableSelect
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
onChange={onChange}
|
||||
options={environments}
|
||||
placeholder="Select environment..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -203,7 +179,7 @@ export const CopySecretsFromBoard = ({
|
||||
<SecretPathInput
|
||||
{...field}
|
||||
placeholder="Provide a path, default is /"
|
||||
environment={selectedEnvSlug}
|
||||
environment={selectedEnvSlug?.slug}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -212,72 +188,57 @@ export const CopySecretsFromBoard = ({
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="flex w-1/2 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
</div>
|
||||
<div className="flex w-full items-start gap-3">
|
||||
<Controller
|
||||
control={control}
|
||||
name="secrets"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
className="flex-1"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<FilterableSelect
|
||||
placeholder={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isSecretsLoading
|
||||
? "Loading secrets..."
|
||||
: secrets?.length
|
||||
? "Select secrets..."
|
||||
: "No secrets found..."
|
||||
}
|
||||
isLoading={isSecretsLoading}
|
||||
options={secrets}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
isMulti
|
||||
getOptionValue={(option) => option.key}
|
||||
getOptionLabel={(option) => option.key}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
className="mt-1 h-9 w-9"
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-2 gap-4 overflow-auto ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
|
||||
))}
|
||||
|
||||
{secrets
|
||||
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
?.map(({ id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
<div className="my-6 ml-2">
|
||||
<Switch
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</Switch>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
@@ -285,7 +246,7 @@ export const CopySecretsFromBoard = ({
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
Copy Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary" onClick={() => onToggle(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -722,26 +722,6 @@ export const SecretOverviewPage = () => {
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((availableEnv) => {
|
||||
const { id: envId, name } = availableEnv;
|
||||
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleEnvSelect(envId);
|
||||
}}
|
||||
key={envId}
|
||||
disabled={visibleEnvs?.length === 1}
|
||||
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{/* <DropdownMenuItem className="px-1.5" asChild>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -796,6 +776,26 @@ export const SecretOverviewPage = () => {
|
||||
<span>Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
|
||||
{userAvailableEnvs.map((availableEnv) => {
|
||||
const { id: envId, name } = availableEnv;
|
||||
|
||||
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleEnvSelect(envId);
|
||||
}}
|
||||
key={envId}
|
||||
disabled={visibleEnvs?.length === 1}
|
||||
icon={isEnvSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">{name}</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -1128,7 +1128,6 @@ export const SecretOverviewPage = () => {
|
||||
>
|
||||
<CreateSecretForm
|
||||
secretPath={secretPath}
|
||||
getSecretByKey={getSecretByKey}
|
||||
onClose={() => handlePopUpClose("addSecretsInAllEnvs")}
|
||||
/>
|
||||
</ModalContent>
|
||||
|
||||
+35
-88
@@ -1,13 +1,13 @@
|
||||
import { ClipboardEvent } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faTriangleExclamation, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
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, Checkbox, FormControl, FormLabel, Input, Tooltip } from "@app/components/v2";
|
||||
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
||||
import {
|
||||
@@ -17,20 +17,14 @@ import {
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretV3,
|
||||
useCreateWsTag,
|
||||
useGetWsTags,
|
||||
useUpdateSecretV3
|
||||
} from "@app/hooks/api";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||
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.record(z.boolean().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, {
|
||||
@@ -41,22 +35,19 @@ type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secretPath?: string;
|
||||
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||
// modal props
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }: Props) => {
|
||||
export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
const newSecretKey = watch("key");
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
@@ -65,22 +56,13 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { data: projectTags, isLoading: isTagsLoading } = useGetWsTags(
|
||||
canReadTags ? workspaceId : ""
|
||||
);
|
||||
|
||||
const handleFormSubmit = async ({ key, value, environments: selectedEnv, tags }: TFormSchema) => {
|
||||
const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]);
|
||||
const isEnvironmentsSelected = environmentsSelected.length;
|
||||
|
||||
if (!isEnvironmentsSelected) {
|
||||
createNotification({ type: "error", text: "Select at least one environment" });
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = environmentsSelected.map(async (env) => {
|
||||
const promises = selectedEnv.map(async (env) => {
|
||||
const environment = env.slug;
|
||||
// create folder if not existing
|
||||
if (secretPath !== "/") {
|
||||
@@ -106,21 +88,7 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
}
|
||||
}
|
||||
|
||||
const isEdit = getSecretByKey(environment, key) !== undefined;
|
||||
if (isEdit) {
|
||||
return {
|
||||
...(await updateSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretKey: key,
|
||||
secretValue: value || "",
|
||||
type: SecretType.Shared,
|
||||
tagIds: tags?.map((el) => el.value)
|
||||
})),
|
||||
environment
|
||||
};
|
||||
}
|
||||
// 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({
|
||||
@@ -278,54 +246,33 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Environments" className="mb-2" />
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto py-2">
|
||||
{environments
|
||||
.filter((environmentSlug) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: environmentSlug.slug,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
)
|
||||
)
|
||||
.map((env) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`environments.${env.slug}`}
|
||||
key={`secret-input-${env.slug}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`secret-input-${env.slug}`}
|
||||
className="!justify-start"
|
||||
>
|
||||
<span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
|
||||
<span title={env.name} className="truncate">
|
||||
{env.name}
|
||||
</span>
|
||||
<span>
|
||||
{getSecretByKey(env.slug, newSecretKey) && (
|
||||
<Tooltip
|
||||
className="max-w-[150px]"
|
||||
content="Secret already exists, and it will be overwritten"
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
+22
-3
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { faAws } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
|
||||
|
||||
import { AwsKmsForm } from "./AwsKmsForm";
|
||||
import { GcpKmsForm } from "./GcpKmsForm";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
@@ -21,8 +22,13 @@ enum WizardSteps {
|
||||
const EXTERNAL_KMS_LIST = [
|
||||
{
|
||||
icon: faAws,
|
||||
provider: ExternalKmsProvider.AWS,
|
||||
provider: ExternalKmsProvider.Aws,
|
||||
title: "AWS KMS"
|
||||
},
|
||||
{
|
||||
icon: faGoogle,
|
||||
provider: ExternalKmsProvider.Gcp,
|
||||
title: "GCP KMS"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -42,6 +48,7 @@ export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => {
|
||||
title="Add a Key Management System"
|
||||
subTitle="Configure an external key management system (KMS)"
|
||||
className="my-4"
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
{wizardStep === WizardSteps.SelectProvider && (
|
||||
@@ -79,7 +86,7 @@ export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => {
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === ExternalKmsProvider.AWS && (
|
||||
selectedProvider === ExternalKmsProvider.Aws && (
|
||||
<motion.div
|
||||
key="kms-aws"
|
||||
transition={{ duration: 0.1 }}
|
||||
@@ -90,6 +97,18 @@ export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => {
|
||||
<AwsKmsForm onCancel={() => onToggle(false)} onCompleted={() => onToggle(false)} />
|
||||
</motion.div>
|
||||
)}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === ExternalKmsProvider.Gcp && (
|
||||
<motion.div
|
||||
key="kms-gcp"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<GcpKmsForm onCancel={() => onToggle(false)} onCompleted={() => onToggle(false)} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
+3
-3
@@ -64,7 +64,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
name: kms?.name,
|
||||
description: kms?.description ?? "",
|
||||
provider: {
|
||||
type: ExternalKmsProvider.AWS,
|
||||
type: ExternalKmsProvider.Aws,
|
||||
inputs: {
|
||||
credential: {
|
||||
type: kms?.external?.providerInput?.credential?.type,
|
||||
@@ -88,7 +88,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
|
||||
const selectedAwsAuthType = watch("provider.inputs.credential.type");
|
||||
|
||||
const handleAddAwsKms = async (data: AddExternalKmsType) => {
|
||||
const handleAwsKmsFormSubmit = async (data: AddExternalKmsType) => {
|
||||
const { name, description, provider } = data;
|
||||
try {
|
||||
if (kms) {
|
||||
@@ -123,7 +123,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleAddAwsKms)} autoComplete="off">
|
||||
<form onSubmit={handleSubmit(handleAwsKmsFormSubmit)} autoComplete="off">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Badge, Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import {
|
||||
useAddExternalKms,
|
||||
useExternalKmsFetchGcpKeys,
|
||||
useUpdateExternalKms
|
||||
} from "@app/hooks/api";
|
||||
import {
|
||||
AddExternalKmsGcpFormSchema,
|
||||
AddExternalKmsGcpFormSchemaType,
|
||||
ExternalKmsGcpCredentialSchema,
|
||||
ExternalKmsGcpCredentialSchemaType,
|
||||
ExternalKmsProvider,
|
||||
Kms
|
||||
} from "@app/hooks/api/kms/types";
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
kms?: Kms;
|
||||
};
|
||||
|
||||
const GCP_REGIONS = [
|
||||
{ label: "Johannesburg", value: "africa-south1" },
|
||||
{ label: "Taiwan", value: "asia-east1" },
|
||||
{ label: "Hong Kong", value: "asia-east2" },
|
||||
{ label: "Tokyo", value: "asia-northeast1" },
|
||||
{ label: "Osaka", value: "asia-northeast2" },
|
||||
{ label: "Seoul", value: "asia-northeast3" },
|
||||
{ label: "Mumbai", value: "asia-south1" },
|
||||
{ label: "Delhi", value: "asia-south2" },
|
||||
{ label: "Singapore", value: "asia-southeast1" },
|
||||
{ label: "Jakarta", value: "asia-southeast2" },
|
||||
{ label: "Sydney", value: "australia-southeast1" },
|
||||
{ label: "Melbourne", value: "australia-southeast2" },
|
||||
{ label: "Warsaw", value: "europe-central2" },
|
||||
{ label: "Finland", value: "europe-north1" },
|
||||
{ label: "Belgium", value: "europe-west1" },
|
||||
{ label: "London", value: "europe-west2" },
|
||||
{ label: "Frankfurt", value: "europe-west3" },
|
||||
{ label: "Netherlands", value: "europe-west4" },
|
||||
{ label: "Zurich", value: "europe-west6" },
|
||||
{ label: "Milan", value: "europe-west8" },
|
||||
{ label: "Paris", value: "europe-west9" },
|
||||
{ label: "Berlin", value: "europe-west10" },
|
||||
{ label: "Turin", value: "europe-west12" },
|
||||
{ label: "Madrid", value: "europe-southwest1" },
|
||||
{ label: "Doha", value: "me-central1" },
|
||||
{ label: "Dammam", value: "me-central2" },
|
||||
{ label: "Tel Aviv", value: "me-west1" },
|
||||
{ label: "Montréal", value: "northamerica-northeast1" },
|
||||
{ label: "Toronto", value: "northamerica-northeast2" },
|
||||
{ label: "São Paulo", value: "southamerica-east1" },
|
||||
{ label: "Santiago", value: "southamerica-west1" },
|
||||
{ label: "Iowa", value: "us-central1" },
|
||||
{ label: "South Carolina", value: "us-east1" },
|
||||
{ label: "North Virginia", value: "us-east4" },
|
||||
{ label: "Columbus", value: "us-east5" },
|
||||
{ label: "Dallas", value: "us-south1" },
|
||||
{ label: "Oregon", value: "us-west1" },
|
||||
{ label: "Los Angeles", value: "us-west2" },
|
||||
{ label: "Salt Lake City", value: "us-west3" },
|
||||
{ label: "Las Vegas", value: "us-west4" }
|
||||
];
|
||||
|
||||
const formatOptionLabel = ({ value, label }: { value: string; label: string }) => (
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<span>{label}</span>
|
||||
<Badge variant="success">{value}</Badge>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
const [isCredentialValid, setIsCredentialValid] = useState<boolean>(false);
|
||||
const [keys, setKeys] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
resetField,
|
||||
setValue,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<AddExternalKmsGcpFormSchemaType>({
|
||||
resolver: zodResolver(AddExternalKmsGcpFormSchema),
|
||||
defaultValues: {
|
||||
formType: kms ? "updateGcpKms" : "newGcpKms",
|
||||
name: kms?.name ?? "",
|
||||
description: kms?.description ?? "",
|
||||
gcpRegion: kms
|
||||
? {
|
||||
label:
|
||||
GCP_REGIONS.find((r) => r.value === kms.external.providerInput.gcpRegion)?.label ??
|
||||
"",
|
||||
value: kms.external.providerInput.gcpRegion
|
||||
}
|
||||
: undefined,
|
||||
keyObject: undefined
|
||||
}
|
||||
});
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const { mutateAsync: addGcpExternalKms } = useAddExternalKms(currentOrg?.id!);
|
||||
const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(currentOrg?.id!);
|
||||
const { mutateAsync: fetchGcpKeys, isLoading: isFetchGcpKeysLoading } =
|
||||
useExternalKmsFetchGcpKeys(currentOrg?.id!);
|
||||
|
||||
// transforms the credential file into a JSON object
|
||||
async function getCredentialFileJson(): Promise<ExternalKmsGcpCredentialSchemaType | null> {
|
||||
const files = getValues("credentialFile");
|
||||
if (!files || !files.length) {
|
||||
return null;
|
||||
}
|
||||
const file = files[0];
|
||||
if (file.type !== "application/json") {
|
||||
setError("credentialFile", {
|
||||
message: "Only .json files are accepted."
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const jsonContents = await file.text();
|
||||
const parsedJson = ExternalKmsGcpCredentialSchema.safeParse(JSON.parse(jsonContents));
|
||||
if (!parsedJson.success) {
|
||||
setError("credentialFile", {
|
||||
message: "Invalid Service Account credential JSON."
|
||||
});
|
||||
return null;
|
||||
}
|
||||
clearErrors("credentialFile");
|
||||
return parsedJson.data;
|
||||
}
|
||||
|
||||
// handles the form submission
|
||||
const handleGcpKmsFormSubmit = async (data: AddExternalKmsGcpFormSchemaType) => {
|
||||
const { name, description, gcpRegion: gcpRegionObject, keyObject } = data;
|
||||
const gcpRegion = gcpRegionObject.value;
|
||||
if (!keys.find((k) => k.value === keyObject?.value)) {
|
||||
setError("keyObject", {
|
||||
message: "Please select a valid key."
|
||||
});
|
||||
resetField("keyObject");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (kms) {
|
||||
await updateGcpExternalKms({
|
||||
kmsId: kms.id,
|
||||
name,
|
||||
description,
|
||||
provider: {
|
||||
type: ExternalKmsProvider.Gcp,
|
||||
inputs: {
|
||||
gcpRegion,
|
||||
keyName: keyObject?.value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated GCP External KMS",
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
const credentialJson = await getCredentialFileJson();
|
||||
if (!credentialJson) {
|
||||
return;
|
||||
}
|
||||
await addGcpExternalKms({
|
||||
name,
|
||||
description,
|
||||
provider: {
|
||||
type: ExternalKmsProvider.Gcp,
|
||||
inputs: {
|
||||
gcpRegion,
|
||||
keyName: keyObject?.value,
|
||||
credential: credentialJson
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully added GCP External KMS",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
|
||||
onCompleted();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGCPKeys = async () => {
|
||||
// @ts-expect-error - issue with the way react-select renders the placeholder. We need to set the value to null explicitly otherwise it will not re-render
|
||||
setValue("keyObject", null);
|
||||
setKeys([]);
|
||||
|
||||
const credentialJson = kms ? undefined : await getCredentialFileJson();
|
||||
if (!kms && !credentialJson) {
|
||||
return;
|
||||
}
|
||||
const gcpRegion = getValues("gcpRegion").value;
|
||||
if (!gcpRegion.length) {
|
||||
setError("gcpRegion", {
|
||||
message: "Please select a GCP region to fetch GCP Keys."
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await fetchGcpKeys({
|
||||
gcpRegion,
|
||||
...(kms ? { kmsId: kms.id } : { credential: credentialJson! })
|
||||
});
|
||||
setIsCredentialValid(!!res.keys);
|
||||
const returnedKeys = res.keys.map((key) => {
|
||||
const parts = key.split("/");
|
||||
const keyLabel = `${parts[5]}/${parts[7]}`;
|
||||
return {
|
||||
value: key,
|
||||
label: keyLabel
|
||||
};
|
||||
});
|
||||
|
||||
setKeys(returnedKeys);
|
||||
if (kms) {
|
||||
const existingKey = returnedKeys.find((k) => k.value === kms.external.providerInput.keyName);
|
||||
if (existingKey) {
|
||||
setValue("keyObject", existingKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPlaceholderText = () => {
|
||||
if (isFetchGcpKeysLoading) {
|
||||
return "Loading keys in this region...";
|
||||
}
|
||||
if (!isCredentialValid) {
|
||||
return "Upload a valid credential file";
|
||||
}
|
||||
if (keys.length) {
|
||||
return "Select a key";
|
||||
}
|
||||
|
||||
return "No valid keys found in this region";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (kms && !isCredentialValid) {
|
||||
fetchGCPKeys();
|
||||
}
|
||||
}, [kms]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleGcpKmsFormSubmit)} autoComplete="off">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="gcpRegion"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Region" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder="Select a GCP region"
|
||||
name="gcpRegion"
|
||||
options={GCP_REGIONS}
|
||||
value={field.value}
|
||||
onChange={(e) => {
|
||||
resetField("keyObject");
|
||||
field.onChange(e);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{!kms && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="credentialFile"
|
||||
render={({ field: { value, onChange, ref, ...rest }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Service Account Credential JSON"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept=".json"
|
||||
placeholder=""
|
||||
value={value?.filename}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.files);
|
||||
fetchGCPKeys();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="keyObject"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="GCP Key Name" errorText={error?.message} isError={Boolean(error)}>
|
||||
<FilterableSelect
|
||||
className="w-full"
|
||||
placeholder={getPlaceholderText()}
|
||||
isDisabled={!isCredentialValid || !keys.length}
|
||||
name="key"
|
||||
options={keys}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
{kms && (
|
||||
<span className="text-xs text-mineshaft-300">
|
||||
To change your GCP credentials, create a new external KMS and assign it to project you
|
||||
want to use it with.
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-6 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
import { faAws } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faEllipsis, faLock, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -117,9 +117,12 @@ export const OrgEncryptionTab = withPermission(
|
||||
externalKmsList?.map((kms) => (
|
||||
<Tr key={kms.id}>
|
||||
<Td className="flex max-w-xs items-center overflow-hidden text-ellipsis hover:overflow-auto hover:break-all">
|
||||
{kms.externalKms.provider === ExternalKmsProvider.AWS && (
|
||||
{kms.externalKms.provider === ExternalKmsProvider.Aws && (
|
||||
<FontAwesomeIcon icon={faAws} />
|
||||
)}
|
||||
{kms.externalKms.provider === ExternalKmsProvider.Gcp && (
|
||||
<FontAwesomeIcon icon={faGoogle} />
|
||||
)}
|
||||
<div className="ml-2">{kms.externalKms.provider.toUpperCase()}</div>
|
||||
</Td>
|
||||
<Td>{kms.name}</Td>
|
||||
|
||||
+10
-2
@@ -3,6 +3,7 @@ import { useGetExternalKmsById } from "@app/hooks/api";
|
||||
import { ExternalKmsProvider } from "@app/hooks/api/kms/types";
|
||||
|
||||
import { AwsKmsForm } from "./AwsKmsForm";
|
||||
import { GcpKmsForm } from "./GcpKmsForm";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@@ -14,15 +15,22 @@ export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) =>
|
||||
const { data: externalKms, isLoading } = useGetExternalKmsById(kmsId);
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title="Edit configuration">
|
||||
<ModalContent title="Edit configuration" bodyClassName="overflow-visible">
|
||||
{isLoading && <ContentLoader />}
|
||||
{externalKms?.external?.provider === ExternalKmsProvider.AWS && (
|
||||
{externalKms?.external?.provider === ExternalKmsProvider.Aws && (
|
||||
<AwsKmsForm
|
||||
kms={externalKms}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
{externalKms?.external?.provider === ExternalKmsProvider.Gcp && (
|
||||
<GcpKmsForm
|
||||
kms={externalKms}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onCompleted={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+2
-8
@@ -2,7 +2,6 @@ import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/router";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import axios from "axios";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
useGetSlackIntegrationById,
|
||||
useUpdateSlackIntegration
|
||||
} from "@app/hooks/api";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
@@ -22,13 +22,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const slackFormSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Alias must be a valid slug"
|
||||
}),
|
||||
slug: slugSchema({ min: 1, field: "Alias" }),
|
||||
description: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ type Props = {
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
slug: slugSchema,
|
||||
slug: slugSchema(),
|
||||
name: z.string().trim().min(1),
|
||||
permissions: projectRoleFormSchema.shape.permissions
|
||||
});
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ const formSchema = z.object({
|
||||
environments: z
|
||||
.object({
|
||||
name: z.string().trim().min(1),
|
||||
slug: slugSchema
|
||||
slug: slugSchema({ min: 1, max: 32 })
|
||||
})
|
||||
.array()
|
||||
});
|
||||
|
||||
+2
-10
@@ -1,6 +1,5 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -18,17 +17,10 @@ import {
|
||||
useCreateProjectTemplate,
|
||||
useUpdateProjectTemplate
|
||||
} from "@app/hooks/api/projectTemplates";
|
||||
import { slugSchema } from "@app/lib/schemas";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(32)
|
||||
.toLowerCase()
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Name must be in slug format"
|
||||
}),
|
||||
name: slugSchema({ min: 1, max: 32, field: "Name" }),
|
||||
description: z.string().max(500).optional()
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user