mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 14:27:23 +00:00
Merge remote-tracking branch 'origin/main' into misc/move-audit-logs-to-dedicated
This commit is contained in:
@@ -86,13 +86,15 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
|
||||
icon,
|
||||
as: Item = "button",
|
||||
iconPos = "left",
|
||||
isDisabled = false,
|
||||
...props
|
||||
}: DropdownMenuItemProps<T> & ComponentPropsWithRef<T>) => (
|
||||
}: DropdownMenuItemProps<T> & ComponentPropsWithRef<T> & { isDisabled?: boolean }) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"block cursor-pointer rounded-sm px-4 py-2 font-inter text-xs text-mineshaft-200 outline-none data-[highlighted]:bg-mineshaft-700",
|
||||
className
|
||||
className,
|
||||
isDisabled ? "pointer-events-none opacity-50" : ""
|
||||
)}
|
||||
>
|
||||
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
|
||||
|
||||
@@ -77,7 +77,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
const debouncedValue = useDebounce(value, 500);
|
||||
const [debouncedValue] = useDebounce(value, 500);
|
||||
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const Pagination = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
|
||||
"flex w-full items-center justify-end border-t border-mineshaft-600 bg-mineshaft-800 py-3 px-4 text-white",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SecretPathInput = ({
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [isInputFocused, setIsInputFocus] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const debouncedInputValue = useDebounce(inputValue, 200);
|
||||
const [debouncedInputValue] = useDebounce(inputValue, 200);
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SwitchProps = Omit<SwitchPrimitive.SwitchProps, "checked" | "disable
|
||||
isChecked?: boolean;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export const Switch = ({
|
||||
@@ -17,9 +18,10 @@ export const Switch = ({
|
||||
isChecked,
|
||||
isDisabled,
|
||||
isRequired,
|
||||
containerClassName,
|
||||
...props
|
||||
}: SwitchProps): JSX.Element => (
|
||||
<div className="flex items-center font-inter text-bunker-300">
|
||||
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
|
||||
<label className="text-sm" htmlFor={id}>
|
||||
{children}
|
||||
{isRequired && <span className="pl-1 text-red">*</span>}
|
||||
|
||||
@@ -27,35 +27,40 @@ export const Tooltip = ({
|
||||
isDisabled,
|
||||
position = "top",
|
||||
...props
|
||||
}: TooltipProps) => (
|
||||
<TooltipPrimitive.Root
|
||||
delayDuration={50}
|
||||
open={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<TooltipPrimitive.Trigger asChild={asChild}>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
side={position}
|
||||
align="center"
|
||||
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
|
||||
}: TooltipProps) =>
|
||||
// just render children if tooltip content is empty
|
||||
content ? (
|
||||
<TooltipPrimitive.Root
|
||||
delayDuration={50}
|
||||
open={isOpen}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<TooltipPrimitive.Trigger asChild={asChild}>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Content
|
||||
side={position}
|
||||
align="center"
|
||||
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
|
||||
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
|
||||
data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade
|
||||
`,
|
||||
isDisabled && "!hidden",
|
||||
center && "text-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
<TooltipPrimitive.Arrow width={11} height={5} className="fill-mineshaft-600" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
isDisabled && "!hidden",
|
||||
center && "text-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
<TooltipPrimitive.Arrow width={11} height={5} className="fill-mineshaft-600" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Root>
|
||||
) : (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext";
|
||||
export type { ProjectPermissionSet, TProjectPermission } from "./types";
|
||||
export { ProjectPermissionActions, ProjectPermissionSub } from "./types";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionSub
|
||||
} from "./types";
|
||||
|
||||
@@ -7,6 +7,15 @@ export enum ProjectPermissionActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCmekActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
Encrypt = "encrypt",
|
||||
Decrypt = "decrypt"
|
||||
}
|
||||
|
||||
export enum PermissionConditionOperators {
|
||||
$IN = "$in",
|
||||
$ALL = "$all",
|
||||
@@ -55,7 +64,8 @@ export enum ProjectPermissionSub {
|
||||
CertificateTemplates = "certificate-templates",
|
||||
PkiAlerts = "pki-alerts",
|
||||
PkiCollections = "pki-collections",
|
||||
Kms = "kms"
|
||||
Kms = "kms",
|
||||
Cmek = "cmek"
|
||||
}
|
||||
|
||||
type SubjectFields = {
|
||||
@@ -97,6 +107,7 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
|
||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
|
||||
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback];
|
||||
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek];
|
||||
|
||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
export type { TProjectPermission } from "./ProjectPermissionContext";
|
||||
export {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionProvider,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { encodeBase64 } from "tweetnacl-util";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { cmekKeys } from "@app/hooks/api/cmeks/queries";
|
||||
import {
|
||||
TCmekDecrypt,
|
||||
TCmekDecryptResponse,
|
||||
TCmekEncrypt,
|
||||
TCmekEncryptResponse,
|
||||
TCreateCmek,
|
||||
TDeleteCmek,
|
||||
TUpdateCmek
|
||||
} from "@app/hooks/api/cmeks/types";
|
||||
|
||||
export const useCreateCmek = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: TCreateCmek) => {
|
||||
const { data } = await apiRequest.post("/api/v1/kms/keys", payload);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries(cmekKeys.getCmeksByProjectId({ projectId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCmek = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId, name, description, isDisabled }: TUpdateCmek) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/kms/keys/${keyId}`, {
|
||||
name,
|
||||
description,
|
||||
isDisabled
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries(cmekKeys.getCmeksByProjectId({ projectId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCmek = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId }: TDeleteCmek) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/kms/keys/${keyId}`);
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries(cmekKeys.getCmeksByProjectId({ projectId }));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCmekEncrypt = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId, plaintext, isBase64Encoded }: TCmekEncrypt) => {
|
||||
const { data } = await apiRequest.post<TCmekEncryptResponse>(
|
||||
`/api/v1/kms/keys/${keyId}/encrypt`,
|
||||
{
|
||||
plaintext: isBase64Encoded ? plaintext : encodeBase64(Buffer.from(plaintext))
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useCmekDecrypt = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => {
|
||||
const { data } = await apiRequest.post<TCmekDecryptResponse>(
|
||||
`/api/v1/kms/keys/${keyId}/decrypt`,
|
||||
{
|
||||
ciphertext
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { CmekOrderBy, TListProjectCmeksDTO, TProjectCmeksList } from "@app/hooks/api/cmeks/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
|
||||
export const cmekKeys = {
|
||||
all: ["cmek"] as const,
|
||||
lists: () => [...cmekKeys.all, "list"] as const,
|
||||
getCmeksByProjectId: ({ projectId, ...filters }: TListProjectCmeksDTO) =>
|
||||
[...cmekKeys.lists(), projectId, filters] as const
|
||||
};
|
||||
|
||||
export const useGetCmeksByProjectId = (
|
||||
{
|
||||
projectId,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
orderBy = CmekOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
search = ""
|
||||
}: TListProjectCmeksDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TProjectCmeksList,
|
||||
unknown,
|
||||
TProjectCmeksList,
|
||||
ReturnType<typeof cmekKeys.getCmeksByProjectId>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: cmekKeys.getCmeksByProjectId({
|
||||
projectId,
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
search
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TProjectCmeksList>("/api/v1/kms/keys", {
|
||||
params: { projectId, offset, limit, search, orderBy, orderDirection }
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: Boolean(projectId) && (options?.enabled ?? true),
|
||||
keepPreviousData: true,
|
||||
...options
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
|
||||
export type TCmek = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
encryptionAlgorithm: EncryptionAlgorithm;
|
||||
projectId: string;
|
||||
isDisabled: boolean;
|
||||
isReserved: boolean;
|
||||
orgId: string;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ProjectRef = { projectId: string };
|
||||
type KeyRef = { keyId: string };
|
||||
|
||||
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm"> & ProjectRef;
|
||||
export type TUpdateCmek = KeyRef &
|
||||
Partial<Pick<TCmek, "name" | "description" | "isDisabled">> &
|
||||
ProjectRef;
|
||||
export type TDeleteCmek = KeyRef & ProjectRef;
|
||||
|
||||
export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean };
|
||||
export type TCmekDecrypt = KeyRef & { ciphertext: string };
|
||||
|
||||
export type TProjectCmeksList = {
|
||||
keys: TCmek[];
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type TListProjectCmeksDTO = {
|
||||
projectId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: CmekOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type TCmekEncryptResponse = {
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
export type TCmekDecryptResponse = {
|
||||
plaintext: string;
|
||||
};
|
||||
|
||||
export enum CmekOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export enum EncryptionAlgorithm {
|
||||
AES_GCM_256 = "aes-256-gcm",
|
||||
AES_GCM_128 = "aes-128-gcm"
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export enum DynamicSecretProviders {
|
||||
ElasticSearch = "elastic-search",
|
||||
MongoDB = "mongo-db",
|
||||
RabbitMq = "rabbit-mq",
|
||||
AzureEntraId = "azure-entra-id"
|
||||
AzureEntraId = "azure-entra-id",
|
||||
Ldap = "ldap"
|
||||
}
|
||||
|
||||
export enum SqlProviders {
|
||||
@@ -188,7 +189,20 @@ export type TDynamicSecretProvider =
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DynamicSecretProviders.Ldap;
|
||||
inputs: {
|
||||
url: string;
|
||||
binddn: string;
|
||||
bindpass: string;
|
||||
ca?: string | undefined;
|
||||
creationLdif: string;
|
||||
revocationLdif: string;
|
||||
rollbackLdif?: string;
|
||||
};
|
||||
};
|
||||
;
|
||||
|
||||
export type TCreateDynamicSecretDTO = {
|
||||
projectSlug: string;
|
||||
|
||||
@@ -8,9 +8,9 @@ import { AddExternalKmsType, KmsType } from "./types";
|
||||
export const useAddExternalKms = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, description, provider }: AddExternalKmsType) => {
|
||||
mutationFn: async ({ name, description, provider }: AddExternalKmsType) => {
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms", {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
});
|
||||
@@ -28,14 +28,14 @@ export const useUpdateExternalKms = (orgId: string) => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
kmsId,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
}: {
|
||||
kmsId: string;
|
||||
} & AddExternalKmsType) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ export const useGetActiveProjectKms = (projectId: string) => {
|
||||
} = await apiRequest.get<{
|
||||
secretManagerKmsKey: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
isExternal: string;
|
||||
};
|
||||
}>(`/api/v1/workspace/${projectId}/kms`);
|
||||
|
||||
@@ -5,7 +5,7 @@ export type Kms = {
|
||||
id: string;
|
||||
description: string;
|
||||
orgId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
external: {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -21,7 +21,7 @@ export type KmsListEntry = {
|
||||
isDisabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
externalKms: {
|
||||
provider: string;
|
||||
status: string;
|
||||
@@ -88,7 +88,7 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
|
||||
]);
|
||||
|
||||
export const AddExternalKmsSchema = z.object({
|
||||
slug: z
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
|
||||
@@ -122,6 +122,6 @@ export type TOrgIdentitiesList = {
|
||||
};
|
||||
|
||||
export enum OrgIdentityOrderBy {
|
||||
Name = "name",
|
||||
Role = "role"
|
||||
Name = "name"
|
||||
// Role = "role"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { secretSharingKeys } from "./queries";
|
||||
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
|
||||
import {
|
||||
TCreatedSharedSecret,
|
||||
TCreateSharedSecretRequest,
|
||||
TDeleteSharedSecretRequest,
|
||||
TSharedSecret
|
||||
} from "./types";
|
||||
|
||||
export const useCreateSharedSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
|
||||
const { data } = await apiRequest.post<TCreatedSharedSecret>(
|
||||
"/api/v1/secret-sharing",
|
||||
inputData
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets())
|
||||
@@ -20,7 +28,7 @@ export const useCreatePublicSharedSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.post<TSharedSecret>(
|
||||
const { data } = await apiRequest.post<TCreatedSharedSecret>(
|
||||
"/api/v1/secret-sharing/public",
|
||||
inputData
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ export const secretSharingKeys = {
|
||||
allSharedSecrets: () => ["sharedSecrets"] as const,
|
||||
specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) =>
|
||||
[...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const,
|
||||
getSecretById: (arg: { id: string; hashedHex: string; password?: string }) => [
|
||||
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
|
||||
"shared-secret",
|
||||
arg
|
||||
]
|
||||
@@ -46,7 +46,7 @@ export const useGetActiveSharedSecretById = ({
|
||||
password
|
||||
}: {
|
||||
sharedSecretId: string;
|
||||
hashedHex: string;
|
||||
hashedHex: string | null;
|
||||
password?: string;
|
||||
}) => {
|
||||
return useQuery<TViewSharedSecretResponse>(
|
||||
@@ -55,7 +55,7 @@ export const useGetActiveSharedSecretById = ({
|
||||
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
|
||||
`/api/v1/secret-sharing/public/${sharedSecretId}`,
|
||||
{
|
||||
hashedHex,
|
||||
...(hashedHex && { hashedHex }),
|
||||
password
|
||||
}
|
||||
);
|
||||
@@ -63,7 +63,7 @@ export const useGetActiveSharedSecretById = ({
|
||||
return data;
|
||||
},
|
||||
{
|
||||
enabled: Boolean(sharedSecretId) && Boolean(hashedHex)
|
||||
enabled: Boolean(sharedSecretId)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,13 +13,14 @@ export type TSharedSecret = {
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type TCreatedSharedSecret = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type TCreateSharedSecretRequest = {
|
||||
name?: string;
|
||||
password?: string;
|
||||
encryptedValue: string;
|
||||
hashedHex: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
secretValue: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews?: number;
|
||||
accessType?: SecretSharingAccessType;
|
||||
@@ -28,6 +29,7 @@ export type TCreateSharedSecretRequest = {
|
||||
export type TViewSharedSecretResponse = {
|
||||
isPasswordProtected: boolean;
|
||||
secret: {
|
||||
secretValue?: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
@@ -44,4 +46,3 @@ export enum SecretSharingAccessType {
|
||||
Anyone = "anyone",
|
||||
Organization = "organization"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useDebounce } from "./useDebounce";
|
||||
export { useLeaveConfirm } from "./useLeaveConfirm";
|
||||
export { usePagination } from "./usePagination";
|
||||
export { usePersistentState } from "./usePersistentState";
|
||||
export { usePopUp } from "./usePopUp";
|
||||
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
|
||||
// Ref: https://usehooks.com/useDebounce/
|
||||
export const useDebounce = <T extends unknown>(value: T, delay = 500): T => {
|
||||
export const useDebounce = <T extends unknown>(
|
||||
value: T,
|
||||
delay = 500
|
||||
): [T, Dispatch<SetStateAction<T>>] => {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
@@ -22,5 +25,5 @@ export const useDebounce = <T extends unknown>(value: T, delay = 500): T => {
|
||||
[value, delay] // Only re-call effect if value or delay changes
|
||||
);
|
||||
|
||||
return debouncedValue;
|
||||
return [debouncedValue, setDebouncedValue];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useDebounce } from "@app/hooks/useDebounce";
|
||||
|
||||
export const usePagination = <T extends string>(initialOrderBy: T) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
|
||||
const [orderBy, setOrderBy] = useState<T>(initialOrderBy);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebounce(search);
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
|
||||
return {
|
||||
offset,
|
||||
limit: perPage,
|
||||
page,
|
||||
setPage,
|
||||
perPage,
|
||||
setPerPage,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
debouncedSearch,
|
||||
search,
|
||||
setSearch,
|
||||
orderBy,
|
||||
setOrderBy
|
||||
};
|
||||
};
|
||||
@@ -630,6 +630,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/project/${currentWorkspace?.id}/kms`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
isSelected={router.asPath === `/project/${currentWorkspace?.id}/kms`}
|
||||
icon="system-outline-90-lock-closed"
|
||||
>
|
||||
Key Management
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/project/${currentWorkspace?.id}/members`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
@@ -942,7 +952,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</SelectItem>
|
||||
{externalKmsList?.map((kms) => (
|
||||
<SelectItem value={kms.id} key={`kms-${kms.id}`}>
|
||||
{kms.slug}
|
||||
{kms.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -1101,7 +1101,7 @@ const OrganizationPage = () => {
|
||||
</SelectItem>
|
||||
{externalKmsList?.map((kms) => (
|
||||
<SelectItem value={kms.id} key={`kms-${kms.id}`}>
|
||||
{kms.slug}
|
||||
{kms.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { KmsPage } from "@app/views/Project/KmsPage";
|
||||
|
||||
const KMS = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: "KMS" })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Head>
|
||||
<KmsPage />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KMS;
|
||||
|
||||
KMS.requireAuth = true;
|
||||
+4
-1
@@ -36,6 +36,7 @@ const schema = z
|
||||
value: z.string().trim().min(1)
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
.optional()
|
||||
})
|
||||
.required();
|
||||
@@ -88,6 +89,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
identityId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
metadata?: { key: string; value: string }[];
|
||||
customRole: {
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -99,7 +101,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
if (identity) {
|
||||
reset({
|
||||
name: identity.name,
|
||||
role: identity?.customRole?.slug ?? identity.role
|
||||
role: identity?.customRole?.slug ?? identity.role,
|
||||
metadata: identity.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
|
||||
+27
-18
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowDown,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
|
||||
@@ -50,28 +50,35 @@ type Props = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
const INIT_PER_PAGE = 20;
|
||||
|
||||
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
const router = useRouter();
|
||||
const { currentOrg } = useOrganization();
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
|
||||
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
|
||||
const [orderBy, setOrderBy] = useState(OrgIdentityOrderBy.Name);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search);
|
||||
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setPage,
|
||||
setSearch,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
|
||||
|
||||
const organizationId = currentOrg?.id || "";
|
||||
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isLoading, isFetching } = useGetIdentityMembershipOrgs(
|
||||
{
|
||||
organizationId,
|
||||
offset,
|
||||
limit: perPage,
|
||||
limit,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
search: debouncedSearch
|
||||
@@ -79,10 +86,11 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
{ keepPreviousData: true }
|
||||
);
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (data && data.totalCount < offset) setPage(1);
|
||||
}, [data?.totalCount]);
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
const { data: roles } = useGetOrgRoles(organizationId);
|
||||
|
||||
@@ -155,7 +163,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<Th>Role</Th>
|
||||
{/* <Th>
|
||||
<div className="flex items-center">
|
||||
Role
|
||||
<IconButton
|
||||
@@ -174,7 +183,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
</Th> */}
|
||||
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
@@ -277,9 +286,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data && data.totalCount > 0 && (
|
||||
{!isLoading && data && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const OrgAdminProjects = withPermission(
|
||||
() => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search);
|
||||
const [debouncedSearch] = useDebounce(search);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const router = useRouter();
|
||||
const orgAdminAccessProject = useOrgAdminAccessProject();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import { CaTab, CertificatesTab,PkiAlertsTab } from "./components";
|
||||
import { CaTab, CertificatesTab, PkiAlertsTab } from "./components";
|
||||
|
||||
enum TabSections {
|
||||
Ca = "certificate-authorities",
|
||||
@@ -36,5 +36,5 @@ export const CertificatesPage = withProjectPermission(
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Certificates }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { faCheckCircle, faCopy, faInfoCircle, faLockOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { decodeBase64 } from "tweetnacl-util";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { TCmek, useCmekDecrypt } from "@app/hooks/api/cmeks";
|
||||
|
||||
const formSchema = z.object({
|
||||
ciphertext: z.string()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
cmek: TCmek;
|
||||
};
|
||||
|
||||
type FormProps = Pick<Props, "cmek">;
|
||||
|
||||
const DecryptForm = ({ cmek }: FormProps) => {
|
||||
const cmekDecrypt = useCmekDecrypt();
|
||||
const [shouldDecode, setShouldDecode] = useState(false);
|
||||
const [plaintext, setPlaintext] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const [copyCiphertext, isCopyingCiphertext, setCopyCipherText] = useTimedReset<string>({
|
||||
initialState: "Copy to Clipboard"
|
||||
});
|
||||
|
||||
const handleDecryptData = async (formData: FormData) => {
|
||||
try {
|
||||
const data = await cmekDecrypt.mutateAsync({ ...formData, keyId: cmek.id });
|
||||
createNotification({
|
||||
text: "Successfully decrypted data",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
setPlaintext(
|
||||
shouldDecode ? Buffer.from(decodeBase64(data.plaintext)).toString("utf8") : data.plaintext
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to decrypt data",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const text = cmekDecrypt.data?.plaintext;
|
||||
if (!text) return;
|
||||
|
||||
setPlaintext(shouldDecode ? Buffer.from(decodeBase64(text)).toString("utf8") : text);
|
||||
}, [shouldDecode]);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(plaintext ?? "");
|
||||
|
||||
setCopyCipherText("Copied to Clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleDecryptData)}>
|
||||
{plaintext ? (
|
||||
<FormControl label="Decrypted Data (plaintext)">
|
||||
<TextArea
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
isDisabled
|
||||
value={plaintext}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<FormControl
|
||||
label="Encrypted Data (ciphertext)"
|
||||
errorText={errors.ciphertext?.message}
|
||||
isError={Boolean(errors.ciphertext)}
|
||||
>
|
||||
<TextArea
|
||||
{...register("ciphertext")}
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
<Switch
|
||||
id="decode-base-64"
|
||||
isChecked={shouldDecode}
|
||||
onCheckedChange={setShouldDecode}
|
||||
containerClassName="mb-6 ml-0.5 -mt-2.5"
|
||||
>
|
||||
Decode Base64{" "}
|
||||
<Tooltip content="Toggle this switch on if your data was originally plain text.">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className=" text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</Switch>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className={`mr-4 ${plaintext ? "w-44" : ""}`}
|
||||
size="sm"
|
||||
leftIcon={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
plaintext ? (
|
||||
isCopyingCiphertext ? (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
)
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faLockOpen} />
|
||||
)
|
||||
}
|
||||
onClick={plaintext ? handleCopyToClipboard : undefined}
|
||||
type={plaintext ? "button" : "submit"}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{plaintext ? copyCiphertext : "Decrypt"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
{plaintext ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CmekDecryptModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
subTitle={
|
||||
<>
|
||||
Decrypt ciphertext using <span className="font-bold">{cmek?.name}</span>. Returns Base64
|
||||
encoded plaintext.
|
||||
</>
|
||||
}
|
||||
title="Decrypt Data"
|
||||
>
|
||||
<DecryptForm cmek={cmek} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheckCircle, faCopy, faInfoCircle, faLock } 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,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Switch,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { TCmek, useCmekEncrypt } from "@app/hooks/api/cmeks";
|
||||
|
||||
const formSchema = z.object({
|
||||
plaintext: z.string(),
|
||||
isBase64Encoded: z.boolean()
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
cmek: TCmek;
|
||||
};
|
||||
|
||||
type FormProps = Pick<Props, "cmek">;
|
||||
|
||||
const EncryptForm = ({ cmek }: FormProps) => {
|
||||
const cmekEncrypt = useCmekEncrypt();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
control,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
isBase64Encoded: false
|
||||
}
|
||||
});
|
||||
|
||||
const [copyCiphertext, isCopyingCiphertext, setCopyCipherText] = useTimedReset<string>({
|
||||
initialState: "Copy to Clipboard"
|
||||
});
|
||||
|
||||
const handleEncryptData = async (formData: FormData) => {
|
||||
try {
|
||||
await cmekEncrypt.mutateAsync({ ...formData, keyId: cmek.id });
|
||||
createNotification({
|
||||
text: "Successfully encrypted data",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to encrypt data",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ciphertext = cmekEncrypt.data?.ciphertext;
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
navigator.clipboard.writeText(ciphertext ?? "");
|
||||
|
||||
setCopyCipherText("Copied to Clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleEncryptData)}>
|
||||
{ciphertext ? (
|
||||
<FormControl label="Encrypted Data (Ciphertext)">
|
||||
<TextArea
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
isDisabled
|
||||
value={cmekEncrypt.data?.ciphertext}
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<>
|
||||
<FormControl
|
||||
label="Data (Plaintext)"
|
||||
errorText={errors.plaintext?.message}
|
||||
isError={Boolean(errors.plaintext)}
|
||||
>
|
||||
<TextArea
|
||||
{...register("plaintext")}
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
/>
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="isBase64Encoded"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch
|
||||
id="encode-base-64"
|
||||
isChecked={value}
|
||||
onCheckedChange={onChange}
|
||||
containerClassName="mb-6 ml-0.5 -mt-2.5"
|
||||
>
|
||||
Data is Base64 encoded{" "}
|
||||
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className=" text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</Switch>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className={`mr-4 ${ciphertext ? "w-44" : ""}`}
|
||||
size="sm"
|
||||
leftIcon={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
ciphertext ? (
|
||||
isCopyingCiphertext ? (
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
)
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faLock} />
|
||||
)
|
||||
}
|
||||
onClick={ciphertext ? handleCopyToClipboard : undefined}
|
||||
type={ciphertext ? "button" : "submit"}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{ciphertext ? copyCiphertext : "Encrypt"}
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
{ciphertext ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CmekEncryptModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Encrypt Data"
|
||||
subTitle={
|
||||
<>
|
||||
Encrypt data using <span className="font-bold">{cmek?.name}</span>. Returns Base64
|
||||
encoded ciphertext.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<EncryptForm cmek={cmek} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
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";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.toLowerCase()
|
||||
.max(32)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Name must be in slug format"
|
||||
}),
|
||||
description: z.string().max(500).optional(),
|
||||
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
|
||||
});
|
||||
|
||||
export type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
cmek?: TCmek | null;
|
||||
};
|
||||
|
||||
type FormProps = Pick<Props, "cmek"> & {
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
const CmekForm = ({ onComplete, cmek }: FormProps) => {
|
||||
const createCmek = useCreateCmek();
|
||||
const updateCmek = useUpdateCmek();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const projectId = currentWorkspace?.id!;
|
||||
const isUpdate = !!cmek;
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: cmek?.name,
|
||||
description: cmek?.description,
|
||||
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
|
||||
const mutation = isUpdate
|
||||
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
|
||||
: createCmek.mutateAsync({
|
||||
projectId,
|
||||
encryptionAlgorithm,
|
||||
name,
|
||||
description
|
||||
});
|
||||
|
||||
try {
|
||||
await mutation;
|
||||
createNotification({
|
||||
text: `Successfully ${isUpdate ? "updated" : "added"} key`,
|
||||
type: "success"
|
||||
});
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: `Failed to ${isUpdate ? "update" : "add"} key`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateCmek)}>
|
||||
<FormControl
|
||||
helperText="Name must be slug-friendly"
|
||||
errorText={errors.name?.message}
|
||||
isError={Boolean(errors.name?.message)}
|
||||
label="Name"
|
||||
>
|
||||
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
|
||||
</FormControl>
|
||||
{!isUpdate && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="encryptionAlgorithm"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
|
||||
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
|
||||
<SelectItem value={value} key={`source-environment-${key}`}>
|
||||
{key.replaceAll("_", "-")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl
|
||||
label="Description (optional)"
|
||||
errorText={errors.description?.message}
|
||||
isError={Boolean(errors.description?.message)}
|
||||
>
|
||||
<TextArea
|
||||
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
|
||||
{...register("description")}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isUpdate ? "Update" : "Add"} Key
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CmekModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent title={`${cmek ? "Update" : "Add"} Key`}>
|
||||
<CmekForm onComplete={() => onOpenChange(false)} cmek={cmek} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faArrowUpRightFromSquare,
|
||||
faCancel,
|
||||
faCheckCircle,
|
||||
faEdit,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faKey,
|
||||
faLock,
|
||||
faLockOpen,
|
||||
faMagnifyingGlass,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { BadgeProps } from "@app/components/v2/Badge/Badge";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePagination, usePopUp } from "@app/hooks";
|
||||
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
|
||||
import { CmekDecryptModal } from "./CmekDecryptModal";
|
||||
import { CmekEncryptModal } from "./CmekEncryptModal";
|
||||
import { CmekModal } from "./CmekModal";
|
||||
import { DeleteCmekModal } from "./DeleteCmekModal";
|
||||
|
||||
const getStatusBadgeProps = (
|
||||
isDisabled: boolean
|
||||
): { variant: BadgeProps["variant"]; label: string } => {
|
||||
if (isDisabled) {
|
||||
return {
|
||||
variant: "danger",
|
||||
label: "Disabled"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
variant: "success",
|
||||
label: "Active"
|
||||
};
|
||||
};
|
||||
|
||||
export const CmekTable = () => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const projectId = currentWorkspace?.id ?? "";
|
||||
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setPage,
|
||||
setSearch,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(CmekOrderBy.Name);
|
||||
|
||||
const { data, isLoading, isFetching } = useGetCmeksByProjectId({
|
||||
projectId,
|
||||
offset,
|
||||
limit,
|
||||
search: debouncedSearch,
|
||||
orderBy,
|
||||
orderDirection
|
||||
});
|
||||
|
||||
const { keys = [], totalCount = 0 } = data ?? {};
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"upsertKey",
|
||||
"deleteKey",
|
||||
"encryptData",
|
||||
"decryptData"
|
||||
] as const);
|
||||
|
||||
const handleSort = () => {
|
||||
setOrderDirection((prev) =>
|
||||
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
||||
);
|
||||
};
|
||||
|
||||
const updateCmek = useUpdateCmek();
|
||||
|
||||
const handleDisableCmek = async ({ id: keyId, isDisabled }: TCmek) => {
|
||||
try {
|
||||
await updateCmek.mutateAsync({
|
||||
keyId,
|
||||
projectId,
|
||||
isDisabled: !isDisabled
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text =
|
||||
error?.response?.data?.message ?? `Failed to ${isDisabled ? "enable" : "disable"} key`;
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cannotEditKey = permission.cannot(
|
||||
ProjectPermissionCmekActions.Edit,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
const cannotDeleteKey = permission.cannot(
|
||||
ProjectPermissionCmekActions.Delete,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
const cannotEncryptData = permission.cannot(
|
||||
ProjectPermissionCmekActions.Encrypt,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
const cannotDecryptData = permission.cannot(
|
||||
ProjectPermissionCmekActions.Decrypt,
|
||||
ProjectPermissionSub.Cmek
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="kms-keys-tab"
|
||||
transition={{ duration: 0.15 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
>
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="whitespace-nowrap text-xl font-semibold text-mineshaft-100">Keys</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/kms/infisical-kms">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Cmek}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("upsertKey", null)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<Input
|
||||
containerClassName="mb-4"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search keys by name..."
|
||||
/>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="h-14">
|
||||
<Th className="w-1/3">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={handleSort}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>Algorithm</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>Version</Th>
|
||||
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="project-keys" />}
|
||||
{!isLoading &&
|
||||
keys.length > 0 &&
|
||||
keys.map((cmek) => {
|
||||
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
|
||||
const { variant, label } = getStatusBadgeProps(isDisabled);
|
||||
|
||||
return (
|
||||
<Tr className="group h-10 hover:bg-mineshaft-700" key={`st-v3-${id}`}>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{name}
|
||||
{description && (
|
||||
<Tooltip content={description}>
|
||||
<FontAwesomeIcon
|
||||
icon={faInfoCircle}
|
||||
className=" text-mineshaft-400"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="uppercase">{encryptionAlgorithm}</Td>
|
||||
<Td>
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
</Td>
|
||||
<Td>{version}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="plain"
|
||||
colorSchema="primary"
|
||||
className="ml-4 p-0 data-[state=open]:text-primary-400"
|
||||
ariaLabel="More options"
|
||||
>
|
||||
<FontAwesomeIcon size="lg" icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[160px]">
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotEncryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("encryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLock} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotEncryptData || isDisabled}
|
||||
>
|
||||
Encrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
cannotDecryptData
|
||||
? "Access Restricted"
|
||||
: isDisabled
|
||||
? "Key Disabled"
|
||||
: ""
|
||||
}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("decryptData", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faLockOpen} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotDecryptData || isDisabled}
|
||||
>
|
||||
Decrypt Data
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={cannotEditKey ? "Access Restricted" : ""}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("upsertKey", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faEdit} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotEditKey}
|
||||
>
|
||||
Edit Key
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={cannotEditKey ? "Access Restricted" : ""}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDisableCmek(cmek)}
|
||||
icon={
|
||||
<FontAwesomeIcon icon={isDisabled ? faCheckCircle : faCancel} />
|
||||
}
|
||||
iconPos="left"
|
||||
isDisabled={cannotEditKey}
|
||||
>
|
||||
{isDisabled ? "Enable" : "Disable"} Key
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={cannotDeleteKey ? "Access Restricted" : ""}
|
||||
position="left"
|
||||
>
|
||||
<div>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handlePopUpOpen("deleteKey", cmek)}
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
iconPos="left"
|
||||
isDisabled={cannotDeleteKey}
|
||||
>
|
||||
Delete Key
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && keys.length === 0 && (
|
||||
<EmptyState
|
||||
title={
|
||||
debouncedSearch.trim().length > 0
|
||||
? "No keys match search filter"
|
||||
: "No keys have been added to this project"
|
||||
}
|
||||
icon={faKey}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
<DeleteCmekModal
|
||||
isOpen={popUp.deleteKey.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("deleteKey", isOpen)}
|
||||
cmek={popUp.deleteKey.data as TCmek}
|
||||
/>
|
||||
<CmekModal
|
||||
isOpen={popUp.upsertKey.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upsertKey", isOpen)}
|
||||
cmek={popUp.upsertKey.data as TCmek | null}
|
||||
/>
|
||||
<CmekEncryptModal
|
||||
isOpen={popUp.encryptData.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("encryptData", isOpen)}
|
||||
cmek={popUp.encryptData.data as TCmek}
|
||||
/>
|
||||
<CmekDecryptModal
|
||||
isOpen={popUp.decryptData.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
|
||||
cmek={popUp.decryptData.data as TCmek}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { TCmek, useDeleteCmek } from "@app/hooks/api/cmeks";
|
||||
|
||||
type Props = {
|
||||
cmek: TCmek;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteCmekModal = ({ isOpen, onOpenChange, cmek }: Props) => {
|
||||
const deleteCmek = useDeleteCmek();
|
||||
|
||||
if (!cmek) return null;
|
||||
|
||||
const { id: keyId, projectId, name } = cmek;
|
||||
|
||||
const handleDeleteCmek = async () => {
|
||||
try {
|
||||
await deleteCmek.mutateAsync({
|
||||
keyId,
|
||||
projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Key successfully deleted",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to delete key";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteActionModal
|
||||
isOpen={isOpen}
|
||||
title={`Are you sure want to delete ${name}?`}
|
||||
onChange={onOpenChange}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={handleDeleteCmek}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CmekTable";
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
|
||||
import { CmekTable } from "./components";
|
||||
|
||||
export const KmsPage = withProjectPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||
<p className="mr-4 text-3xl font-semibold text-white">Key Management System</p>
|
||||
<p className="text-md mb-4 text-bunker-300">
|
||||
Manage keys and perform cryptographic operations.
|
||||
</p>
|
||||
<CmekTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{
|
||||
action: ProjectPermissionCmekActions.Read,
|
||||
subject: ProjectPermissionSub.Cmek
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowDown,
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
@@ -56,7 +56,7 @@ import { IdentityModal } from "./components/IdentityModal";
|
||||
import { IdentityRoleForm } from "./components/IdentityRoleForm";
|
||||
|
||||
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
|
||||
const INIT_PER_PAGE = 20;
|
||||
|
||||
const formatRoleName = (role: string, customRoleName?: string) => {
|
||||
if (role === ProjectMembershipRole.Custom) return customRoleName;
|
||||
if (role === ProjectMembershipRole.Member) return "Developer";
|
||||
@@ -67,27 +67,43 @@ export const IdentityTab = withProjectPermission(
|
||||
() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
|
||||
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
|
||||
const [orderBy, setOrderBy] = useState(ProjectIdentityOrderBy.Name);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search);
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
search,
|
||||
debouncedSearch,
|
||||
setPage,
|
||||
setSearch,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage
|
||||
} = usePagination(ProjectIdentityOrderBy.Name);
|
||||
|
||||
const workspaceId = currentWorkspace?.id ?? "";
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isLoading, isFetching } = useGetWorkspaceIdentityMemberships(
|
||||
{
|
||||
workspaceId: currentWorkspace?.id || "",
|
||||
offset,
|
||||
limit: perPage,
|
||||
limit,
|
||||
orderDirection,
|
||||
orderBy,
|
||||
search: debouncedSearch
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
);
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -122,11 +138,6 @@ export const IdentityTab = withProjectPermission(
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (data && data.totalCount < offset) setPage(1);
|
||||
}, [data?.totalCount]);
|
||||
|
||||
const handleSort = (column: ProjectIdentityOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
setOrderDirection((prev) =>
|
||||
@@ -369,9 +380,9 @@ export const IdentityTab = withProjectPermission(
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data && data.totalCount > 0 && (
|
||||
{!isLoading && data && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
|
||||
+46
-4
@@ -1,6 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context";
|
||||
import {
|
||||
PermissionConditionOperators,
|
||||
TPermissionCondition,
|
||||
@@ -15,6 +19,15 @@ const GeneralPolicyActionSchema = z.object({
|
||||
create: z.boolean().optional()
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
create: z.boolean().optional(),
|
||||
encrypt: z.boolean().optional(),
|
||||
decrypt: z.boolean().optional()
|
||||
});
|
||||
|
||||
const SecretFolderPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional()
|
||||
});
|
||||
@@ -88,7 +101,8 @@ export const formSchema = z.object({
|
||||
[ProjectPermissionSub.Workspace]: WorkspacePolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([])
|
||||
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
|
||||
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([])
|
||||
})
|
||||
.partial()
|
||||
.optional()
|
||||
@@ -119,7 +133,7 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
|
||||
return formConditions;
|
||||
};
|
||||
|
||||
// convert role permission to form compatiable data structure
|
||||
// convert role permission to form compatible data structure
|
||||
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
const formVal: Partial<TFormSchema["permissions"]> = {};
|
||||
|
||||
@@ -198,6 +212,23 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
|
||||
} else if (subject === ProjectPermissionSub.Cmek) {
|
||||
const canRead = action.includes(ProjectPermissionCmekActions.Read);
|
||||
const canEdit = action.includes(ProjectPermissionCmekActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionCmekActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
|
||||
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
|
||||
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
|
||||
|
||||
if (!formVal[subject]) formVal[subject] = [{}];
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
if (canRead) formVal[subject]![0].read = true;
|
||||
if (canEdit) formVal[subject]![0].edit = true;
|
||||
if (canCreate) formVal[subject]![0].create = true;
|
||||
if (canDelete) formVal[subject]![0].delete = true;
|
||||
if (canEncrypt) formVal[subject]![0].encrypt = true;
|
||||
if (canDecrypt) formVal[subject]![0].decrypt = true;
|
||||
}
|
||||
});
|
||||
return formVal;
|
||||
@@ -277,8 +308,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
title: "Secret Folders",
|
||||
actions: [{ label: "Read Only", value: "read" }]
|
||||
},
|
||||
[ProjectPermissionSub.Kms]: {
|
||||
[ProjectPermissionSub.Cmek]: {
|
||||
title: "KMS",
|
||||
actions: [
|
||||
{ label: "Read", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" },
|
||||
{ label: "Encrypt", value: "encrypt" },
|
||||
{ label: "Decrypt", value: "decrypt" }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.Kms]: {
|
||||
title: "Project KMS Configuration",
|
||||
actions: [{ label: "Modify", value: "edit" }]
|
||||
},
|
||||
[ProjectPermissionSub.Integrations]: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecretsSingleEnv,
|
||||
useGetSecretApprovalPolicyOfABoard,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useGetWsTags
|
||||
} from "@app/hooks/api";
|
||||
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
|
||||
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView";
|
||||
import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView";
|
||||
@@ -47,7 +48,6 @@ const LOADER_TEXT = [
|
||||
"Getting secret import links..."
|
||||
];
|
||||
|
||||
const INIT_PER_PAGE = 20;
|
||||
export const SecretMainPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
|
||||
@@ -55,11 +55,18 @@ export const SecretMainPage = () => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
|
||||
const paginationOffset = (page - 1) * perPage;
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
setPage,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage,
|
||||
orderBy
|
||||
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
|
||||
|
||||
const [snapshotId, setSnapshotId] = useState<string | null>(null);
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
@@ -79,7 +86,7 @@ export const SecretMainPage = () => {
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
const defaultFilterState = {
|
||||
tags: {},
|
||||
searchFilter: (router.query.searchFilter as string) || "",
|
||||
include: {
|
||||
@@ -88,8 +95,11 @@ export const SecretMainPage = () => {
|
||||
[RowType.DynamicSecret]: canReadSecret,
|
||||
[RowType.Secret]: canReadSecret
|
||||
}
|
||||
});
|
||||
const debouncedSearchFilter = useDebounce(filter.searchFilter);
|
||||
};
|
||||
|
||||
const [filter, setFilter] = useState<Filter>(defaultFilterState);
|
||||
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
|
||||
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
|
||||
|
||||
// change filters if permissions change at different paths/env
|
||||
useEffect(() => {
|
||||
@@ -126,8 +136,9 @@ export const SecretMainPage = () => {
|
||||
environment,
|
||||
projectId: workspaceId,
|
||||
secretPath,
|
||||
offset: paginationOffset,
|
||||
limit: perPage,
|
||||
offset,
|
||||
limit,
|
||||
orderBy,
|
||||
search: debouncedSearchFilter,
|
||||
orderDirection,
|
||||
includeImports: canReadSecret && filter.include.import,
|
||||
@@ -149,6 +160,11 @@ export const SecretMainPage = () => {
|
||||
totalCount = 0
|
||||
} = data ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
// fetch imported secrets to show user the overriden ones
|
||||
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
|
||||
projectId: workspaceId,
|
||||
@@ -251,14 +267,38 @@ export const SecretMainPage = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount < paginationOffset) setPage(1);
|
||||
}, [totalCount]);
|
||||
// restore filters for path if set
|
||||
const restore = filterHistory.get(secretPath);
|
||||
setFilter(restore ?? defaultFilterState);
|
||||
setDebouncedSearchFilter(restore?.searchFilter ?? "");
|
||||
const { searchFilter, ...query } = router.query;
|
||||
|
||||
// this is a temp work around until we fully transition state to query params,
|
||||
// setting the initial search filter by query and then moving it to internal state
|
||||
if (router.query.searchFilter) {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query
|
||||
});
|
||||
}
|
||||
}, [secretPath]);
|
||||
|
||||
if (isDetailsLoading) {
|
||||
return <ContentLoader text={LOADER_TEXT} />;
|
||||
}
|
||||
|
||||
const handleResetFilter = () => {
|
||||
// store for breadcrumb nav to restore previously used filters
|
||||
setFilterHistory((prev) => {
|
||||
const curr = new Map(prev);
|
||||
curr.set(secretPath, filter);
|
||||
return curr;
|
||||
});
|
||||
|
||||
setFilter(defaultFilterState);
|
||||
setDebouncedSearchFilter("");
|
||||
};
|
||||
|
||||
return (
|
||||
<StoreProvider>
|
||||
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
@@ -339,6 +379,7 @@ export const SecretMainPage = () => {
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
onNavigateToFolder={handleResetFilter}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && dynamicSecrets?.length && (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { TypeOptions } from "react-toastify";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
@@ -56,7 +55,6 @@ import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType, SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { debounce } from "@app/lib/fn/debounce";
|
||||
|
||||
import {
|
||||
PopUpNames,
|
||||
@@ -118,7 +116,6 @@ export const ActionBar = ({
|
||||
] as const);
|
||||
const { subscription } = useSubscription();
|
||||
const { openPopUp } = usePopUpAction();
|
||||
const [search, setSearch] = useState(filter.searchFilter);
|
||||
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
@@ -134,8 +131,6 @@ export const ActionBar = ({
|
||||
(rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
|
||||
);
|
||||
|
||||
const debouncedOnSearch = debounce(onSearchChange, 500);
|
||||
|
||||
const handleFolderCreate = async (folderName: string) => {
|
||||
try {
|
||||
await createFolder({
|
||||
@@ -286,11 +281,15 @@ export const ActionBar = ({
|
||||
<Input
|
||||
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by folder name, key name, comment..."
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
value={search}
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
className={filter.searchFilter ? "text-primary" : ""}
|
||||
icon={faMagnifyingGlass}
|
||||
/>
|
||||
}
|
||||
value={filter.searchFilter}
|
||||
onChange={(evt) => {
|
||||
setSearch(evt.target.value);
|
||||
debouncedOnSearch(evt.target.value);
|
||||
onSearchChange(evt.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+27
-1
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { DiRedis } from "react-icons/di";
|
||||
import { SiApachecassandra, SiElasticsearch, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si";
|
||||
import { SiApachecassandra, SiElasticsearch, SiFiles, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si";
|
||||
import { faAws } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faDatabase } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -14,6 +14,7 @@ import { AwsIamInputForm } from "./AwsIamInputForm";
|
||||
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
|
||||
import { CassandraInputForm } from "./CassandraInputForm";
|
||||
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
|
||||
import { LdapInputForm } from "./LdapInputForm";
|
||||
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
|
||||
import { MongoDBDatabaseInputForm } from "./MongoDBInputForm";
|
||||
import { RabbitMqInputForm } from "./RabbitMqInputForm";
|
||||
@@ -83,6 +84,11 @@ const DYNAMIC_SECRET_LIST = [
|
||||
icon: <SiMicrosoftazure size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.AzureEntraId,
|
||||
title: "Azure Entra ID",
|
||||
},
|
||||
{
|
||||
icon: <SiFiles size="1.5rem" />,
|
||||
provider: DynamicSecretProviders.Ldap,
|
||||
title: "LDAP",
|
||||
}
|
||||
];
|
||||
|
||||
@@ -325,6 +331,26 @@ export const CreateDynamicSecretForm = ({
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
{wizardStep === WizardSteps.ProviderInputs &&
|
||||
selectedProvider === DynamicSecretProviders.Ldap && (
|
||||
<motion.div
|
||||
key="dynamic-ldap-step"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<LdapInputForm
|
||||
onCompleted={handleFormReset}
|
||||
onCancel={handleFormReset}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
</AnimatePresence>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import Link from "next/link";
|
||||
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
provider: z.object({
|
||||
url: z.string().trim().min(1),
|
||||
binddn: z.string().trim().min(1),
|
||||
bindpass: z.string().trim().min(1),
|
||||
ca: z.string().optional(),
|
||||
|
||||
creationLdif: z.string().min(1),
|
||||
revocationLdif: z.string().min(1),
|
||||
rollbackLdif: z.string().optional()
|
||||
}),
|
||||
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
});
|
||||
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onCompleted: () => void;
|
||||
onCancel: () => void;
|
||||
secretPath: string;
|
||||
projectSlug: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const LdapInputForm = ({
|
||||
onCompleted,
|
||||
onCancel,
|
||||
secretPath,
|
||||
projectSlug,
|
||||
environment
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
provider: {
|
||||
url: "",
|
||||
binddn: "",
|
||||
bindpass: "",
|
||||
ca: "",
|
||||
creationLdif: "",
|
||||
revocationLdif: "",
|
||||
rollbackLdif: ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (createDynamicSecret.isLoading) return;
|
||||
try {
|
||||
await createDynamicSecret.mutateAsync({
|
||||
provider: { type: DynamicSecretProviders.Ldap, inputs: provider },
|
||||
maxTTL,
|
||||
name,
|
||||
path: secretPath,
|
||||
defaultTTL,
|
||||
projectSlug,
|
||||
environmentSlug: environment
|
||||
});
|
||||
onCompleted();
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="dynamic-secret" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
defaultValue="1h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
defaultValue="24h"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
<Link
|
||||
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/ldap"
|
||||
passHref
|
||||
>
|
||||
<a target="_blank" rel="noopener noreferrer">
|
||||
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||
Docs
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="ml-1.5 mb-[0.07rem] text-xxs"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.url"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="URL"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.binddn"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bind DN"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.bindpass"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bind Password"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="CA"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} placeholder="-----BEGIN CERTIFICATE----- ..." />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.creationLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Creation LDIF"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.revocationLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Revocation LDIF"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="provider.rollbackLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Rollback LDIF"
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
+27
@@ -194,6 +194,33 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === DynamicSecretProviders.Ldap) {
|
||||
const { USERNAME, PASSWORD, DN_ARRAY } = data as {
|
||||
USERNAME: string;
|
||||
PASSWORD: string;
|
||||
DN_ARRAY: string[];
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<OutputDisplay label="Username" value={USERNAME} />
|
||||
<OutputDisplay
|
||||
label="Password"
|
||||
value={PASSWORD}
|
||||
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
|
||||
/>
|
||||
<FormControl label="DNs" className="flex-grow">
|
||||
<SecretInput
|
||||
isReadOnly
|
||||
isVisible
|
||||
value={JSON.stringify(DN_ARRAY)}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
+19
@@ -9,6 +9,7 @@ import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
|
||||
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
||||
import { EditDynamicSecretMongoDBForm } from "./EditDynamicSecretMongoDBForm";
|
||||
import { EditDynamicSecretRabbitMqForm } from "./EditDynamicSecretRabbitMqForm";
|
||||
@@ -221,6 +222,24 @@ export const EditDynamicSecretForm = ({
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{dynamicSecretDetails?.type === DynamicSecretProviders.Ldap && (
|
||||
<motion.div
|
||||
key="ldap-edit"
|
||||
transition={{ duration: 0.1 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<EditDynamicSecretLdapForm
|
||||
onClose={onClose}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecret={dynamicSecretDetails}
|
||||
environment={environment}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
|
||||
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
inputs: z
|
||||
.object({
|
||||
url: z.string().trim().min(1),
|
||||
binddn: z.string().trim().min(1),
|
||||
bindpass: z.string().trim().min(1),
|
||||
ca: z.string().optional(),
|
||||
creationLdif: z.string().min(1),
|
||||
revocationLdif: z.string().min(1),
|
||||
rollbackLdif: z.string().optional()
|
||||
})
|
||||
.partial(),
|
||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
.optional()
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
// a day
|
||||
if (valMs > 24 * 60 * 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
})
|
||||
.nullable(),
|
||||
newName: z
|
||||
.string()
|
||||
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||
.optional()
|
||||
});
|
||||
type TForm = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
export const EditDynamicSecretLdapForm = ({
|
||||
onClose,
|
||||
dynamicSecret,
|
||||
environment,
|
||||
secretPath,
|
||||
projectSlug
|
||||
}: Props) => {
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
defaultTTL: dynamicSecret.defaultTTL,
|
||||
maxTTL: dynamicSecret.maxTTL,
|
||||
newName: dynamicSecret.name,
|
||||
inputs: {
|
||||
...(dynamicSecret.inputs as TForm["inputs"])
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||
|
||||
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
|
||||
// wait till previous request is finished
|
||||
if (updateDynamicSecret.isLoading) return;
|
||||
try {
|
||||
await updateDynamicSecret.mutateAsync({
|
||||
name: dynamicSecret.name,
|
||||
path: secretPath,
|
||||
projectSlug,
|
||||
environmentSlug: environment,
|
||||
data: {
|
||||
maxTTL: maxTTL || undefined,
|
||||
defaultTTL,
|
||||
inputs,
|
||||
newName: newName === dynamicSecret.name ? undefined : newName
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully updated dynamic secret"
|
||||
});
|
||||
} catch (err) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update dynamic secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-grow">
|
||||
<Controller
|
||||
control={control}
|
||||
name="newName"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="DYN-1" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="defaultTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Default TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<Controller
|
||||
control={control}
|
||||
name="maxTTL"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={<TtlFormLabel label="Max TTL" />}
|
||||
isError={Boolean(error?.message)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.url"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="URL" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.binddn"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Bind DN" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.bindpass"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Bind Password"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.ca"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="CA" isError={Boolean(error)} errorText={error?.message}>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.creationLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Creation LDIF"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.revocationLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Revocation LDIF"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="inputs.rollbackLdif"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Rollback LDIF"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<TextArea {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center space-x-4">
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="outline_bg" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -18,13 +18,15 @@ type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
};
|
||||
|
||||
export const FolderListView = ({
|
||||
folders = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/"
|
||||
secretPath = "/",
|
||||
onNavigateToFolder
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"updateFolder",
|
||||
@@ -88,13 +90,16 @@ export const FolderListView = ({
|
||||
};
|
||||
|
||||
const handleFolderClick = (name: string) => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: `${router.query?.secretPath || ""}/${name}`
|
||||
}
|
||||
});
|
||||
const path = `${router.query?.secretPath || ""}/${name}`;
|
||||
router
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: path
|
||||
}
|
||||
})
|
||||
.then(() => onNavigateToFolder(path));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ export const CopySecretsFromBoard = ({
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
|
||||
const [debouncedEnvCopySecretPath] = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useDebounce, usePopUp } from "@app/hooks";
|
||||
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretV3,
|
||||
@@ -95,18 +95,21 @@ enum RowType {
|
||||
type Filter = {
|
||||
[key in RowType]: boolean;
|
||||
};
|
||||
const INIT_PER_PAGE = 20;
|
||||
|
||||
const DEFAULT_FILTER_STATE = {
|
||||
[RowType.Folder]: true,
|
||||
[RowType.DynamicSecret]: true,
|
||||
[RowType.Secret]: true
|
||||
};
|
||||
|
||||
export const SecretOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// this is to set expandable table width
|
||||
// coz when overflow the table goes to the right
|
||||
const parentTableRef = useRef<HTMLTableElement>(null);
|
||||
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
|
||||
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,14 +124,13 @@ export const SecretOverviewPage = () => {
|
||||
const workspaceId = currentWorkspace?.id as string;
|
||||
const projectSlug = currentWorkspace?.slug as string;
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const debouncedSearchFilter = useDebounce(searchFilter);
|
||||
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(searchFilter);
|
||||
const secretPath = (router.query?.secretPath as string) || "/";
|
||||
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
[RowType.Folder]: true,
|
||||
[RowType.DynamicSecret]: true,
|
||||
[RowType.Secret]: true
|
||||
});
|
||||
const [filter, setFilter] = useState<Filter>(DEFAULT_FILTER_STATE);
|
||||
const [filterHistory, setFilterHistory] = useState<
|
||||
Map<string, { filter: Filter; searchFilter: string }>
|
||||
>(new Map());
|
||||
|
||||
const [selectedEntries, setSelectedEntries] = useState<{
|
||||
[EntryType.FOLDER]: Record<string, boolean>;
|
||||
@@ -138,8 +140,17 @@ export const SecretOverviewPage = () => {
|
||||
[EntryType.SECRET]: {}
|
||||
});
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
|
||||
const {
|
||||
offset,
|
||||
limit,
|
||||
orderDirection,
|
||||
setOrderDirection,
|
||||
setPage,
|
||||
perPage,
|
||||
page,
|
||||
setPerPage,
|
||||
orderBy
|
||||
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
|
||||
|
||||
const toggleSelectedEntry = useCallback(
|
||||
(type: EntryType, key: string) => {
|
||||
@@ -195,7 +206,7 @@ export const SecretOverviewPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleEnvs(userAvailableEnvs);
|
||||
}, [userAvailableEnvs, secretPath]);
|
||||
}, [userAvailableEnvs]);
|
||||
|
||||
const { isImportedSecretPresentInEnv, getImportedSecretByKey, getEnvImportedSecretKeyCount } =
|
||||
useGetImportedSecretsAllEnvs({
|
||||
@@ -204,21 +215,19 @@ export const SecretOverviewPage = () => {
|
||||
environments: userAvailableEnvs.map(({ slug }) => slug)
|
||||
});
|
||||
|
||||
const paginationOffset = (page - 1) * perPage;
|
||||
|
||||
const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
|
||||
{
|
||||
projectId: workspaceId,
|
||||
environments: visibleEnvs.map((env) => env.slug),
|
||||
secretPath,
|
||||
orderDirection,
|
||||
orderBy: DashboardSecretsOrderBy.Name,
|
||||
orderBy,
|
||||
includeFolders: filter.folder,
|
||||
includeDynamicSecrets: filter.dynamic,
|
||||
includeSecrets: filter.secret,
|
||||
search: debouncedSearchFilter,
|
||||
limit: perPage,
|
||||
offset: paginationOffset
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
{ enabled: isProjectV3 }
|
||||
);
|
||||
@@ -227,12 +236,17 @@ export const SecretOverviewPage = () => {
|
||||
secrets,
|
||||
folders,
|
||||
dynamicSecrets,
|
||||
totalCount = 0,
|
||||
totalFolderCount,
|
||||
totalSecretCount,
|
||||
totalDynamicSecretCount
|
||||
totalDynamicSecretCount,
|
||||
totalCount = 0
|
||||
} = overview ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
|
||||
|
||||
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
|
||||
@@ -456,16 +470,35 @@ export const SecretOverviewPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetSearch = () => setSearchFilter("");
|
||||
const handleResetSearch = (path: string) => {
|
||||
const restore = filterHistory.get(path);
|
||||
setFilter(restore?.filter ?? DEFAULT_FILTER_STATE);
|
||||
const search = restore?.searchFilter ?? "";
|
||||
setSearchFilter(search);
|
||||
setDebouncedSearchFilter(search);
|
||||
};
|
||||
|
||||
const handleFolderClick = (path: string) => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: `${router.query?.secretPath || ""}/${path}`
|
||||
}
|
||||
// store for breadcrumb nav to restore previously used filters
|
||||
setFilterHistory((prev) => {
|
||||
const curr = new Map(prev);
|
||||
curr.set(secretPath, { filter, searchFilter });
|
||||
return curr;
|
||||
});
|
||||
|
||||
router
|
||||
.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: `${router.query?.secretPath || ""}/${path}`
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
setFilter(DEFAULT_FILTER_STATE);
|
||||
setSearchFilter("");
|
||||
setDebouncedSearchFilter("");
|
||||
});
|
||||
};
|
||||
|
||||
const handleExploreEnvClick = async (slug: string) => {
|
||||
@@ -507,11 +540,6 @@ export const SecretOverviewPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount < paginationOffset) setPage(1);
|
||||
}, [totalCount]);
|
||||
|
||||
const handleToggleRowType = useCallback(
|
||||
(rowType: RowType) =>
|
||||
setFilter((state) => {
|
||||
@@ -544,7 +572,9 @@ export const SecretOverviewPage = () => {
|
||||
|
||||
const isTableEmpty = totalCount === 0;
|
||||
|
||||
const isTableFiltered = Boolean(Object.values(filter).filter((enabled) => !enabled).length);
|
||||
const isTableFiltered =
|
||||
Boolean(Object.values(filter).filter((enabled) => !enabled).length) ||
|
||||
userAvailableEnvs.length !== visibleEnvs.length;
|
||||
|
||||
if (!isProjectV3)
|
||||
return (
|
||||
@@ -706,7 +736,12 @@ export const SecretOverviewPage = () => {
|
||||
placeholder="Search by secret/folder name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className={searchFilter ? "text-primary" : ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{userAvailableEnvs.length > 0 && (
|
||||
|
||||
+5
-5
@@ -4,16 +4,16 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
type Props = {
|
||||
secretPath: string;
|
||||
onResetSearch: () => void;
|
||||
onResetSearch: (path: string) => void;
|
||||
};
|
||||
|
||||
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const onFolderCrumbClick = (index: number) => {
|
||||
const newSecPath = secretPath.split("/").filter(Boolean).slice(0, index).join("/");
|
||||
if (secretPath === `/${newSecPath}`) return;
|
||||
const query = { ...router.query, secretPath: `/${newSecPath}` } as Record<string, string>;
|
||||
const newSecPath = `/${secretPath.split("/").filter(Boolean).slice(0, index).join("/")}`;
|
||||
if (secretPath === newSecPath) return;
|
||||
const query = { ...router.query, secretPath: newSecPath } as Record<string, string>;
|
||||
// root condition
|
||||
if (index === 0) delete query.secretPath;
|
||||
router
|
||||
@@ -21,7 +21,7 @@ export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) =>
|
||||
pathname: router.pathname,
|
||||
query
|
||||
})
|
||||
.then(() => onResetSearch());
|
||||
.then(() => onResetSearch(newSecPath));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+5
-5
@@ -61,7 +61,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
} = useForm<AddExternalKmsType>({
|
||||
resolver: zodResolver(AddExternalKmsSchema),
|
||||
defaultValues: {
|
||||
slug: kms?.slug,
|
||||
name: kms?.name,
|
||||
description: kms?.description ?? "",
|
||||
provider: {
|
||||
type: ExternalKmsProvider.AWS,
|
||||
@@ -89,12 +89,12 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
const selectedAwsAuthType = watch("provider.inputs.credential.type");
|
||||
|
||||
const handleAddAwsKms = async (data: AddExternalKmsType) => {
|
||||
const { slug, description, provider } = data;
|
||||
const { name, description, provider } = data;
|
||||
try {
|
||||
if (kms) {
|
||||
await updateAwsExternalKms({
|
||||
kmsId: kms.id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
});
|
||||
@@ -105,7 +105,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
});
|
||||
} else {
|
||||
await addAwsExternalKms({
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
provider
|
||||
});
|
||||
@@ -126,7 +126,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
|
||||
<form onSubmit={handleSubmit(handleAddAwsKms)} autoComplete="off">
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Input placeholder="" {...field} />
|
||||
|
||||
+2
-2
@@ -122,7 +122,7 @@ export const OrgEncryptionTab = withPermission(
|
||||
)}
|
||||
<div className="ml-2">{kms.externalKms.provider.toUpperCase()}</div>
|
||||
</Td>
|
||||
<Td>{kms.slug}</Td>
|
||||
<Td>{kms.name}</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
@@ -170,7 +170,7 @@ export const OrgEncryptionTab = withPermission(
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("removeExternalKms", {
|
||||
slug: kms.slug,
|
||||
name: kms.name,
|
||||
kmsId: kms.id,
|
||||
provider: kms.externalKms.provider
|
||||
});
|
||||
|
||||
+1
-1
@@ -307,7 +307,7 @@ export const EncryptionTab = () => {
|
||||
</SelectItem>
|
||||
{externalKmsList?.map((kms) => (
|
||||
<SelectItem value={kms.id} key={`kms-${kms.id}`}>
|
||||
{kms.slug}
|
||||
{kms.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -8,7 +6,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
|
||||
@@ -79,30 +76,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
|
||||
try {
|
||||
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
|
||||
|
||||
const key = crypto.randomBytes(16).toString("hex");
|
||||
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: secret,
|
||||
key
|
||||
});
|
||||
|
||||
const { id } = await createSharedSecret.mutateAsync({
|
||||
name,
|
||||
password,
|
||||
encryptedValue: ciphertext,
|
||||
hashedHex,
|
||||
iv,
|
||||
tag,
|
||||
secretValue: secret,
|
||||
expiresAt,
|
||||
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
|
||||
accessType
|
||||
});
|
||||
|
||||
setSecretLink(
|
||||
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
|
||||
hashedHex
|
||||
)}-${encodeURIComponent(key)}`
|
||||
);
|
||||
setSecretLink(`${window.location.origin}/shared/secret/${id}`);
|
||||
reset();
|
||||
|
||||
setCopyTextSecret("secret");
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { NextRouter, useRouter } from "next/router";
|
||||
import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing";
|
||||
|
||||
import { PasswordContainer,SecretContainer, SecretErrorContainer } from "./components";
|
||||
import { PasswordContainer, SecretContainer, SecretErrorContainer } from "./components";
|
||||
|
||||
const extractDetailsFromUrl = (router: NextRouter) => {
|
||||
const { id, key: urlEncodedKey } = router.query;
|
||||
|
||||
const idString = id as string;
|
||||
|
||||
if (urlEncodedKey) {
|
||||
const [hashedHex, key] = urlEncodedKey ? urlEncodedKey.toString().split("-") : ["", ""];
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
hashedHex,
|
||||
key
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: idString,
|
||||
hashedHex: null,
|
||||
key: null
|
||||
};
|
||||
};
|
||||
|
||||
export const ViewSecretPublicPage = () => {
|
||||
const router = useRouter();
|
||||
const [password, setPassword] = useState<string>();
|
||||
const { id, key: urlEncodedPublicKey } = router.query;
|
||||
|
||||
const [hashedHex, key] = urlEncodedPublicKey
|
||||
? urlEncodedPublicKey.toString().split("-")
|
||||
: ["", ""];
|
||||
const { hashedHex, key, id } = extractDetailsFromUrl(router);
|
||||
|
||||
const {
|
||||
data: fetchSecret,
|
||||
@@ -25,7 +44,7 @@ export const ViewSecretPublicPage = () => {
|
||||
isLoading,
|
||||
isFetching
|
||||
} = useGetActiveSharedSecretById({
|
||||
sharedSecretId: id as string,
|
||||
sharedSecretId: id,
|
||||
hashedHex,
|
||||
password
|
||||
});
|
||||
@@ -80,7 +99,7 @@ export const ViewSecretPublicPage = () => {
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{!error && fetchSecret?.secret && key && (
|
||||
{!error && fetchSecret?.secret && (
|
||||
<SecretContainer secret={fetchSecret.secret} secretKey={key} />
|
||||
)}
|
||||
{error && !isInvalidCredential && <SecretErrorContainer />}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
|
||||
|
||||
type Props = {
|
||||
secret: TViewSharedSecretResponse["secret"];
|
||||
secretKey: string;
|
||||
secretKey: string | null;
|
||||
};
|
||||
|
||||
export const SecretContainer = ({ secret, secretKey: key }: Props) => {
|
||||
@@ -25,6 +25,10 @@ export const SecretContainer = ({ secret, secretKey: key }: Props) => {
|
||||
});
|
||||
|
||||
const decryptedSecret = useMemo(() => {
|
||||
if (secret.secretValue) {
|
||||
return secret.secretValue;
|
||||
}
|
||||
|
||||
if (secret && secret.encryptedValue && key) {
|
||||
const res = decryptSymmetric({
|
||||
ciphertext: secret.encryptedValue,
|
||||
|
||||
@@ -38,7 +38,7 @@ const UserPanelTable = ({
|
||||
const [searchUserFilter, setSearchUserFilter] = useState("");
|
||||
const { user } = useUser();
|
||||
const userId = user?.id || "";
|
||||
const debounedSearchTerm = useDebounce(searchUserFilter, 500);
|
||||
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
|
||||
|
||||
Reference in New Issue
Block a user