Merge remote-tracking branch 'origin' into ssh-certs

This commit is contained in:
Tuan Dang
2024-12-10 12:46:14 -08:00
216 changed files with 6137 additions and 2765 deletions
@@ -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]
+12
View File
@@ -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;
};
+6 -1
View File
@@ -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",
+36 -6
View File
@@ -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}`
}
]
});
}
},
+7 -8
View File
@@ -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";
+4 -2
View File
@@ -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));
}
});
};
+25 -13
View File
@@ -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
+16 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export {
useAddExternalKms,
useExternalKmsFetchGcpKeys,
useLoadProjectKmsBackup,
useRemoveExternalKms,
useUpdateExternalKms,
+49 -2
View File
@@ -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));
}
});
};
+99 -4
View File
@@ -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"
}
+25 -6
View File
@@ -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();
}
},
+5 -5
View File
@@ -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 ? (
+20 -9
View File
@@ -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
View File
@@ -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
}
);
@@ -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">
@@ -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>
@@ -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>
);
};
@@ -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 ${
@@ -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>
);
};
@@ -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>
);
};
@@ -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 }
);
@@ -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>
);
};
@@ -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>
);
};
@@ -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>
);
};
@@ -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";
@@ -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>
)}
/>
@@ -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 ${
@@ -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}
@@ -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>
)}
/>
@@ -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>
)}
/>
@@ -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();
+3 -2
View File
@@ -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>
)}
/>
@@ -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"
>
@@ -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>
@@ -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"
>
@@ -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)
});
@@ -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>
@@ -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
@@ -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>
);
};
@@ -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 +0,0 @@
export { IdentityRoleForm } from "./IdentityRoleForm";
@@ -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();
@@ -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" },
@@ -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) => (
@@ -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
]);
}}
@@ -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>
);
};
@@ -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>
</>
);
};
@@ -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
@@ -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}
@@ -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>
)}
/>
@@ -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>
)}
/>
@@ -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>
@@ -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}
@@ -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>
@@ -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"
@@ -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>
);
};
@@ -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>
@@ -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,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()
});
@@ -31,7 +31,7 @@ type Props = {
};
const formSchema = z.object({
slug: slugSchema,
slug: slugSchema(),
name: z.string().trim().min(1),
permissions: projectRoleFormSchema.shape.permissions
});
@@ -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()
});
@@ -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