Merge remote-tracking branch 'origin/main' into misc/move-audit-logs-to-dedicated

This commit is contained in:
Sheen Capadngan
2024-10-04 22:16:46 +08:00
135 changed files with 4314 additions and 581 deletions
@@ -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 || "";
+3 -1
View File
@@ -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>}
+30 -25
View File
@@ -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>;
+1
View File
@@ -10,6 +10,7 @@ export {
export type { TProjectPermission } from "./ProjectPermissionContext";
export {
ProjectPermissionActions,
ProjectPermissionCmekActions,
ProjectPermissionProvider,
ProjectPermissionSub,
useProjectPermission
+3
View File
@@ -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;
}
});
};
+53
View File
@@ -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
});
};
+58
View File
@@ -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"
}
+16 -2
View File
@@ -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;
+4 -4
View File
@@ -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
});
+1 -1
View File
@@ -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`);
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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";
+6 -3
View File
@@ -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];
};
+31
View File
@@ -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
};
};
+11 -1
View File
@@ -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;
@@ -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({
@@ -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)}
@@ -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>
@@ -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>
@@ -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>
);
};
@@ -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;
};
@@ -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>
);
};
@@ -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 (
@@ -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 && (
@@ -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 (
@@ -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} />
@@ -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
});
@@ -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({