feat: added new secret input component and updated toolbar key special prop to innerKey

This commit is contained in:
akhilmhdh
2023-07-27 16:36:34 +05:30
parent b6b3c8a736
commit bca14dd5c4
34 changed files with 2969 additions and 2505 deletions
@@ -58,15 +58,13 @@ export default function NavHeader({
return ( return (
<div className="flex flex-row items-center pt-6"> <div className="flex flex-row items-center pt-6">
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-black text-sm"> <div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black">
{currentOrg?.name?.charAt(0)} {currentOrg?.name?.charAt(0)}
</div> </div>
<Link <Link passHref legacyBehavior href={`/org/${currentOrg?._id}/overview`}>
passHref <a className="pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
legacyBehavior {currentOrg?.name}
href={`/org/${currentOrg?._id}/overview`} </a>
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary pl-0.5">{currentOrg?.name}</a>
</Link> </Link>
{isProjectRelated && ( {isProjectRelated && (
<> <>
@@ -85,7 +83,7 @@ export default function NavHeader({
<Link <Link
passHref passHref
legacyBehavior legacyBehavior
href={{ pathname: "/project/[id]/secrets", query: { id: router.query.id } }} href={{ pathname: "/project/[id]/secrets/overview", query: { id: router.query.id } }}
> >
<a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a> <a className="text-sm font-semibold text-primary/80 hover:text-primary">{pageName}</a>
</Link> </Link>
@@ -126,7 +124,11 @@ export default function NavHeader({
{index + 1 === folders?.length ? ( {index + 1 === folders?.length ? (
<span className="text-sm font-semibold text-bunker-300">{name}</span> <span className="text-sm font-semibold text-bunker-300">{name}</span>
) : ( ) : (
<Link passHref legacyBehavior href={{ pathname: "/project/[id]/secrets", query }}> <Link
passHref
legacyBehavior
href={{ pathname: "/project/[id]/secrets/[env]", query }}
>
<a className="text-sm font-semibold text-primary/80 hover:text-primary"> <a className="text-sm font-semibold text-primary/80 hover:text-primary">
{name === "root" ? selectedEnv?.name : name} {name === "root" ? selectedEnv?.name : name}
</a> </a>
@@ -0,0 +1,92 @@
/* eslint-disable react/no-danger */
import { HTMLAttributes } from "react";
import ContentEditable from "react-contenteditable";
import sanitizeHtml from "sanitize-html";
import { useToggle } from "@app/hooks";
const REGEX = /\${([^}]+)}/g;
const stripSpanTags = (str: string) => str.replace(/<\/?span[^>]*>/g, "");
const replaceContentWithDot = (str: string) => {
let finalStr = "";
let isHtml = false;
for (let i = 0; i < str.length; i += 1) {
const char = str.at(i);
if (char === "<" || char === ">") {
isHtml = char === "<";
finalStr += char;
} else if (!isHtml && char !== "\n") {
finalStr += "&#8226;";
} else {
finalStr += char;
}
}
return finalStr;
};
const syntaxHighlight = (orgContent?: string | null, isVisible?: boolean) => {
if (!orgContent) return "";
if (!isVisible) return replaceContentWithDot(orgContent);
const content = stripSpanTags(orgContent);
const newContent = content.replace(
REGEX,
(_a, b) =>
`<span class="ph-no-capture text-yellow">&#36;&#123;<span class="ph-no-capture text-yello-200/80">${b}</span>&#125;</span>`
);
return newContent;
};
const sanitizeConf = {
allowedTags: ["div", "span", "br", "p"]
};
type Props = Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "onBlur"> & {
value?: string | null;
isVisible?: boolean;
isDisabled?: boolean;
onChange?: (val: string, html: string) => void;
onBlur?: (sanitizedHtml: string) => void;
};
export const SecretInput = ({
value,
isVisible,
onChange,
onBlur,
isDisabled,
...props
}: Props) => {
const [isSecretFocused, setIsSecretFocused] = useToggle();
return (
<div
className="thin-scrollbar relative overflow-y-auto overflow-x-hidden"
style={{ maxHeight: `${21 * 7}px` }}
>
<div
dangerouslySetInnerHTML={{
__html: syntaxHighlight(value, isVisible || isSecretFocused)
}}
className="absolute top-0 left-0 z-0 h-full w-full text-ellipsis whitespace-pre-line break-all"
/>
<ContentEditable
className="relative z-10 h-full w-full text-ellipsis whitespace-pre-line break-all text-transparent caret-white outline-none"
role="textbox"
onChange={(evt) => {
if (onChange) onChange(evt.currentTarget.innerText.trim(), evt.currentTarget.innerHTML);
}}
onFocus={() => setIsSecretFocused.on()}
disabled={isDisabled}
spellCheck={false}
onBlur={(evt) => {
if (onBlur) onBlur(sanitizeHtml(evt.currentTarget.innerHTML || "", sanitizeConf));
setIsSecretFocused.off();
}}
html={isVisible || isSecretFocused ? value || "" : syntaxHighlight(value, false)}
{...props}
/>
</div>
);
};
@@ -0,0 +1 @@
export { SecretInput } from "./SecretInput";
@@ -52,7 +52,7 @@ export const Loading: Story = {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
<TableSkeleton columns={3} key="story-book-table" /> <TableSkeleton columns={3} innerKey="story-book-table" />
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
+28 -11
View File
@@ -33,10 +33,7 @@ export type TableProps = {
export const Table = ({ children, className }: TableProps): JSX.Element => ( export const Table = ({ children, className }: TableProps): JSX.Element => (
<table <table
className={twMerge( className={twMerge("w-full bg-mineshaft-800 p-2 text-left text-sm text-gray-300", className)}
"w-full bg-mineshaft-800 p-2 text-left text-sm text-gray-300",
className
)}
> >
{children} {children}
</table> </table>
@@ -58,11 +55,24 @@ export const THead = ({ children, className }: THeadProps): JSX.Element => (
export type TrProps = { export type TrProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
isHoverable?: boolean;
isSelectable?: boolean;
} & HTMLAttributes<HTMLTableRowElement>; } & HTMLAttributes<HTMLTableRowElement>;
export const Tr = ({ children, className, ...props }: TrProps): JSX.Element => ( export const Tr = ({
children,
className,
isHoverable,
isSelectable,
...props
}: TrProps): JSX.Element => (
<tr <tr
className={twMerge("border border-solid border-mineshaft-700 cursor-default", className)} className={twMerge(
"cursor-default border border-solid border-mineshaft-700",
isHoverable && "hover:bg-mineshaft-600",
isSelectable && "cursor-pointer",
className
)}
{...props} {...props}
> >
{children} {children}
@@ -76,7 +86,14 @@ export type ThProps = {
}; };
export const Th = ({ children, className }: ThProps): JSX.Element => ( export const Th = ({ children, className }: ThProps): JSX.Element => (
<th className={twMerge("bg-mineshaft-800 px-5 pt-4 pb-3.5 font-semibold border-b-2 border-mineshaft-600", className)}>{children}</th> <th
className={twMerge(
"border-b-2 border-mineshaft-600 bg-mineshaft-800 px-5 pt-4 pb-3.5 font-semibold",
className
)}
>
{children}
</th>
); );
// table body // table body
@@ -106,15 +123,15 @@ export type TBodyLoader = {
columns: number; columns: number;
className?: string; className?: string;
// unique key for mapping // unique key for mapping
key: string; innerKey: string;
}; };
export const TableSkeleton = ({ rows = 3, columns, key, className }: TBodyLoader): JSX.Element => ( export const TableSkeleton = ({ rows = 3, columns, innerKey, className }: TBodyLoader): JSX.Element => (
<> <>
{Array.apply(0, Array(rows)).map((_x, i) => ( {Array.apply(0, Array(rows)).map((_x, i) => (
<Tr key={`${key}-skeleton-rows-${i + 1}`}> <Tr key={`${innerKey}-skeleton-rows-${i + 1}`}>
{Array.apply(0, Array(columns)).map((_y, j) => ( {Array.apply(0, Array(columns)).map((_y, j) => (
<Td key={`${key}-skeleton-rows-${i + 1}-column-${j + 1}`}> <Td key={`${innerKey}-skeleton-rows-${i + 1}-column-${j + 1}`}>
<Skeleton className={className} /> <Skeleton className={className} />
</Td> </Td>
))} ))}
+2 -1
View File
@@ -4,7 +4,7 @@ export * from "./Checkbox";
export * from "./DeleteActionModal"; export * from "./DeleteActionModal";
export * from "./Drawer"; export * from "./Drawer";
export * from "./Dropdown"; export * from "./Dropdown";
export * from "./EmailServiceSetupModal" export * from "./EmailServiceSetupModal";
export * from "./EmptyState"; export * from "./EmptyState";
export * from "./FormControl"; export * from "./FormControl";
export * from "./HoverCardv2"; export * from "./HoverCardv2";
@@ -13,6 +13,7 @@ export * from "./Input";
export * from "./Menu"; export * from "./Menu";
export * from "./Modal"; export * from "./Modal";
export * from "./Popoverv2"; export * from "./Popoverv2";
export * from "./SecretInput";
export * from "./Select"; export * from "./Select";
export * from "./Skeleton"; export * from "./Skeleton";
export * from "./Spinner"; export * from "./Spinner";
@@ -1,6 +1,7 @@
export { export {
useCreateFolder, useCreateFolder,
useDeleteFolder, useDeleteFolder,
useGetFoldersByEnv,
useGetProjectFolders, useGetProjectFolders,
useGetProjectFoldersBatch, useGetProjectFoldersBatch,
useUpdateFolder useUpdateFolder
@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@@ -9,6 +9,7 @@ import {
DeleteFolderDTO, DeleteFolderDTO,
GetProjectFoldersBatchDTO, GetProjectFoldersBatchDTO,
GetProjectFoldersDTO, GetProjectFoldersDTO,
TGetFoldersByEnvDTO,
TSecretFolder, TSecretFolder,
UpdateFolderDTO UpdateFolderDTO
} from "./types"; } from "./types";
@@ -62,6 +63,48 @@ export const useGetProjectFolders = ({
) )
}); });
export const useGetFoldersByEnv = ({
parentFolderPath,
workspaceId,
environments,
parentFolderId
}: TGetFoldersByEnvDTO) => {
const folders = useQueries({
queries: environments.map((env) => ({
queryKey: queryKeys.getSecretFolders(workspaceId, env, parentFolderPath || parentFolderId),
queryFn: async () => fetchProjectFolders(workspaceId, env, parentFolderId, parentFolderPath),
enabled: Boolean(workspaceId) && Boolean(env)
}))
});
const folderNames = useMemo(() => {
const names = new Set<string>();
folders?.forEach(({ data }) => {
data?.folders.forEach(({ name }) => {
names.add(name);
});
});
return [...names];
}, [(folders || []).map((folder) => folder.data)]);
const isFolderPresentInEnv = useCallback(
(name: string, env: string) => {
const selectedEnvIndex = environments.indexOf(env);
if (selectedEnvIndex !== -1) {
return Boolean(
folders?.[selectedEnvIndex]?.data?.folders?.find(
({ name: folderName }) => folderName === name
)
);
}
return false;
},
[(folders || []).map((folder) => folder.data)]
);
return { folders, folderNames, isFolderPresentInEnv };
};
export const useGetProjectFoldersBatch = ({ export const useGetProjectFoldersBatch = ({
folders = [], folders = [],
isPaused, isPaused,
@@ -17,6 +17,13 @@ export type GetProjectFoldersBatchDTO = {
parentFolderPath?: string; parentFolderPath?: string;
}; };
export type TGetFoldersByEnvDTO = {
environments: string[];
workspaceId: string;
parentFolderPath?: string;
parentFolderId?: string;
};
export type CreateFolderDTO = { export type CreateFolderDTO = {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
+2 -1
View File
@@ -1,6 +1,7 @@
export { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "./mutations";
export { export {
useBatchSecretsOp, useBatchSecretsOp,
useGetProjectSecrets, useGetProjectSecrets,
useGetProjectSecretsByKey, useGetProjectSecretsAllEnv,
useGetSecretVersion useGetSecretVersion
} from "./queries"; } from "./queries";
@@ -0,0 +1,172 @@
import crypto from "crypto";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
decryptAssymmetric,
encryptSymmetric
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import { secretKeys } from "./queries";
import { TCreateSecretsV3DTO, TDeleteSecretsV3DTO, TUpdateSecretsV3DTO } from "./types";
const encryptSecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
// encrypt key
const {
ciphertext: secretKeyCiphertext,
iv: secretKeyIV,
tag: secretKeyTag
} = encryptSymmetric({
plaintext: key,
key: randomBytes
});
// encrypt value
const {
ciphertext: secretValueCiphertext,
iv: secretValueIV,
tag: secretValueTag
} = encryptSymmetric({
plaintext: value ?? "",
key: randomBytes
});
// encrypt comment
const {
ciphertext: secretCommentCiphertext,
iv: secretCommentIV,
tag: secretCommentTag
} = encryptSymmetric({
plaintext: comment ?? "",
key: randomBytes
});
return {
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag
};
};
export const useCreateSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TCreateSecretsV3DTO>({
mutationFn: async ({
secretPath = "/",
type,
environment,
workspaceId,
secretName,
secretValue,
latestFileKey,
secretComment
}) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const randomBytes = latestFileKey
? decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
})
: crypto.randomBytes(16).toString("hex");
const reqBody = {
workspaceId,
environment,
type,
secretPath,
...encryptSecret(randomBytes, secretName, secretValue, secretComment)
};
const { data } = await apiRequest.post(`/api/v3/secrets/${secretName}`, reqBody);
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
export const useUpdateSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateSecretsV3DTO>({
mutationFn: async ({
secretPath = "/",
type,
environment,
workspaceId,
secretName,
secretValue,
latestFileKey
}) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const randomBytes = latestFileKey
? decryptAssymmetric({
ciphertext: latestFileKey.encryptedKey,
nonce: latestFileKey.nonce,
publicKey: latestFileKey.sender.publicKey,
privateKey: PRIVATE_KEY
})
: crypto.randomBytes(16).toString("hex");
const { secretValueIV, secretValueTag, secretValueCiphertext } = encryptSecret(
randomBytes,
secretName,
secretValue,
""
);
const reqBody = {
workspaceId,
environment,
type,
secretPath,
secretValueIV,
secretValueTag,
secretValueCiphertext
};
const { data } = await apiRequest.patch(`/api/v3/secrets/${secretName}`, reqBody);
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
export const useDeleteSecretV3 = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TDeleteSecretsV3DTO>({
mutationFn: async ({ secretPath = "/", type, environment, workspaceId, secretName }) => {
const reqBody = {
workspaceId,
environment,
type,
secretPath
};
const { data } = await apiRequest.delete(`/api/v3/secrets/${secretName}`, {
data: reqBody
});
return data;
},
onSuccess: (_, { workspaceId, environment, secretPath }) => {
queryClient.invalidateQueries(
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
);
}
});
};
+63 -78
View File
@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
@@ -38,40 +38,15 @@ const fetchProjectEncryptedSecrets = async (
folderId?: string, folderId?: string,
secretPath?: string secretPath?: string
) => { ) => {
if (typeof env === "string") { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", { params: {
params: { environment: env,
environment: env, workspaceId,
workspaceId, folderId: folderId || undefined,
folderId: folderId || undefined, secretPath
secretPath
}
});
return data.secrets;
}
if (typeof env === "object") {
let allEnvData: any = [];
// eslint-disable-next-line no-restricted-syntax
for (const envPoint of env) {
// eslint-disable-next-line no-await-in-loop
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v2/secrets", {
params: {
environment: envPoint,
workspaceId,
folderId,
secretPath
}
});
allEnvData = allEnvData.concat(data.secrets);
} }
});
return allEnvData; return data.secrets;
// eslint-disable-next-line no-else-return
} else {
return null;
}
}; };
export const useGetProjectSecrets = ({ export const useGetProjectSecrets = ({
@@ -167,31 +142,13 @@ export const useGetProjectSecretsAllEnv = ({
decryptFileKey, decryptFileKey,
folderId, folderId,
secretPath secretPath
}: TGetProjectSecretsAllEnvDTO) => }: TGetProjectSecretsAllEnvDTO) => {
useQueries({ const secrets = useQueries({
queries: envs.map((env) => ({ queries: envs.map((env) => ({
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath || folderId), queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath || folderId),
enabled: Boolean(decryptFileKey && workspaceId && env), enabled: Boolean(decryptFileKey && workspaceId && env),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath) queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
})) select: (data: EncryptedSecret[]) => {
});
export const useGetProjectSecretsByKey = ({
workspaceId,
env,
decryptFileKey,
isPaused,
folderId,
secretPath
}: GetProjectSecretsDTO) =>
useQuery({
// wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
// right now secretpath is passed as folderid as only this is used in overview
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
select: useCallback(
(data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
const latestKey = decryptFileKey; const latestKey = decryptFileKey;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
@@ -201,12 +158,11 @@ export const useGetProjectSecretsByKey = ({
privateKey: PRIVATE_KEY privateKey: PRIVATE_KEY
}); });
const sharedSecrets: Record<string, DecryptedSecret[]> = {}; const sharedSecrets: Record<string, DecryptedSecret> = {};
const personalSecrets: Record<string, { id: string; value: string }> = {}; const personalSecrets: Record<string, { id: string; value: string }> = {};
// this used for add-only mode in dashboard // this used for add-only mode in dashboard
// type won't be there thus only one key is shown // type won't be there thus only one key is shown
const duplicateSecretKey: Record<string, boolean> = {}; const duplicateSecretKey: Record<string, boolean> = {};
const uniqSecKeys: Record<string, boolean> = {};
data.forEach((encSecret: EncryptedSecret) => { data.forEach((encSecret: EncryptedSecret) => {
const secretKey = decryptSymmetric({ const secretKey = decryptSymmetric({
ciphertext: encSecret.secretKeyCiphertext, ciphertext: encSecret.secretKeyCiphertext,
@@ -214,7 +170,6 @@ export const useGetProjectSecretsByKey = ({
tag: encSecret.secretKeyTag, tag: encSecret.secretKeyTag,
key key
}); });
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
const secretValue = decryptSymmetric({ const secretValue = decryptSymmetric({
ciphertext: encSecret.secretValueCiphertext, ciphertext: encSecret.secretValueCiphertext,
@@ -242,35 +197,65 @@ export const useGetProjectSecretsByKey = ({
}; };
if (encSecret.type === "personal") { if (encSecret.type === "personal") {
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = { personalSecrets[decryptedSecret.key] = {
id: encSecret._id, id: encSecret._id,
value: secretValue value: secretValue
}; };
} else { } else {
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) { if (!duplicateSecretKey?.[decryptedSecret.key]) {
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = []; sharedSecrets[decryptedSecret.key] = decryptedSecret;
sharedSecrets[secretKey].push(decryptedSecret);
} }
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true; duplicateSecretKey[decryptedSecret.key] = true;
} }
}); });
Object.keys(sharedSecrets).forEach((secName) => {
sharedSecrets[secName].forEach((val) => {
const dupKey = `${val.key}-${val.env}`;
if (personalSecrets?.[dupKey]) {
val.idOverride = personalSecrets[dupKey].id;
val.valueOverride = personalSecrets[dupKey].value;
val.overrideAction = "modified";
}
});
});
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length }; Object.keys(sharedSecrets).forEach((val) => {
}, if (personalSecrets?.[val]) {
[decryptFileKey] sharedSecrets[val].idOverride = personalSecrets[val].id;
) sharedSecrets[val].valueOverride = personalSecrets[val].value;
sharedSecrets[val].overrideAction = "modified";
}
});
return sharedSecrets;
}
}))
}); });
const secKeys = useMemo(() => {
const keys = new Set<string>();
secrets?.forEach(({ data }) => {
// TODO(akhilmhdh): find out why this is unknown
Object.keys(data || {}).forEach((key) => keys.add(key));
});
return [...keys];
}, [(secrets || []).map((sec) => sec.data)]);
const getEnvSecretKeyCount = useCallback(
(env: string) => {
const selectedEnvIndex = envs.indexOf(env);
if (selectedEnvIndex !== -1) {
return Object.keys(secrets[selectedEnvIndex]?.data || {}).length;
}
return 0;
},
[(secrets || []).map((sec) => sec.data)]
);
const getSecretByKey = useCallback(
(env: string, key: string) => {
const selectedEnvIndex = envs.indexOf(env);
if (selectedEnvIndex !== -1) {
const sec = secrets[selectedEnvIndex]?.data?.[key];
return sec;
}
return undefined;
},
[(secrets || []).map((sec) => sec.data)]
);
return { data: secrets, secKeys, getSecretByKey, getEnvSecretKeyCount };
};
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>( const { data } = await apiRequest.get<{ secretVersions: EncryptedSecretVersion[] }>(
`/api/v1/secret/${secretId}/secret-versions`, `/api/v1/secret/${secretId}/secret-versions`,
+29
View File
@@ -118,3 +118,32 @@ export type GetSecretVersionsDTO = {
offset: number; offset: number;
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
}; };
export type TCreateSecretsV3DTO = {
latestFileKey: UserWsKeyPair;
secretName: string;
secretValue: string;
secretComment: string;
secretPath: string;
workspaceId: string;
environment: string;
type: string;
};
export type TUpdateSecretsV3DTO = {
latestFileKey: UserWsKeyPair;
workspaceId: string;
environment: string;
type: string;
secretPath: string;
secretName: string;
secretValue: string;
};
export type TDeleteSecretsV3DTO = {
workspaceId: string;
environment: string;
type: string;
secretPath: string;
secretName: string;
};
+340 -259
View File
@@ -13,7 +13,18 @@ import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons";
import { faAngleDown, faArrowLeft, faArrowUpRightFromSquare, faBook, faCheck, faEnvelope, faInfinity, faMobile, faPlus, faQuestion } from "@fortawesome/free-solid-svg-icons"; import {
faAngleDown,
faArrowLeft,
faArrowUpRightFromSquare,
faBook,
faCheck,
faEnvelope,
faInfinity,
faMobile,
faPlus,
faQuestion
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
@@ -41,7 +52,14 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useGetOrgTrialUrl, useLogoutUser, useUploadWsKey } from "@app/hooks/api"; import {
fetchOrgUsers,
useAddUserToWs,
useCreateWorkspace,
useGetOrgTrialUrl,
useLogoutUser,
useUploadWsKey
} from "@app/hooks/api";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -89,7 +107,9 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
// const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true); // const [ isLearningNoteOpen, setIsLearningNoteOpen ] = useState(true);
const isAddingProjectsAllowed = subscription?.workspaceLimit ? (subscription.workspacesUsed < subscription.workspaceLimit) : true; const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
const createWs = useCreateWorkspace(); const createWs = useCreateWorkspace();
const uploadWsKey = useUploadWsKey(); const uploadWsKey = useUploadWsKey();
@@ -110,22 +130,22 @@ export const AppLayout = ({ children }: LayoutProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => { useEffect(() => {
const handleRouteChange = () => { const handleRouteChange = () => {
(window).Intercom("update"); window.Intercom("update");
}; };
router.events.on("routeChangeComplete", handleRouteChange); router.events.on("routeChangeComplete", handleRouteChange);
return () => { return () => {
router.events.off("routeChangeComplete", handleRouteChange); router.events.off("routeChangeComplete", handleRouteChange);
}; };
}, []); }, []);
const logout = useLogoutUser(); const logout = useLogoutUser();
const logOutUser = async () => { const logOutUser = async () => {
try { try {
console.log("Logging out...") console.log("Logging out...");
await logout.mutateAsync(); await logout.mutateAsync();
localStorage.removeItem("protectedKey"); localStorage.removeItem("protectedKey");
localStorage.removeItem("protectedKeyIV"); localStorage.removeItem("protectedKeyIV");
@@ -145,27 +165,30 @@ export const AppLayout = ({ children }: LayoutProps) => {
const changeOrg = async (orgId) => { const changeOrg = async (orgId) => {
localStorage.setItem("orgData.id", orgId); localStorage.setItem("orgData.id", orgId);
router.push(`/org/${orgId}/overview`) router.push(`/org/${orgId}/overview`);
} };
// TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid // TODO(akhilmhdh): This entire logic will be rechecked and will try to avoid
// Placing the localstorage as much as possible // Placing the localstorage as much as possible
// Wait till tony integrates the azure and its launched // Wait till tony integrates the azure and its launched
useEffect(() => { useEffect(() => {
// Put a user in an org if they're not in one yet // Put a user in an org if they're not in one yet
const putUserInOrg = async () => { const putUserInOrg = async () => {
if (tempLocalStorage("orgData.id") === "") { if (tempLocalStorage("orgData.id") === "") {
localStorage.setItem("orgData.id", orgs[0]?._id); localStorage.setItem("orgData.id", orgs[0]?._id);
} }
if (currentOrg && ( if (
(workspaces?.length === 0 && router.asPath.includes("project")) currentOrg &&
|| router.asPath.includes("/project/undefined") ((workspaces?.length === 0 && router.asPath.includes("project")) ||
|| (!orgs?.map(org => org._id)?.includes(router.query.id) && !router.asPath.includes("project") && !router.asPath.includes("personal") && !router.asPath.includes("integration")) router.asPath.includes("/project/undefined") ||
)) { (!orgs?.map((org) => org._id)?.includes(router.query.id) &&
!router.asPath.includes("project") &&
!router.asPath.includes("personal") &&
!router.asPath.includes("integration")))
) {
router.push(`/org/${currentOrg?._id}/overview`); router.push(`/org/${currentOrg?._id}/overview`);
} }
// else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) { // else if (!router.asPath.includes("org") && !router.asPath.includes("project") && !router.asPath.includes("integrations") && !router.asPath.includes("personal-settings")) {
// const pathSegments = router.asPath.split("/").filter((segment) => segment.length > 0); // const pathSegments = router.asPath.split("/").filter((segment) => segment.length > 0);
@@ -233,7 +256,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
} }
createNotification({ text: "Workspace created", type: "success" }); createNotification({ text: "Workspace created", type: "success" });
handlePopUpClose("addNewWs"); handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets`); router.push(`/project/${newWorkspaceId}/secrets/overview`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" }); createNotification({ text: "Failed to create workspace", type: "error" });
@@ -244,190 +267,235 @@ export const AppLayout = ({ children }: LayoutProps) => {
<> <>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex"> <div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row"> <div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<aside className="w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60 dark"> <aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]"> <nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div> <div>
{!router.asPath.includes("personal") && <div className="h-12 px-3 flex items-center pt-6 cursor-default"> {!router.asPath.includes("personal") && (
{(router.asPath.includes("project") || router.asPath.includes("integrations")) && <Link href={`/org/${currentOrg?._id}/overview`}><div className="pl-1 pr-2 text-mineshaft-400 hover:text-mineshaft-100 duration-200"> <div className="flex h-12 cursor-default items-center px-3 pt-6">
<FontAwesomeIcon icon={faArrowLeft} /> {(router.asPath.includes("project") ||
</div></Link>} router.asPath.includes("integrations")) && (
<DropdownMenu> <Link href={`/org/${currentOrg?._id}/overview`}>
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600"> <div className="pl-1 pr-2 text-mineshaft-400 duration-200 hover:text-mineshaft-100">
<div className="mr-auto flex items-center hover:bg-mineshaft-600 py-1.5 pl-1.5 pr-2 rounded-md"> <FontAwesomeIcon icon={faArrowLeft} />
<div className="w-5 h-5 rounded-md bg-primary flex justify-center items-center text-sm">{currentOrg?.name.charAt(0)}</div> </div>
<div className="pl-3 text-mineshaft-100 text-sm">{currentOrg?.name} <FontAwesomeIcon icon={faAngleDown} className="text-xs pl-1 pt-1 text-mineshaft-300" /></div> </Link>
</div> )}
</DropdownMenuTrigger> <DropdownMenu>
<DropdownMenuContent align="start" className="p-1"> <DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
<div className="text-xs text-mineshaft-400 px-2 py-1">{user?.email}</div> <div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
{orgs?.map(org => <DropdownMenuItem key={org._id}> <div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm">
<Button {currentOrg?.name.charAt(0)}
onClick={() => changeOrg(org?._id)} </div>
variant="plain" <div className="pl-3 text-sm text-mineshaft-100">
colorSchema="secondary" {currentOrg?.name}{" "}
size="xs" <FontAwesomeIcon
className="w-full flex items-center justify-start p-0 font-normal" icon={faAngleDown}
leftIcon={currentOrg._id === org._id && <FontAwesomeIcon icon={faCheck} className="mr-3 text-primary"/>} className="pl-1 pt-1 text-xs text-mineshaft-300"
> />
<div className="w-full flex justify-between items-center">{org.name}</div> </div>
</Button> </div>
</DropdownMenuItem> </DropdownMenuTrigger>
)} <DropdownMenuContent align="start" className="p-1">
<div className="h-1 mt-1 border-t border-mineshaft-600"/> <div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<button {orgs?.map((org) => (
type="button" <DropdownMenuItem key={org._id}>
onClick={logOutUser} <Button
className="w-full" onClick={() => changeOrg(org?._id)}
> variant="plain"
<DropdownMenuItem>Log Out</DropdownMenuItem> colorSchema="secondary"
</button> size="xs"
</DropdownMenuContent> className="flex w-full items-center justify-start p-0 font-normal"
</DropdownMenu> leftIcon={
<DropdownMenu> currentOrg._id === org._id && (
<DropdownMenuTrigger asChild className="hover:bg-primary-400 hover:text-black data-[state=open]:text-black data-[state=open]:bg-primary-400 p-1"> <FontAwesomeIcon icon={faCheck} className="mr-3 text-primary" />
<div className="child w-6 h-6 rounded-full bg-mineshaft hover:bg-mineshaft-500 pr-1 text-xs text-mineshaft-300 flex justify-center items-center"> )
{user?.firstName?.charAt(0)}{user?.lastName && user?.lastName?.charAt(0)} }
</div> >
</DropdownMenuTrigger> <div className="flex w-full items-center justify-between">
<DropdownMenuContent align="start" className="p-1"> {org.name}
<div className="text-xs text-mineshaft-400 px-2 py-1">{user?.email}</div> </div>
<Link href="/personal-settings"><DropdownMenuItem>Personal Settings</DropdownMenuItem></Link> </Button>
<a </DropdownMenuItem>
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="w-full mt-3 text-sm text-mineshaft-300 font-normal leading-[1.2rem] hover:text-mineshaft-100"
>
<DropdownMenuItem>Documentation<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-1.5 text-xxs mb-[0.06rem]" /></DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="w-full mt-3 text-sm text-mineshaft-300 font-normal leading-[1.2rem] hover:text-mineshaft-100"
>
<DropdownMenuItem>Join Slack Community<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-1.5 text-xxs mb-[0.06rem]" /></DropdownMenuItem>
</a>
<div className="h-1 mt-1 border-t border-mineshaft-600"/>
<button
type="button"
onClick={logOutUser}
className="w-full"
>
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div>}
{!router.asPath.includes("org") && (!router.asPath.includes("personal") && currentWorkspace ? (
<div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?._id}
value={currentWorkspace?._id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => {
router.push(`/project/${value}/secrets`);
localStorage.setItem("projectData.id", value);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className='h-full no-scrollbar no-scrollbar::-webkit-scrollbar'>
{workspaces
.filter((ws) => ws.organization === currentOrg?._id)
.map(({ _id, name }) => (
<SelectItem
key={`ws-layout-list-${_id}`}
value={_id}
className={`${currentWorkspace?._id === _id && "bg-mineshaft-600"}`}
>
{name}
</SelectItem>
))} ))}
</div> <div className="mt-1 h-1 border-t border-mineshaft-600" />
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" /> <button type="button" onClick={logOutUser} className="w-full">
<div className="w-full"> <DropdownMenuItem>Log Out</DropdownMenuItem>
<Button </button>
className="w-full bg-mineshaft-700 py-2 text-bunker-200" </DropdownMenuContent>
colorSchema="primary" </DropdownMenu>
variant="outline_bg" <DropdownMenu>
size="sm" <DropdownMenuTrigger
onClick={() => { asChild
if (isAddingProjectsAllowed) { className="p-1 hover:bg-primary-400 hover:text-black data-[state=open]:bg-primary-400 data-[state=open]:text-black"
handlePopUpOpen("addNewWs") >
} else { <div className="child flex h-6 w-6 items-center justify-center rounded-full bg-mineshaft pr-1 text-xs text-mineshaft-300 hover:bg-mineshaft-500">
handlePopUpOpen("upgradePlan"); {user?.firstName?.charAt(0)}
} {user?.lastName && user?.lastName?.charAt(0)}
}} </div>
leftIcon={<FontAwesomeIcon icon={faPlus} />} </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1 text-xs text-mineshaft-400">{user?.email}</div>
<Link href="/personal-settings">
<DropdownMenuItem>Personal Settings</DropdownMenuItem>
</Link>
<a
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
> >
Add Project <DropdownMenuItem>
</Button> Documentation
</div> <FontAwesomeIcon
</Select> icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<a
href="https://infisical.com/slack"
target="_blank"
rel="noopener noreferrer"
className="mt-3 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300 hover:text-mineshaft-100"
>
<DropdownMenuItem>
Join Slack Community
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] pl-1.5 text-xxs"
/>
</DropdownMenuItem>
</a>
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<button type="button" onClick={logOutUser} className="w-full">
<DropdownMenuItem>Log Out</DropdownMenuItem>
</button>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
) : <Link href={`/org/${currentOrg?._id}/overview`}><div className="pr-2 my-6 flex justify-center items-center text-mineshaft-300 hover:text-mineshaft-100 cursor-default text-sm"> )}
<FontAwesomeIcon icon={faArrowLeft} className="pr-3"/> {!router.asPath.includes("org") &&
Back to organization (!router.asPath.includes("personal") && currentWorkspace ? (
</div></Link>)} <div className="mt-5 mb-4 w-full p-3">
<p className="ml-1.5 mb-1 text-xs font-semibold uppercase text-gray-400">
Project
</p>
<Select
defaultValue={currentWorkspace?._id}
value={currentWorkspace?._id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => {
router.push(`/project/${value}/secrets/overview`);
localStorage.setItem("projectData.id", value);
}}
position="popper"
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700"
>
<div className="no-scrollbar::-webkit-scrollbar h-full no-scrollbar">
{workspaces
.filter((ws) => ws.organization === currentOrg?._id)
.map(({ _id, name }) => (
<SelectItem
key={`ws-layout-list-${_id}`}
value={_id}
className={`${currentWorkspace?._id === _id && "bg-mineshaft-600"}`}
>
{name}
</SelectItem>
))}
</div>
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
<div className="w-full">
<Button
className="w-full bg-mineshaft-700 py-2 text-bunker-200"
colorSchema="primary"
variant="outline_bg"
size="sm"
onClick={() => {
if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs");
} else {
handlePopUpOpen("upgradePlan");
}
}}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Add Project
</Button>
</div>
</Select>
</div>
) : (
<Link href={`/org/${currentOrg?._id}/overview`}>
<div className="my-6 flex cursor-default items-center justify-center pr-2 text-sm text-mineshaft-300 hover:text-mineshaft-100">
<FontAwesomeIcon icon={faArrowLeft} className="pr-3" />
Back to organization
</div>
</Link>
))}
<div className={`px-1 ${!router.asPath.includes("personal") ? "block" : "hidden"}`}> <div className={`px-1 ${!router.asPath.includes("personal") ? "block" : "hidden"}`}>
{((router.asPath.includes("project") || router.asPath.includes("integrations")) && currentWorkspace) ? <Menu> {(router.asPath.includes("project") || router.asPath.includes("integrations")) &&
<Link href={`/project/${currentWorkspace?._id}/secrets`} passHref> currentWorkspace ? (
<a> <Menu>
<MenuItem <Link href={`/project/${currentWorkspace?._id}/secrets/overview`} passHref>
isSelected={router.asPath.includes(`/project/${currentWorkspace?._id}/secrets`)} <a>
icon="system-outline-90-lock-closed" <MenuItem
> isSelected={router.asPath.includes(
{t("nav.menu.secrets")} `/project/${currentWorkspace?._id}/secrets/overview`
</MenuItem> )}
</a> icon="system-outline-90-lock-closed"
</Link> >
<Link href={`/project/${currentWorkspace?._id}/members`} passHref> {t("nav.menu.secrets")}
<a> </MenuItem>
<MenuItem </a>
isSelected={router.asPath === `/project/${currentWorkspace?._id}/members`} </Link>
icon="system-outline-96-groups" <Link href={`/project/${currentWorkspace?._id}/members`} passHref>
> <a>
{t("nav.menu.members")} <MenuItem
</MenuItem> isSelected={
</a> router.asPath === `/project/${currentWorkspace?._id}/members`
</Link> }
<Link href={`/integrations/${currentWorkspace?._id}`} passHref> icon="system-outline-96-groups"
<a> >
<MenuItem {t("nav.menu.members")}
isSelected={router.asPath === `/integrations/${currentWorkspace?._id}`} </MenuItem>
icon="system-outline-82-extension" </a>
> </Link>
{t("nav.menu.integrations")} <Link href={`/integrations/${currentWorkspace?._id}`} passHref>
</MenuItem> <a>
</a> <MenuItem
</Link> isSelected={router.asPath === `/integrations/${currentWorkspace?._id}`}
<Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref> icon="system-outline-82-extension"
<a> >
<MenuItem {t("nav.menu.integrations")}
isSelected={ </MenuItem>
router.asPath === `/project/${currentWorkspace?._id}/allowlist` </a>
} </Link>
icon="system-outline-126-verified" <Link href={`/project/${currentWorkspace?._id}/allowlist`} passHref>
> <a>
IP Allowlist <MenuItem
</MenuItem> isSelected={
</a> router.asPath === `/project/${currentWorkspace?._id}/allowlist`
</Link> }
<Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref> icon="system-outline-126-verified"
<a> >
<MenuItem IP Allowlist
isSelected={router.asPath === `/project/${currentWorkspace?._id}/audit-logs`} </MenuItem>
icon="system-outline-168-view-headline" </a>
> </Link>
Audit Logs <Link href={`/project/${currentWorkspace?._id}/audit-logs`} passHref>
</MenuItem> <a>
</a> <MenuItem
</Link> isSelected={
{/* <Link href={`/project/${currentWorkspace?._id}/secret-scanning`} passHref> router.asPath === `/project/${currentWorkspace?._id}/audit-logs`
}
icon="system-outline-168-view-headline"
>
Audit Logs
</MenuItem>
</a>
</Link>
{/* <Link href={`/project/${currentWorkspace?._id}/secret-scanning`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={router.asPath === `/project/${currentWorkspace?._id}/secret-scanning`} isSelected={router.asPath === `/project/${currentWorkspace?._id}/secret-scanning`}
@@ -438,20 +506,21 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> */} </Link> */}
<Link href={`/project/${currentWorkspace?._id}/settings`} passHref> <Link href={`/project/${currentWorkspace?._id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={ isSelected={
router.asPath === `/project/${currentWorkspace?._id}/settings` router.asPath === `/project/${currentWorkspace?._id}/settings`
} }
icon="system-outline-109-slider-toggle-settings" icon="system-outline-109-slider-toggle-settings"
> >
{t("nav.menu.project-settings")} {t("nav.menu.project-settings")}
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
</Menu> </Menu>
: <Menu className="mt-4"> ) : (
<Menu className="mt-4">
<Link href={`/org/${currentOrg?._id}/overview`} passHref> <Link href={`/org/${currentOrg?._id}/overview`} passHref>
<a> <a>
<MenuItem <MenuItem
@@ -462,7 +531,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
{/* {workspaces.map(project => <Link key={project._id} href={`/project/${project?._id}/secrets`} passHref> {/* {workspaces.map(project => <Link key={project._id} href={`/project/${project?._id}/secrets/overview`} passHref>
<a> <a>
<SubMenuItem <SubMenuItem
isSelected={false} isSelected={false}
@@ -508,20 +577,25 @@ export const AppLayout = ({ children }: LayoutProps) => {
<Link href={`/org/${currentOrg?._id}/settings`} passHref> <Link href={`/org/${currentOrg?._id}/settings`} passHref>
<a> <a>
<MenuItem <MenuItem
isSelected={ isSelected={router.asPath === `/org/${currentOrg?._id}/settings`}
router.asPath === `/org/${currentOrg?._id}/settings`
}
icon="system-outline-109-slider-toggle-settings" icon="system-outline-109-slider-toggle-settings"
> >
Organization Settings Organization Settings
</MenuItem> </MenuItem>
</a> </a>
</Link> </Link>
</Menu>} </Menu>
)}
</div> </div>
</div> </div>
<div className={`relative mt-10 ${subscription && subscription.slug === "starter" && !subscription.has_used_trial ? "mb-2" : "mb-4"} w-full px-3 text-mineshaft-400 cursor-default text-sm flex flex-col items-center`}> <div
{/* <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[9.9rem] ${router.asPath.includes("org") ? "bottom-[8.4rem]" : "bottom-[5.4rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-30`}/> className={`relative mt-10 ${
subscription && subscription.slug === "starter" && !subscription.has_used_trial
? "mb-2"
: "mb-4"
} flex w-full cursor-default flex-col items-center px-3 text-sm text-mineshaft-400`}
>
{/* <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[9.9rem] ${router.asPath.includes("org") ? "bottom-[8.4rem]" : "bottom-[5.4rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-30`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[10.7rem] ${router.asPath.includes("org") ? "bottom-[8.15rem]" : "bottom-[5.15rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-50`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[10.7rem] ${router.asPath.includes("org") ? "bottom-[8.15rem]" : "bottom-[5.15rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-50`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[11.5rem] ${router.asPath.includes("org") ? "bottom-[7.9rem]" : "bottom-[4.9rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-70`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[11.5rem] ${router.asPath.includes("org") ? "bottom-[7.9rem]" : "bottom-[4.9rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-70`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[12.3rem] ${router.asPath.includes("org") ? "bottom-[7.65rem]" : "bottom-[4.65rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-90`}/> <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[12.3rem] ${router.asPath.includes("org") ? "bottom-[7.65rem]" : "bottom-[4.65rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-90`}/>
@@ -549,22 +623,24 @@ export const AppLayout = ({ children }: LayoutProps) => {
</a> </a>
</div> </div>
</div> */} </div> */}
{router.asPath.includes("org") && <div {router.asPath.includes("org") && (
onKeyDown={() => null} <div
role="button" onKeyDown={() => null}
tabIndex={0} role="button"
onClick={() => router.push(`/org/${router.query.id}/members?action=invite`)} tabIndex={0}
className="w-full" onClick={() => router.push(`/org/${router.query.id}/members?action=invite`)}
> className="w-full"
<div className="hover:text-mineshaft-200 duration-200 mb-3 pl-5 w-full"> >
<FontAwesomeIcon icon={faPlus} className="mr-3"/> <div className="mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
Invite people <FontAwesomeIcon icon={faPlus} className="mr-3" />
Invite people
</div>
</div> </div>
</div>} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<div className="hover:text-mineshaft-200 duration-200 mb-2 pl-5 w-full"> <div className="mb-2 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faQuestion} className="px-[0.1rem] mr-3"/> <FontAwesomeIcon icon={faQuestion} className="mr-3 px-[0.1rem]" />
Help & Support Help & Support
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -586,28 +662,33 @@ export const AppLayout = ({ children }: LayoutProps) => {
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( {subscription &&
<button subscription.slug === "starter" &&
type="button" !subscription.has_used_trial && (
onClick={async () => { <button
if (!subscription || !currentOrg) return; type="button"
onClick={async () => {
// direct user to start pro trial if (!subscription || !currentOrg) return;
const url = await mutateAsync({
orgId: currentOrg._id, // direct user to start pro trial
success_url: window.location.href const url = await mutateAsync({
}); orgId: currentOrg._id,
success_url: window.location.href
window.location.href = url; });
}}
className="w-full mt-1.5" window.location.href = url;
> }}
<div className="hover:text-primary-400 text-mineshaft-300 duration-200 flex justify-left items-center py-1 bg-mineshaft-600 rounded-md hover:bg-mineshaft-500 mb-1.5 mt-1.5 pl-4 w-full"> className="mt-1.5 w-full"
<FontAwesomeIcon icon={faInfinity} className="mr-3 ml-0.5 py-2 text-primary"/> >
Start Free Pro Trial <div className="justify-left mb-1.5 mt-1.5 flex w-full items-center rounded-md bg-mineshaft-600 py-1 pl-4 text-mineshaft-300 duration-200 hover:bg-mineshaft-500 hover:text-primary-400">
</div> <FontAwesomeIcon
</button> icon={faInfinity}
)} className="mr-3 ml-0.5 py-2 text-primary"
/>
Start Free Pro Trial
</div>
</button>
)}
</div> </div>
</nav> </nav>
</aside> </aside>
+223 -129
View File
@@ -1,4 +1,3 @@
import crypto from "crypto"; import crypto from "crypto";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -9,14 +8,31 @@ import { useRouter } from "next/router";
import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { faSlack } from "@fortawesome/free-brands-svg-icons";
import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; import { faFolderOpen } from "@fortawesome/free-regular-svg-icons";
import { faArrowRight, faCheckCircle, faHandPeace, faMagnifyingGlass, faNetworkWired, faPlug, faPlus, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import {
faArrowRight,
faCheckCircle,
faHandPeace,
faMagnifyingGlass,
faNetworkWired,
faPlug,
faPlus,
faUserPlus
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck"; import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck";
import { Button, Checkbox, FormControl, Input, Modal, ModalContent, UpgradePlanModal } from "@app/components/v2"; import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
UpgradePlanModal
} from "@app/components/v2";
import { TabsObject } from "@app/components/v2/Tabs"; import { TabsObject } from "@app/components/v2/Tabs";
import { useSubscription, useUser, useWorkspace } from "@app/context"; import { useSubscription, useUser, useWorkspace } from "@app/context";
import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useUploadWsKey } from "@app/hooks/api"; import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useUploadWsKey } from "@app/hooks/api";
@@ -25,11 +41,14 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { encryptAssymmetric } from "../../../../components/utilities/cryptography/crypto"; import { encryptAssymmetric } from "../../../../components/utilities/cryptography/crypto";
import registerUserAction from "../../../api/userActions/registerUserAction"; import registerUserAction from "../../../api/userActions/registerUserAction";
const features = [{ const features = [
"_id": 0, {
"name": "Kubernetes Operator", _id: 0,
"description": "Pull secrets into your Kubernetes containers and automatically redeploy upon secret changes." name: "Kubernetes Operator",
}] description:
"Pull secrets into your Kubernetes containers and automatically redeploy upon secret changes."
}
];
type ItemProps = { type ItemProps = {
text: string; text: string;
@@ -58,7 +77,11 @@ const learningItem = ({
className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`} className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`}
href={link} href={link}
> >
<div className={`${complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""} mb-3 rounded-md`}> <div
className={`${
complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""
} mb-3 rounded-md`}
>
<div <div
onKeyDown={() => null} onKeyDown={() => null}
role="button" role="button"
@@ -70,7 +93,11 @@ const learningItem = ({
}); });
} }
}} }}
className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${complete? "bg-gradient-to-r from-[#0e1f01] to-mineshaft-700 border-mineshaft-900 cursor-default" : "bg-mineshaft-800 hover:bg-mineshaft-700 border-mineshaft-600 shadow-xl cursor-pointer"} duration-200 text-mineshaft-100`} className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${
complete
? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700"
: "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700"
} text-mineshaft-100 duration-200`}
> >
<div className="mr-4 flex flex-row items-center"> <div className="mr-4 flex flex-row items-center">
<FontAwesomeIcon icon={icon} className="mx-2 w-16 text-4xl" /> <FontAwesomeIcon icon={icon} className="mx-2 w-16 text-4xl" />
@@ -148,7 +175,11 @@ const learningItemSquare = ({
className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`} className={`w-full ${complete && "opacity-30 duration-200 hover:opacity-100"}`}
href={link} href={link}
> >
<div className={`${complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""} rounded-md w-full`}> <div
className={`${
complete ? "bg-gradient-to-r from-primary-500/70 p-[0.07rem]" : ""
} w-full rounded-md`}
>
<div <div
onKeyDown={() => null} onKeyDown={() => null}
role="button" role="button"
@@ -160,23 +191,32 @@ const learningItemSquare = ({
}); });
} }
}} }}
className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${complete? "bg-gradient-to-r from-[#0e1f01] to-mineshaft-700 border-mineshaft-900 cursor-default" : "bg-mineshaft-800 hover:bg-mineshaft-700 border-mineshaft-600 shadow-xl cursor-pointer"} duration-200 text-mineshaft-100`} className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${
complete
? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700"
: "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700"
} text-mineshaft-100 duration-200`}
> >
<div className="flex flex-col items-center w-full px-6 py-4"> <div className="flex w-full flex-col items-center px-6 py-4">
<div className="flex flex-row items-start justify-between w-full"> <div className="flex w-full flex-row items-start justify-between">
<FontAwesomeIcon icon={icon} className="w-16 text-5xl text-mineshaft-200 group-hover:text-mineshaft-100 duration-100 pt-2" /> <FontAwesomeIcon
icon={icon}
className="w-16 pt-2 text-5xl text-mineshaft-200 duration-100 group-hover:text-mineshaft-100"
/>
{complete && ( {complete && (
<div className="absolute left-14 top-12 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700"> <div className="absolute left-14 top-12 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
<FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-primary" /> <FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-primary" />
</div> </div>
)} )}
<div <div
className={`text-right text-sm font-normal text-mineshaft-300 ${complete ? "text-primary font-semibold" : ""}`} className={`text-right text-sm font-normal text-mineshaft-300 ${
complete ? "font-semibold text-primary" : ""
}`}
> >
{complete ? "Complete!" : `About ${time}`} {complete ? "Complete!" : `About ${time}`}
</div> </div>
</div> </div>
<div className="flex flex-col items-start justify-start w-full pt-4"> <div className="flex w-full flex-col items-start justify-start pt-4">
<div className="mt-0.5 text-lg font-medium">{text}</div> <div className="mt-0.5 text-lg font-medium">{text}</div>
<div className="text-sm font-normal text-mineshaft-300">{subText}</div> <div className="text-sm font-normal text-mineshaft-300">{subText}</div>
</div> </div>
@@ -202,11 +242,14 @@ export default function Organization() {
const router = useRouter(); const router = useRouter();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const orgWorkspaces = workspaces?.filter(workspace => workspace.organization === localStorage.getItem("orgData.id")) || [] const orgWorkspaces =
workspaces?.filter(
(workspace) => workspace.organization === localStorage.getItem("orgData.id")
) || [];
const currentOrg = String(router.query.id); const currentOrg = String(router.query.id);
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const addWsUser = useAddUserToWs(); const addWsUser = useAddUserToWs();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"addNewWs", "addNewWs",
"upgradePlan" "upgradePlan"
@@ -269,7 +312,7 @@ export default function Organization() {
} }
createNotification({ text: "Workspace created", type: "success" }); createNotification({ text: "Workspace created", type: "success" });
handlePopUpClose("addNewWs"); handlePopUpClose("addNewWs");
router.push(`/project/${newWorkspaceId}/secrets`); router.push(`/project/${newWorkspaceId}/secrets/overview`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ text: "Failed to create workspace", type: "error" }); createNotification({ text: "Failed to create workspace", type: "error" });
@@ -278,7 +321,9 @@ export default function Organization() {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const isAddingProjectsAllowed = subscription?.workspaceLimit ? (subscription.workspacesUsed < subscription.workspaceLimit) : true; const isAddingProjectsAllowed = subscription?.workspaceLimit
? subscription.workspacesUsed < subscription.workspaceLimit
: true;
useEffect(() => { useEffect(() => {
onboardingCheck({ onboardingCheck({
@@ -290,16 +335,16 @@ export default function Organization() {
}, []); }, []);
return ( return (
<div className="flex max-w-7xl mx-auto flex-col justify-start bg-bunker-800 md:h-screen"> <div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800 md:h-screen">
<Head> <Head>
<title>{t("common.head-title", { title: t("settings.members.title") })}</title> <title>{t("common.head-title", { title: t("settings.members.title") })}</title>
<link rel="icon" href="/infisical.ico" /> <link rel="icon" href="/infisical.ico" />
</Head> </Head>
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4"> <div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl">
<p className="mr-4 font-semibold text-white">Projects</p> <p className="mr-4 font-semibold text-white">Projects</p>
<div className="w-full flex flex-row mt-6"> <div className="mt-6 flex w-full flex-row">
<Input <Input
className="h-[2.3rem] text-sm bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by project name..." placeholder="Search by project name..."
value={searchFilter} value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)} onChange={(e) => setSearchFilter(e.target.value)}
@@ -310,7 +355,7 @@ export default function Organization() {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (isAddingProjectsAllowed) { if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs") handlePopUpOpen("addNewWs");
} else { } else {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
} }
@@ -320,120 +365,169 @@ export default function Organization() {
Add New Project Add New Project
</Button> </Button>
</div> </div>
<div className="mt-4 w-full grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{orgWorkspaces.filter(ws => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())).map(workspace => <div key={workspace._id} className="h-40 min-w-72 rounded-md bg-mineshaft-800 border border-mineshaft-600 p-4 flex flex-col justify-between"> {orgWorkspaces
<div className="text-lg text-mineshaft-100 mt-0">{workspace.name}</div> .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
<div className="text-sm text-mineshaft-300 mt-0 pb-6">{(workspace.environments?.length || 0)} environments</div> .map((workspace) => (
<button type="button" onClick={() => { <div
router.push(`/project/${workspace._id}/secrets`); key={workspace._id}
localStorage.setItem("projectData.id", workspace._id); className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
}}> >
<div className="group cursor-default ml-auto hover:bg-primary-800/20 text-sm text-mineshaft-300 hover:text-mineshaft-200 bg-mineshaft-900 py-2 px-4 rounded-full w-max border border-mineshaft-600 hover:border-primary-500/80">Explore <FontAwesomeIcon icon={faArrowRight} className="pl-1.5 pr-0.5 group-hover:pl-2 group-hover:pr-0 duration-200" /></div> <div className="mt-0 text-lg text-mineshaft-100">{workspace.name}</div>
</button> <div className="mt-0 pb-6 text-sm text-mineshaft-300">
</div>)} {workspace.environments?.length || 0} environments
</div>
<button
type="button"
onClick={() => {
router.push(`/project/${workspace._id}/secrets/overview`);
localStorage.setItem("projectData.id", workspace._id);
}}
>
<div className="group ml-auto w-max cursor-default rounded-full border border-mineshaft-600 bg-mineshaft-900 py-2 px-4 text-sm text-mineshaft-300 hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
Explore{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 group-hover:pl-2 group-hover:pr-0"
/>
</div>
</button>
</div>
))}
</div> </div>
{orgWorkspaces.length === 0 && ( {orgWorkspaces.length === 0 && (
<div className="w-full rounded-md bg-mineshaft-800 border border-mineshaft-700 px-4 py-6 text-mineshaft-300 text-base"> <div className="w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
<FontAwesomeIcon icon={faFolderOpen} className="w-full text-center text-5xl mb-4 mt-2 text-mineshaft-400" /> <FontAwesomeIcon
icon={faFolderOpen}
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
/>
<div className="text-center font-light"> <div className="text-center font-light">
You are not part of any projects in this organization yet. When you are, they will appear You are not part of any projects in this organization yet. When you are, they will
here. appear here.
</div> </div>
<div className="mt-0.5 text-center font-light"> <div className="mt-0.5 text-center font-light">
Create a new project, or ask other organization members to give you necessary permissions. Create a new project, or ask other organization members to give you necessary
permissions.
</div> </div>
</div> </div>
)} )}
</div> </div>
{((new Date()).getTime() - (new Date(user?.createdAt)).getTime()) < 30 * 24 * 60 * 60 * 1000 && <div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4"> {new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000 && (
<p className="mr-4 font-semibold text-white mb-4">Onboarding Guide</p> <div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3 w-full mb-3"> <p className="mr-4 mb-4 font-semibold text-white">Onboarding Guide</p>
{learningItemSquare({ <div className="mb-3 grid w-full grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
text: "Watch Infisical demo", {learningItemSquare({
subText: "Set up Infisical in 3 min.", text: "Watch Infisical demo",
complete: hasUserClickedIntro, subText: "Set up Infisical in 3 min.",
icon: faHandPeace, complete: hasUserClickedIntro,
time: "3 min", icon: faHandPeace,
userAction: "intro_cta_clicked", time: "3 min",
link: "https://www.youtube.com/watch?v=PK23097-25I" userAction: "intro_cta_clicked",
})} link: "https://www.youtube.com/watch?v=PK23097-25I"
{orgWorkspaces.length !== 0 && learningItemSquare({ })}
text: "Add your secrets", {orgWorkspaces.length !== 0 &&
subText: "Drop a .env file or type your secrets.", learningItemSquare({
complete: hasUserPushedSecrets, text: "Add your secrets",
icon: faPlus, subText: "Drop a .env file or type your secrets.",
time: "1 min", complete: hasUserPushedSecrets,
userAction: "first_time_secrets_pushed", icon: faPlus,
link: `/project/${orgWorkspaces[0]?._id}/secrets` time: "1 min",
})} userAction: "first_time_secrets_pushed",
{learningItemSquare({ link: `/project/${orgWorkspaces[0]?._id}/secrets`
text: "Invite your teammates", })}
subText: "Infisical is better used as a team.", {learningItemSquare({
complete: usersInOrg, text: "Invite your teammates",
icon: faUserPlus, subText: "Infisical is better used as a team.",
time: "2 min", complete: usersInOrg,
link: `/org/${router.query.id}/members?action=invite` icon: faUserPlus,
})} time: "2 min",
<div className="block xl:hidden 2xl:block">{learningItemSquare({ link: `/org/${router.query.id}/members?action=invite`
text: "Join Infisical Slack", })}
subText: "Have any questions? Ask us!", <div className="block xl:hidden 2xl:block">
complete: hasUserClickedSlack, {learningItemSquare({
icon: faSlack, text: "Join Infisical Slack",
time: "1 min", subText: "Have any questions? Ask us!",
userAction: "slack_cta_clicked", complete: hasUserClickedSlack,
link: "https://infisical.com/slack" icon: faSlack,
})}</div> time: "1 min",
</div> userAction: "slack_cta_clicked",
{orgWorkspaces.length !== 0 && <div className="group text-mineshaft-100 relative mb-3 flex h-full w-full cursor-default flex-col items-center justify-between overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-800 pl-2 pr-2 pt-4 pb-2 shadow-xl duration-200"> link: "https://infisical.com/slack"
<div className="mb-4 flex w-full flex-row items-center pr-4"> })}
<div className="mr-4 flex w-full flex-row items-center">
<FontAwesomeIcon icon={faNetworkWired} className="mx-2 w-16 text-4xl" />
{false && (
<div className="absolute left-12 top-10 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
<FontAwesomeIcon icon={faCheckCircle} className="h-5 w-5 text-4xl text-green" />
</div>
)}
<div className="flex flex-col items-start pl-0.5">
<div className="mt-0.5 text-xl font-semibold">Inject secrets locally</div>
<div className="text-sm font-normal">
Replace .env files with a more secure and efficient alternative.
</div>
</div>
</div>
<div className={`w-28 pr-4 text-right text-sm font-semibold ${false && "text-green"}`}>
About 2 min
</div> </div>
</div> </div>
<TabsObject /> {orgWorkspaces.length !== 0 && (
{false && <div className="absolute bottom-0 left-0 h-1 w-full bg-green" />} <div className="group relative mb-3 flex h-full w-full cursor-default flex-col items-center justify-between overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-800 pl-2 pr-2 pt-4 pb-2 text-mineshaft-100 shadow-xl duration-200">
</div>} <div className="mb-4 flex w-full flex-row items-center pr-4">
{orgWorkspaces.length !== 0 && learningItem({ <div className="mr-4 flex w-full flex-row items-center">
text: "Integrate Infisical with your infrastructure", <FontAwesomeIcon icon={faNetworkWired} className="mx-2 w-16 text-4xl" />
subText: "Connect Infisical to various 3rd party services and platforms.", {false && (
complete: false, <div className="absolute left-12 top-10 flex h-7 w-7 items-center justify-center rounded-full bg-bunker-500 p-2 group-hover:bg-mineshaft-700">
icon: faPlug, <FontAwesomeIcon
time: "15 min", icon={faCheckCircle}
link: "https://infisical.com/docs/integrations/overview" className="h-5 w-5 text-4xl text-green"
})} />
</div>} </div>
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4 pb-6"> )}
<p className="mr-4 font-semibold text-white">Explore More</p> <div className="flex flex-col items-start pl-0.5">
<div className="mt-4 w-full grid grid-flow-dense gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 4fr))" }}> <div className="mt-0.5 text-xl font-semibold">Inject secrets locally</div>
{features.map(feature => <div key={feature._id} className="h-44 w-96 rounded-md bg-mineshaft-800 border border-mineshaft-600 p-4 flex flex-col justify-between"> <div className="text-sm font-normal">
<div className="text-lg text-mineshaft-100 mt-0">{feature.name}</div> Replace .env files with a more secure and efficient alternative.
<div className="text-[15px] font-light text-mineshaft-300 mb-4 mt-2">{feature.description}</div> </div>
<div className="w-full flex items-center"> </div>
<div className="text-mineshaft-300 text-[15px] font-light">Setup time: 20 min</div> </div>
<a <div
target="_blank" className={`w-28 pr-4 text-right text-sm font-semibold ${false && "text-green"}`}
rel="noopener noreferrer" >
className="group cursor-default ml-auto hover:bg-primary-800/20 text-sm text-mineshaft-300 hover:text-mineshaft-200 bg-mineshaft-900 py-2 px-4 rounded-full w-max border border-mineshaft-600 hover:border-primary-500/80" About 2 min
href="https://infisical.com/docs/documentation/getting-started/kubernetes" </div>
> </div>
Learn more <FontAwesomeIcon icon={faArrowRight} className="pl-1.5 pr-0.5 group-hover:pl-2 group-hover:pr-0 duration-200"/> <TabsObject />
</a> {false && <div className="absolute bottom-0 left-0 h-1 w-full bg-green" />}
</div> </div>
</div>)} )}
{orgWorkspaces.length !== 0 &&
learningItem({
text: "Integrate Infisical with your infrastructure",
subText: "Connect Infisical to various 3rd party services and platforms.",
complete: false,
icon: faPlug,
time: "15 min",
link: "https://infisical.com/docs/integrations/overview"
})}
</div>
)}
<div className="mb-4 flex flex-col items-start justify-start px-6 py-6 pb-0 pb-6 text-3xl">
<p className="mr-4 font-semibold text-white">Explore More</p>
<div
className="mt-4 grid w-full grid-flow-dense gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(256px, 4fr))" }}
>
{features.map((feature) => (
<div
key={feature._id}
className="flex h-44 w-96 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
>
<div className="mt-0 text-lg text-mineshaft-100">{feature.name}</div>
<div className="mb-4 mt-2 text-[15px] font-light text-mineshaft-300">
{feature.description}
</div>
<div className="flex w-full items-center">
<div className="text-[15px] font-light text-mineshaft-300">Setup time: 20 min</div>
<a
target="_blank"
rel="noopener noreferrer"
className="group ml-auto w-max cursor-default rounded-full border border-mineshaft-600 bg-mineshaft-900 py-2 px-4 text-sm text-mineshaft-300 hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200"
href="https://infisical.com/docs/documentation/getting-started/kubernetes"
>
Learn more{" "}
<FontAwesomeIcon
icon={faArrowRight}
className="pl-1.5 pr-0.5 duration-200 group-hover:pl-2 group-hover:pr-0"
/>
</a>
</div>
</div>
))}
</div> </div>
</div> </div>
<Modal <Modal
@@ -1,16 +1,10 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router";
import { DashboardPage } from "@app/views/DashboardPage"; import { DashboardPage } from "@app/views/DashboardPage";
import { DashboardEnvOverview } from "@app/views/DashboardPage/DashboardEnvOverview";
const Dashboard = () => { const Dashboard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const queryEnv = router.query.env as string;
const isOverviewMode = !queryEnv;
return ( return (
<> <>
@@ -22,7 +16,7 @@ const Dashboard = () => {
<meta name="og:description" content={String(t("dashboard.og-description"))} /> <meta name="og:description" content={String(t("dashboard.og-description"))} />
</Head> </Head>
<div className="h-full"> <div className="h-full">
{isOverviewMode ? <DashboardEnvOverview /> : <DashboardPage envFromTop={queryEnv} />} <DashboardPage />
</div> </div>
</> </>
); );
@@ -53,7 +53,7 @@ export const OrgIncidentContactsTable = ({
isLoading isLoading
}: Props) => { }: Props) => {
const [searchContact, setSearchContact] = useState(""); const [searchContact, setSearchContact] = useState("");
const {data: serverDetails } = useFetchServerStatus() const { data: serverDetails } = useFetchServerStatus();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addContact", "addContact",
"removeContact", "removeContact",
@@ -98,7 +98,7 @@ export const OrgIncidentContactsTable = ({
<Button <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (serverDetails?.emailConfigured){ if (serverDetails?.emailConfigured) {
handlePopUpOpen("addContact"); handlePopUpOpen("addContact");
} else { } else {
handlePopUpOpen("setUpEmail"); handlePopUpOpen("setUpEmail");
@@ -119,7 +119,7 @@ export const OrgIncidentContactsTable = ({
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={2} key="incident-contact" />} {isLoading && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email }) => ( {filteredContacts?.map(({ email }) => (
<Tr key={email}> <Tr key={email}>
<Td className="w-full">{email}</Td> <Td className="w-full">{email}</Td>
@@ -1,7 +1,14 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons"; import {
faCheck,
faCopy,
faMagnifyingGlass,
faPlus,
faTrash,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
@@ -10,7 +17,8 @@ import { useNotificationContext } from "@app/components/context/Notifications/No
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmailServiceSetupModal, EmptyState, EmailServiceSetupModal,
EmptyState,
FormControl, FormControl,
IconButton, IconButton,
Input, Input,
@@ -29,7 +37,7 @@ import {
Tr, Tr,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization , useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useGetSSOConfig } from "@app/hooks/api"; import { useGetSSOConfig } from "@app/hooks/api";
import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
@@ -47,8 +55,8 @@ type Props = {
onGrantAccess: (userId: string, publicKey: string) => Promise<void>; onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
// the current user id to block remove org button // the current user id to block remove org button
userId: string; userId: string;
completeInviteLink: string | undefined, completeInviteLink: string | undefined;
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>> setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>;
}; };
const addMemberFormSchema = yup.object({ const addMemberFormSchema = yup.object({
@@ -76,7 +84,7 @@ export const OrgMembersTable = ({
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? ""); const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? "");
const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [searchMemberFilter, setSearchMemberFilter] = useState("");
const {data: serverDetails } = useFetchServerStatus() const { data: serverDetails } = useFetchServerStatus();
const { workspaces } = useWorkspace(); const { workspaces } = useWorkspace();
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -85,7 +93,7 @@ export const OrgMembersTable = ({
"upgradePlan", "upgradePlan",
"setUpEmail" "setUpEmail"
] as const); ] as const);
useEffect(() => { useEffect(() => {
if (router.query.action === "invite") { if (router.query.action === "invite") {
handlePopUpOpen("addMember"); handlePopUpOpen("addMember");
@@ -101,11 +109,11 @@ export const OrgMembersTable = ({
const onAddMember = async ({ email }: TAddMemberForm) => { const onAddMember = async ({ email }: TAddMemberForm) => {
await onInviteMember(email); await onInviteMember(email);
if (serverDetails?.emailConfigured){ if (serverDetails?.emailConfigured) {
handlePopUpClose("addMember"); handlePopUpClose("addMember");
} }
reset(); reset();
}; };
const onRemoveOrgMemberApproved = async () => { const onRemoveOrgMemberApproved = async () => {
@@ -118,7 +126,7 @@ export const OrgMembersTable = ({
() => members.find(({ user }) => userId === user?._id)?.role === "owner", () => members.find(({ user }) => userId === user?._id)?.role === "owner",
[userId, members] [userId, members]
); );
const filterdUser = useMemo( const filterdUser = useMemo(
() => () =>
members.filter( members.filter(
@@ -163,10 +171,10 @@ export const OrgMembersTable = ({
text: "You cannot invite users when SAML SSO is configured for your organization", text: "You cannot invite users when SAML SSO is configured for your organization",
type: "error" type: "error"
}); });
return; return;
} }
if (isMoreUserNotAllowed) { if (isMoreUserNotAllowed) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan");
} else { } else {
@@ -190,7 +198,7 @@ export const OrgMembersTable = ({
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={5} key="org-members" />} {isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading && {!isLoading &&
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
const name = user ? `${user.firstName} ${user.lastName}` : "-"; const name = user ? `${user.firstName} ${user.lastName}` : "-";
@@ -219,11 +227,17 @@ export const OrgMembersTable = ({
<SelectItem value="member">member</SelectItem> <SelectItem value="member">member</SelectItem>
</Select> </Select>
)} )}
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && ( {(status === "invited" || status === "verified") &&
<Button className='w-40' colorSchema="primary" variant="outline_bg" onClick={() => onInviteMember(email)}> serverDetails?.emailConfigured && (
Resend Invite <Button
</Button> className="w-40"
)} colorSchema="primary"
variant="outline_bg"
onClick={() => onInviteMember(email)}
>
Resend Invite
</Button>
)}
{status === "completed" && ( {status === "completed" && (
<Button <Button
colorSchema="secondary" colorSchema="secondary"
@@ -241,18 +255,33 @@ export const OrgMembersTable = ({
</Tag> </Tag>
)) ))
) : ( ) : (
<div className='flex flex-row'> <div className="flex flex-row">
{((status === "invited" || status === "verified") && serverDetails?.emailConfigured) {(status === "invited" || status === "verified") &&
? <Tag colorSchema="red">This user hasn&apos;t accepted the invite yet</Tag> serverDetails?.emailConfigured ? (
: <Tag colorSchema="red">This user isn&apos;t part of any projects yet</Tag>} <Tag colorSchema="red">
{router.query.id !== "undefined" && !((status === "invited" || status === "verified") && serverDetails?.emailConfigured) && <button This user hasn&apos;t accepted the invite yet
type="button" </Tag>
onClick={() => router.push(`/project/${workspaces[0]?._id}/members`)} ) : (
className='text-sm bg-mineshaft w-max px-1.5 py-0.5 hover:bg-primary duration-200 hover:text-black cursor-pointer rounded-sm' <Tag colorSchema="red">
> This user isn&apos;t part of any projects yet
<FontAwesomeIcon icon={faPlus} className="mr-1" /> </Tag>
Add to projects )}
</button>} {router.query.id !== "undefined" &&
!(
(status === "invited" || status === "verified") &&
serverDetails?.emailConfigured
) && (
<button
type="button"
onClick={() =>
router.push(`/project/${workspaces[0]?._id}/members`)
}
className="w-max cursor-pointer rounded-sm bg-mineshaft px-1.5 py-0.5 text-sm duration-200 hover:bg-primary hover:text-black"
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
Add to projects
</button>
)}
</div> </div>
)} )}
</Td> </Td>
@@ -282,67 +311,73 @@ export const OrgMembersTable = ({
isOpen={popUp?.addMember?.isOpen} isOpen={popUp?.addMember?.isOpen}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("addMember", isOpen); handlePopUpToggle("addMember", isOpen);
setCompleteInviteLink(undefined) setCompleteInviteLink(undefined);
}} }}
> >
<ModalContent <ModalContent
title={`Invite others to ${orgName}`} title={`Invite others to ${orgName}`}
subTitle={ subTitle={
<div> <div>
{!completeInviteLink && <div> {!completeInviteLink && (
An invite is specific to an email address and expires after 1 day. <div>
<br /> An invite is specific to an email address and expires after 1 day.
For security reasons, you will need to separately add members to projects. <br />
</div>} For security reasons, you will need to separately add members to projects.
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"} </div>
)}
{completeInviteLink &&
"This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
</div> </div>
} }
> >
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} > {!completeInviteLink && (
<Controller <form onSubmit={handleSubmit(onAddMember)}>
control={control} <Controller
defaultValue="" control={control}
name="email" defaultValue=""
render={({ field, fieldState: { error } }) => ( name="email"
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}> render={({ field, fieldState: { error } }) => (
<Input {...field} /> <FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
</FormControl> <Input {...field} />
)} </FormControl>
/> )}
<div className="mt-8 flex items-center"> />
<Button <div className="mt-8 flex items-center">
className="mr-4" <Button
size="sm" className="mr-4"
type="submit" size="sm"
isLoading={isSubmitting} type="submit"
isDisabled={isSubmitting} isLoading={isSubmitting}
> isDisabled={isSubmitting}
Add Member >
</Button> Add Member
<Button </Button>
colorSchema="secondary" <Button
variant="plain" colorSchema="secondary"
onClick={() => handlePopUpClose("addMember")} variant="plain"
> onClick={() => handlePopUpClose("addMember")}
Cancel >
</Button> Cancel
</div> </Button>
</form>} </div>
{ </form>
completeInviteLink && )}
{completeInviteLink && (
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400"> <div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{completeInviteLink}</p> <p className="mr-4 break-all">{completeInviteLink}</p>
<IconButton <IconButton
ariaLabel="copy icon" ariaLabel="copy icon"
colorSchema="secondary" colorSchema="secondary"
className="group relative" className="group relative"
onClick={copyTokenToClipboard} onClick={copyTokenToClipboard}
> >
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} /> <FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span> <span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
</IconButton> click to copy
</div> </span>
} </IconButton>
</div>
)}
</ModalContent> </ModalContent>
</Modal> </Modal>
<DeleteActionModal <DeleteActionModal
@@ -363,4 +398,4 @@ export const OrgMembersTable = ({
/> />
</div> </div>
); );
}; };
@@ -1,14 +1,15 @@
import { useEffect, useMemo,useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Controller,useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
faCheck, faCheck,
faCopy, faCopy,
faMagnifyingGlass, faMagnifyingGlass,
faPencil, faPencil,
faPlus, faPlus,
faServer, faServer,
faTrash} from "@fortawesome/free-solid-svg-icons"; faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
@@ -37,9 +38,10 @@ import {
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { import {
useCreateServiceAccount, useCreateServiceAccount,
useDeleteServiceAccount, useDeleteServiceAccount,
useGetServiceAccounts} from "@app/hooks/api"; useGetServiceAccounts
} from "@app/hooks/api";
const serviceAccountExpiration = [ const serviceAccountExpiration = [
{ label: "1 Day", value: 86400 }, { label: "1 Day", value: 86400 },
@@ -51,317 +53,315 @@ const serviceAccountExpiration = [
]; ];
const addServiceAccountFormSchema = yup.object({ const addServiceAccountFormSchema = yup.object({
name: yup.string().required().label("Name").trim(), name: yup.string().required().label("Name").trim(),
expiresIn: yup.string().required().label("Service Account Expiration") expiresIn: yup.string().required().label("Service Account Expiration")
}); });
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>; type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => { export const OrgServiceAccountsTable = () => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey, setAccessKey] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
"removeServiceAccount",
] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0);
const createServiceAccount = useCreateServiceAccount(); const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const removeServiceAccount = useDeleteServiceAccount(); const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
useEffect(() => { const [accessKey, setAccessKey] = useState("");
let timer: NodeJS.Timeout; const [publicKey, setPublicKey] = useState("");
if (isAccessKeyCopied) { const [privateKey, setPrivateKey] = useState("");
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
} const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
"removeServiceAccount"
] as const);
if (isPublicKeyCopied) { const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000); useGetServiceAccounts(orgId);
}
if (isPrivateKeyCopied) { const createServiceAccount = useCreateServiceAccount();
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); const removeServiceAccount = useDeleteServiceAccount();
}
return () => clearTimeout(timer); useEffect(() => {
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]); let timer: NodeJS.Timeout;
if (isAccessKeyCopied) {
const { timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
if (!currentOrg?._id) return;
const keyPair = generateKeyPair();
setPublicKey(keyPair.publicKey);
setPrivateKey(keyPair.privateKey);
const serviceAccountDetails = await createServiceAccount.mutateAsync({
name,
organizationId: currentOrg?._id,
publicKey: keyPair.publicKey,
expiresIn: Number(expiresIn)
});
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
setStep(1);
reset();
} }
const onRemoveServiceAccount = async () => { if (isPublicKeyCopied) {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose("removeServiceAccount");
} }
const filteredServiceAccounts = useMemo( if (isPrivateKeyCopied) {
() => timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
serviceAccounts.filter(
({ name }) =>
name.toLowerCase().includes(searchServiceAccountFilter)
),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<form onSubmit={handleSubmit(onAddServiceAccount)}>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />
}
} }
return ( return () => clearTimeout(timer);
<div className="w-full"> }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
<div className="mb-4 flex">
<div className="mr-4 flex-1"> const {
<Input control,
value={searchServiceAccountFilter} handleSubmit,
onChange={(e) => setSearchServiceAccountFilter(e.target.value)} reset,
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} formState: { isSubmitting }
placeholder="Search service accounts..." } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
/>
</div> const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
<Button if (!currentOrg?._id) return;
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { const keyPair = generateKeyPair();
setStep(0); setPublicKey(keyPair.publicKey);
reset(); setPrivateKey(keyPair.privateKey);
handlePopUpOpen("addServiceAccount");
}} const serviceAccountDetails = await createServiceAccount.mutateAsync({
> name,
Add Service Account organizationId: currentOrg?._id,
</Button> publicKey: keyPair.publicKey,
</div> expiresIn: Number(expiresIn)
<TableContainer> });
<Table>
<THead> setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
<Th>Name</Th>
<Th className="w-full">Valid Until</Th> setStep(1);
<Th aria-label="actions" /> reset();
</THead> };
<TBody>
{isServiceAccountsLoading && <TableSkeleton columns={5} key="org-service-accounts" />} const onRemoveServiceAccount = async () => {
{!isServiceAccountsLoading && ( const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
filteredServiceAccounts.map(({ await removeServiceAccount.mutateAsync(serviceAccountId);
name, handlePopUpClose("removeServiceAccount");
expiresAt, };
_id: serviceAccountId
}) => { const filteredServiceAccounts = useMemo(
return ( () =>
<Tr key={`org-service-account-${serviceAccountId}`}> serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
<Td>{name}</Td> [serviceAccounts, searchServiceAccountFilter]
<Td>{new Date(expiresAt).toUTCString()}</Td> );
<Td>
<div className="flex"> const renderStep = (stepToRender: number) => {
<IconButton switch (stepToRender) {
ariaLabel="edit" case 0:
colorSchema="secondary" return (
onClick={() => { <form onSubmit={handleSubmit(onAddServiceAccount)}>
if (currentWorkspace?._id) { <Controller
router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); control={control}
} defaultValue=""
}} name="name"
className="mr-2" render={({ field, fieldState: { error } }) => (
> <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<FontAwesomeIcon icon={faPencil} /> <Input {...field} />
</IconButton> </FormControl>
<IconButton )}
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})
)}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/> />
<Controller
control={control}
name="expiresIn"
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
return (
<FormControl
label="Expiration"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{serviceAccountExpiration.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Create Service Account
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpClose("addServiceAccount")}
>
Cancel
</Button>
</div>
</form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />;
}
};
return (
<div className="w-full">
<div className="mb-4 flex">
<div className="mr-4 flex-1">
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
</div> </div>
); <Button
} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && (
<TableSkeleton columns={5} innerKey="org-service-accounts" />
)}
{!isServiceAccountsLoading &&
filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(
`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() =>
handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
};
@@ -10,90 +10,94 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
} from "@app/components/v2";
import timeSince from "@app/ee/utilities/timeSince"; import timeSince from "@app/ee/utilities/timeSince";
import getRisksByOrganization, { GitRisks } from "@app/pages/api/secret-scanning/getRisksByOrganization"; import getRisksByOrganization, {
GitRisks
} from "@app/pages/api/secret-scanning/getRisksByOrganization";
import { RiskStatusSelection } from "./RiskStatusSelection"; import { RiskStatusSelection } from "./RiskStatusSelection";
export const SecretScanningLogsTable = () => { export const SecretScanningLogsTable = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [gitRisks, setGitRisks] = useState<GitRisks[]>([]); const [gitRisks, setGitRisks] = useState<GitRisks[]>([]);
useEffect(() => { useEffect(() => {
const fetchRisks = async () => { const fetchRisks = async () => {
setIsLoading(true); setIsLoading(true);
const risks = await getRisksByOrganization(String(localStorage.getItem("orgData.id"))) const risks = await getRisksByOrganization(String(localStorage.getItem("orgData.id")));
setGitRisks(risks); setGitRisks(risks);
setIsLoading(false); setIsLoading(false);
} };
fetchRisks(); fetchRisks();
},[]) }, []);
return (
return ( <TableContainer className="mt-8">
<TableContainer className="mt-8"> <Table>
<Table> <THead>
<THead> <Tr>
<Tr> <Th className="flex-1">Date</Th>
<Th className="flex-1">Date</Th> <Th className="flex-1">Secret Type</Th>
<Th className="flex-1">Secret Type</Th> <Th className="flex-1">View Risk</Th>
<Th className="flex-1">View Risk</Th> <Th className="flex-1">Info</Th>
<Th className="flex-1">Info</Th> <Th className="flex-1">Status</Th>
<Th className="flex-1">Status</Th> <Th className="flex-1">Action</Th>
<Th className="flex-1">Action</Th> <Th className="w-5" />
<Th className="w-5" /> </Tr>
</THead>
<TBody>
{!isLoading &&
gitRisks &&
gitRisks?.map((risk) => {
return (
<Tr key={risk.ruleID} className="h-10">
<Td>{timeSince(new Date(risk.createdAt))}</Td>
<Td>{risk.ruleID}</Td>
<Td>
<a
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`}
target="_blank"
className="text-red-500"
rel="noreferrer"
>
View Exposed Secret
</a>
</Td>
<Td>
<div className="font-bold">
<a href={`https://github.com/${risk.repositoryFullName}`}>
{risk.repositoryFullName}
</a>
</div>
<div className="text-xs">
<span>{risk.file}</span>
<br />
<br />
<span className="font-bold">{risk.author}</span>
<br />
<span>{risk.email}</span>
</div>
</Td>
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
<Td>
<RiskStatusSelection riskId={risk._id} currentSelection={risk.status} />
</Td>
</Tr> </Tr>
</THead> );
<TBody> })}
{!isLoading && gitRisks && gitRisks?.map((risk) => { {isLoading && <TableSkeleton columns={7} innerKey="gitRisks" />}
return ( {!isLoading && gitRisks && gitRisks?.length === 0 && (
<Tr key={risk.ruleID} className="h-10"> <Tr>
<Td>{timeSince(new Date(risk.createdAt))}</Td> <Td colSpan={7}>
<Td>{risk.ruleID}</Td> <EmptyState title="No risks detected." icon={faCheck} />
<Td> </Td>
<a </Tr>
href={`https://github.com/${risk.repositoryFullName}/blob/${risk.commit}/${risk.file}#L${risk.startLine}-L${risk.endLine}`} )}
target="_blank" </TBody>
className="text-red-500" rel="noreferrer" </Table>
> </TableContainer>
View Exposed Secret );
</a> };
</Td>
<Td>
<div className="font-bold">
<a href={`https://github.com/${risk.repositoryFullName}`}>
{risk.repositoryFullName}
</a>
</div>
<div className="text-xs">
<span>{risk.file}</span><br/>
<br/>
<span className="font-bold">{risk.author}</span><br/>
<span>{risk.email}</span>
</div>
</Td>
<Td>{risk.isResolved ? "Resolved" : "Needs Attention"}</Td>
<Td>
<RiskStatusSelection riskId={risk._id} currentSelection={risk.status}/>
</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={7} key="gitRisks" />}
{!isLoading && gitRisks && gitRisks?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState
title="No risks detected."
icon={faCheck}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,4 +1,4 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons"; import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -10,78 +10,63 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
} from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useGetOrgPlanTable } from "@app/hooks/api";
useGetOrgPlanTable
} from "@app/hooks/api";
export const CurrentPlanSection = () => { export const CurrentPlanSection = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPlanTable(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgPlanTable(currentOrg?._id ?? "");
const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "-";
if (typeof value === "boolean") {
if (value) return (
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return ( const displayCell = (value: null | number | string | boolean) => {
<FontAwesomeIcon if (value === null) return "-";
icon={faCircleXmark}
color='#e74c3c' if (typeof value === "boolean") {
/> if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
);
} return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
return value;
} }
return ( return value;
<div className="p-4 bg-mineshaft-900 mb-6 rounded-lg border border-mineshaft-600"> };
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
<TableContainer className="mt-4"> return (
<Table> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<THead> <h2 className="mb-8 flex-1 text-xl font-semibold text-white">Current Usage</h2>
<Tr> <TableContainer className="mt-4">
<Th className="w-1/3">Feature</Th> <Table>
<Th className="w-1/3">Allowed</Th> <THead>
<Th className="w-1/3">Used</Th> <Tr>
</Tr> <Th className="w-1/3">Feature</Th>
</THead> <Th className="w-1/3">Allowed</Th>
<TBody> <Th className="w-1/3">Used</Th>
{!isLoading && data && data?.rows?.length > 0 && data.rows.map(({ </Tr>
name, </THead>
allowed, <TBody>
used {!isLoading &&
}) => { data &&
return ( data?.rows?.length > 0 &&
<Tr key={`current-plan-row-${name}`} className="h-12"> data.rows.map(({ name, allowed, used }) => {
<Td>{name}</Td> return (
<Td>{displayCell(allowed)}</Td> <Tr key={`current-plan-row-${name}`} className="h-12">
<Td>{used}</Td> <Td>{name}</Td>
</Tr> <Td>{displayCell(allowed)}</Td>
); <Td>{used}</Td>
})} </Tr>
{isLoading && <TableSkeleton columns={5} key="invoices" />} );
{!isLoading && data && data?.rows?.length === 0 && ( })}
<Tr> {isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
<Td colSpan={3}> {!isLoading && data && data?.rows?.length === 0 && (
<EmptyState <Tr>
title="No plan details found" <Td colSpan={3}>
icon={faFileInvoice} <EmptyState title="No plan details found" icon={faFileInvoice} />
/> </Td>
</Td> </Tr>
</Tr> )}
)} </TBody>
</TBody> </Table>
</Table> </TableContainer>
</TableContainer> </div>
</div> );
); };
}
@@ -1,4 +1,4 @@
import { faCircleCheck, faCircleXmark,faFileInvoice } from "@fortawesome/free-solid-svg-icons"; import { faCircleCheck, faCircleXmark, faFileInvoice } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -11,166 +11,129 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization,useSubscription } from "@app/context"; import { useOrganization, useSubscription } from "@app/context";
import { import { useCreateCustomerPortalSession, useGetOrgPlansTable } from "@app/hooks/api";
useCreateCustomerPortalSession,
useGetOrgPlansTable} from "@app/hooks/api";
type Props = { type Props = {
billingCycle: "monthly" | "yearly" billingCycle: "monthly" | "yearly";
} };
export const ManagePlansTable = ({ export const ManagePlansTable = ({ billingCycle }: Props) => {
const { currentOrg } = useOrganization();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?._id ?? "",
billingCycle billingCycle
}: Props) => { });
const { currentOrg } = useOrganization(); const createCustomerPortalSession = useCreateCustomerPortalSession();
const { subscription } = useSubscription();
const { data: tableData, isLoading: isTableDataLoading } = useGetOrgPlansTable({
organizationId: currentOrg?._id ?? "",
billingCycle
});
const createCustomerPortalSession = useCreateCustomerPortalSession();
const displayCell = (value: null | number | string | boolean) => { const displayCell = (value: null | number | string | boolean) => {
if (value === null) return "Unlimited"; if (value === null) return "Unlimited";
if (typeof value === "boolean") {
if (value) return (
<FontAwesomeIcon
icon={faCircleCheck}
color='#2ecc71'
/>
);
return ( if (typeof value === "boolean") {
<FontAwesomeIcon if (value) return <FontAwesomeIcon icon={faCircleCheck} color="#2ecc71" />;
icon={faCircleXmark}
color='#e74c3c' return <FontAwesomeIcon icon={faCircleXmark} color="#e74c3c" />;
/>
);
}
return value;
} }
return ( return value;
<TableContainer> };
<Table>
<THead>
{subscription && !isTableDataLoading && tableData && (
<Tr>
<Th className="">Feature / Limit</Th>
{tableData.head.map(({
name,
priceLine
}) => {
return (
<Th
key={`plans-feature-head-${billingCycle}-${name}`}
className="text-center flex-1"
>
<p>{name}</p>
<p>{priceLine}</p>
</Th>
);
})}
</Tr>
)}
</THead>
<TBody>
{subscription && !isTableDataLoading && tableData && tableData.rows.map(({
name,
starter,
team,
pro,
enterprise
}) => {
return (
<Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
<Td>{displayCell(name)}</Td>
<Td className="text-center">
{displayCell(starter)}
</Td>
<Td className="text-center">
{displayCell(team)}
</Td>
<Td className="text-center">
{displayCell(pro)}
</Td>
<Td className="text-center">
{displayCell(enterprise)}
</Td>
</Tr>
);
})}
{isTableDataLoading && <TableSkeleton columns={5} key="cloud-products" />}
{!isTableDataLoading && tableData?.rows.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No cloud product details found"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({
slug,
tier
}) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) {
subscriptionText = "Downgrade"
}
if (tier === 3) {
subscriptionText = "Contact sales"
}
return isCurrentPlan ? ( return (
<Td> <TableContainer>
<Button <Table>
colorSchema="secondary" <THead>
className="w-full" {subscription && !isTableDataLoading && tableData && (
isDisabled <Tr>
> <Th className="">Feature / Limit</Th>
Current {tableData.head.map(({ name, priceLine }) => {
</Button> return (
</Td> <Th
) : ( key={`plans-feature-head-${billingCycle}-${name}`}
<Td> className="flex-1 text-center"
<Button >
onClick={async () => { <p>{name}</p>
if (!currentOrg?._id) return; <p>{priceLine}</p>
</Th>
if (tier !== 3) { );
const { url } = await createCustomerPortalSession.mutateAsync(currentOrg._id); })}
window.location.href = url; </Tr>
return; )}
} </THead>
<TBody>
window.location.href = "https://infisical.com/scheduledemo"; {subscription &&
}} !isTableDataLoading &&
color="mineshaft" tableData &&
className="w-full" tableData.rows.map(({ name, starter, team, pro, enterprise }) => {
> return (
{subscriptionText} <Tr className="h-12" key={`plans-feature-row-${billingCycle}-${name}`}>
</Button> <Td>{displayCell(name)}</Td>
</Td> <Td className="text-center">{displayCell(starter)}</Td>
); <Td className="text-center">{displayCell(team)}</Td>
})} <Td className="text-center">{displayCell(pro)}</Td>
</Tr> <Td className="text-center">{displayCell(enterprise)}</Td>
)} </Tr>
</TBody> );
</Table> })}
</TableContainer> {isTableDataLoading && <TableSkeleton columns={5} innerKey="cloud-products" />}
); {!isTableDataLoading && tableData?.rows.length === 0 && (
} <Tr>
<Td colSpan={5}>
<EmptyState title="No cloud product details found" icon={faFileInvoice} />
</Td>
</Tr>
)}
{subscription && !isTableDataLoading && tableData && (
<Tr className="h-12">
<Td />
{tableData.head.map(({ slug, tier }) => {
const isCurrentPlan = slug === subscription.slug;
let subscriptionText = "Upgrade";
if (subscription.tier > tier) {
subscriptionText = "Downgrade";
}
if (tier === 3) {
subscriptionText = "Contact sales";
}
return isCurrentPlan ? (
<Td>
<Button colorSchema="secondary" className="w-full" isDisabled>
Current
</Button>
</Td>
) : (
<Td>
<Button
onClick={async () => {
if (!currentOrg?._id) return;
if (tier !== 3) {
const { url } = await createCustomerPortalSession.mutateAsync(
currentOrg._id
);
window.location.href = url;
return;
}
window.location.href = "https://infisical.com/scheduledemo";
}}
color="mineshaft"
className="w-full"
>
{subscriptionText}
</Button>
</Td>
);
})}
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -14,78 +14,68 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api";
useDeleteOrgPmtMethod,
useGetOrgPmtMethods
} from "@app/hooks/api";
export const PmtMethodsTable = () => { export const PmtMethodsTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgPmtMethods(currentOrg?._id ?? "");
const deleteOrgPmtMethod = useDeleteOrgPmtMethod(); const deleteOrgPmtMethod = useDeleteOrgPmtMethod();
const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => { const handleDeletePmtMethodBtnClick = async (pmtMethodId: string) => {
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
await deleteOrgPmtMethod.mutateAsync({ await deleteOrgPmtMethod.mutateAsync({
organizationId: currentOrg._id, organizationId: currentOrg._id,
pmtMethodId pmtMethodId
}); });
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Brand</Th> <Th className="flex-1">Brand</Th>
<Th className="flex-1">Type</Th> <Th className="flex-1">Type</Th>
<Th className="flex-1">Last 4 Digits</Th> <Th className="flex-1">Last 4 Digits</Th>
<Th className="flex-1">Expiration</Th> <Th className="flex-1">Expiration</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{!isLoading && data && data?.length > 0 && data.map(({ {!isLoading &&
_id, data &&
brand, data?.length > 0 &&
exp_month, data.map(({ _id, brand, exp_month, exp_year, funding, last4 }) => (
exp_year, <Tr key={`pmt-method-${_id}`} className="h-10">
funding, <Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td>
last4 <Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td>
}) => ( <Td>{last4}</Td>
<Tr key={`pmt-method-${_id}`} className="h-10"> <Td>{`${exp_month}/${exp_year}`}</Td>
<Td>{brand.charAt(0).toUpperCase() + brand.slice(1)}</Td> <Td>
<Td>{funding.charAt(0).toUpperCase() + funding.slice(1)}</Td> <IconButton
<Td>{last4}</Td> onClick={async () => {
<Td>{`${exp_month}/${exp_year}`}</Td> await handleDeletePmtMethodBtnClick(_id);
<Td> }}
<IconButton size="lg"
onClick={async () => { colorSchema="danger"
await handleDeletePmtMethodBtnClick(_id); variant="plain"
}} ariaLabel="update"
size="lg" >
colorSchema="danger" <FontAwesomeIcon icon={faXmark} />
variant="plain" </IconButton>
ariaLabel="update" </Td>
> </Tr>
<FontAwesomeIcon icon={faXmark} /> ))}
</IconButton> {isLoading && <TableSkeleton columns={5} innerKey="pmt-methods" />}
</Td> {!isLoading && data && data?.length === 0 && (
</Tr> <Tr>
))} <Td colSpan={5}>
{isLoading && <TableSkeleton columns={5} key="pmt-methods" />} <EmptyState title="No payment methods on file" icon={faCreditCard} />
{!isLoading && data && data?.length === 0 && ( </Td>
<Tr> </Tr>
<Td colSpan={5}> )}
<EmptyState </TBody>
title="No payment methods on file" </Table>
icon={faCreditCard} </TableContainer>
/> );
</Td> };
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,4 +1,4 @@
import { faFileInvoice,faXmark } from "@fortawesome/free-solid-svg-icons"; import { faFileInvoice, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
@@ -11,127 +11,120 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api";
useDeleteOrgTaxId,
useGetOrgTaxIds
} from "@app/hooks/api";
const taxIDTypeLabelMap: { [key: string]: string } = { const taxIDTypeLabelMap: { [key: string]: string } = {
"au_abn": "Australia ABN", au_abn: "Australia ABN",
"au_arn": "Australia ARN", au_arn: "Australia ARN",
"bg_uic": "Bulgaria UIC", bg_uic: "Bulgaria UIC",
"br_cnpj": "Brazil CNPJ", br_cnpj: "Brazil CNPJ",
"br_cpf": "Brazil CPF", br_cpf: "Brazil CPF",
"ca_bn": "Canada BN", ca_bn: "Canada BN",
"ca_gst_hst": "Canada GST/HST", ca_gst_hst: "Canada GST/HST",
"ca_pst_bc": "Canada PST BC", ca_pst_bc: "Canada PST BC",
"ca_pst_mb": "Canada PST MB", ca_pst_mb: "Canada PST MB",
"ca_pst_sk": "Canada PST SK", ca_pst_sk: "Canada PST SK",
"ca_qst": "Canada QST", ca_qst: "Canada QST",
"ch_vat": "Switzerland VAT", ch_vat: "Switzerland VAT",
"cl_tin": "Chile TIN", cl_tin: "Chile TIN",
"eg_tin": "Egypt TIN", eg_tin: "Egypt TIN",
"es_cif": "Spain CIF", es_cif: "Spain CIF",
"eu_oss_vat": "EU OSS VAT", eu_oss_vat: "EU OSS VAT",
"eu_vat": "EU VAT", eu_vat: "EU VAT",
"gb_vat": "GB VAT", gb_vat: "GB VAT",
"ge_vat": "Georgia VAT", ge_vat: "Georgia VAT",
"hk_br": "Hong Kong BR", hk_br: "Hong Kong BR",
"hu_tin": "Hungary TIN", hu_tin: "Hungary TIN",
"id_npwp": "Indonesia NPWP", id_npwp: "Indonesia NPWP",
"il_vat": "Israel VAT", il_vat: "Israel VAT",
"in_gst": "India GST", in_gst: "India GST",
"is_vat": "Iceland VAT", is_vat: "Iceland VAT",
"jp_cn": "Japan CN", jp_cn: "Japan CN",
"jp_rn": "Japan RN", jp_rn: "Japan RN",
"jp_trn": "Japan TRN", jp_trn: "Japan TRN",
"ke_pin": "Kenya PIN", ke_pin: "Kenya PIN",
"kr_brn": "South Korea BRN", kr_brn: "South Korea BRN",
"li_uid": "Liechtenstein UID", li_uid: "Liechtenstein UID",
"mx_rfc": "Mexico RFC", mx_rfc: "Mexico RFC",
"my_frp": "Malaysia FRP", my_frp: "Malaysia FRP",
"my_itn": "Malaysia ITN", my_itn: "Malaysia ITN",
"my_sst": "Malaysia SST", my_sst: "Malaysia SST",
"no_vat": "Norway VAT", no_vat: "Norway VAT",
"nz_gst": "New Zealand GST", nz_gst: "New Zealand GST",
"ph_tin": "Philippines TIN", ph_tin: "Philippines TIN",
"ru_inn": "Russia INN", ru_inn: "Russia INN",
"ru_kpp": "Russia KPP", ru_kpp: "Russia KPP",
"sa_vat": "Saudi Arabia VAT", sa_vat: "Saudi Arabia VAT",
"sg_gst": "Singapore GST", sg_gst: "Singapore GST",
"sg_uen": "Singapore UEN", sg_uen: "Singapore UEN",
"si_tin": "Slovenia TIN", si_tin: "Slovenia TIN",
"th_vat": "Thailand VAT", th_vat: "Thailand VAT",
"tr_tin": "Turkey TIN", tr_tin: "Turkey TIN",
"tw_vat": "Taiwan VAT", tw_vat: "Taiwan VAT",
"ua_vat": "Ukraine VAT", ua_vat: "Ukraine VAT",
"us_ein": "US EIN", us_ein: "US EIN",
"za_vat": "South Africa VAT" za_vat: "South Africa VAT"
}; };
export const TaxIDTable = () => { export const TaxIDTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgTaxIds(currentOrg?._id ?? "");
const deleteOrgTaxId = useDeleteOrgTaxId(); const deleteOrgTaxId = useDeleteOrgTaxId();
const handleDeleteTaxIdBtnClick = async (taxId: string) => { const handleDeleteTaxIdBtnClick = async (taxId: string) => {
if (!currentOrg?._id) return; if (!currentOrg?._id) return;
await deleteOrgTaxId.mutateAsync({ await deleteOrgTaxId.mutateAsync({
organizationId: currentOrg._id, organizationId: currentOrg._id,
taxId taxId
}); });
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Type</Th> <Th className="flex-1">Type</Th>
<Th className="flex-1">Value</Th> <Th className="flex-1">Value</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{!isLoading && data && data?.length > 0 && data.map(({ {!isLoading &&
_id, data &&
type, data?.length > 0 &&
value data.map(({ _id, type, value }) => (
}) => ( <Tr key={`tax-id-${_id}`} className="h-10">
<Tr key={`tax-id-${_id}`} className="h-10"> <Td>{taxIDTypeLabelMap[type]}</Td>
<Td>{taxIDTypeLabelMap[type]}</Td> <Td>{value}</Td>
<Td>{value}</Td> <Td>
<Td> <IconButton
<IconButton onClick={async () => {
onClick={async () => { await handleDeleteTaxIdBtnClick(_id);
await handleDeleteTaxIdBtnClick(_id); }}
}} size="lg"
size="lg" colorSchema="danger"
colorSchema="danger" variant="plain"
variant="plain" ariaLabel="update"
ariaLabel="update" >
> <FontAwesomeIcon icon={faXmark} />
<FontAwesomeIcon icon={faXmark} /> </IconButton>
</IconButton> </Td>
</Td> </Tr>
</Tr> ))}
))} {isLoading && <TableSkeleton columns={3} innerKey="tax-ids" />}
{isLoading && <TableSkeleton columns={3} key="tax-ids" />} {!isLoading && data && data?.length === 0 && (
{!isLoading && data && data?.length === 0 && ( <Tr>
<Tr> <Td colSpan={5}>
<Td colSpan={5}> <EmptyState title="No Tax IDs on file" icon={faFileInvoice} />
<EmptyState </Td>
title="No Tax IDs on file" </Tr>
icon={faFileInvoice} )}
/> </TBody>
</Td> </Table>
</Tr> </TableContainer>
)} );
</TBody> };
</Table>
</TableContainer>
);
}
@@ -14,76 +14,67 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { import { useGetOrgInvoices } from "@app/hooks/api";
useGetOrgInvoices
} from "@app/hooks/api";
export const InvoicesTable = () => { export const InvoicesTable = () => {
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? ""); const { data, isLoading } = useGetOrgInvoices(currentOrg?._id ?? "");
return ( return (
<TableContainer className="mt-8"> <TableContainer className="mt-8">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th className="flex-1">Invoice #</Th> <Th className="flex-1">Invoice #</Th>
<Th className="flex-1">Date</Th> <Th className="flex-1">Date</Th>
<Th className="flex-1">Status</Th> <Th className="flex-1">Status</Th>
<Th className="flex-1">Amount</Th> <Th className="flex-1">Amount</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr>
</THead>
<TBody>
{!isLoading &&
data &&
data?.length > 0 &&
data.map(({ _id, created, paid, number, total, invoice_pdf }) => {
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD"
});
const createdDate = new Date(created * 1000);
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return (
<Tr key={`invoice-${_id}`} className="h-10">
<Td>{number}</Td>
<Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td>
<Td>
<IconButton
onClick={async () => window.open(invoice_pdf)}
size="lg"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Td>
</Tr> </Tr>
</THead> );
<TBody> })}
{!isLoading && data && data?.length > 0 && data.map(({ {isLoading && <TableSkeleton columns={5} innerKey="invoices" />}
_id, {!isLoading && data && data?.length === 0 && (
created, <Tr>
paid, <Td colSpan={5}>
number, <EmptyState title="No invoices on file" icon={faFileInvoice} />
total, </Td>
invoice_pdf </Tr>
}) => { )}
const formattedTotal = (Math.floor(total) / 100).toLocaleString("en-US", { </TBody>
style: "currency", </Table>
currency: "USD", </TableContainer>
}); );
const createdDate = new Date(created * 1000); };
const day: number = createdDate.getDate();
const month: number = createdDate.getMonth() + 1;
const year: number = createdDate.getFullYear();
const formattedDate: string = `${day}/${month}/${year}`;
return (
<Tr key={`invoice-${_id}`} className="h-10">
<Td>{number}</Td>
<Td>{formattedDate}</Td>
<Td>{paid ? "Paid" : "Not Paid"}</Td>
<Td>{formattedTotal}</Td>
<Td>
<IconButton
onClick={async () => window.open(invoice_pdf)}
size="lg"
variant="plain"
ariaLabel="update"
>
<FontAwesomeIcon icon={faDownload} />
</IconButton>
</Td>
</Tr>
);
})}
{isLoading && <TableSkeleton columns={5} key="invoices" />}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={5}>
<EmptyState
title="No invoices on file"
icon={faFileInvoice}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -1,407 +1,412 @@
import { useState } from "react"; import { useState } from "react";
import { Controller,useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import { faKey, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
faKey,
faMagnifyingGlass,
faPlus,
faTrash} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup"; import * as yup from "yup";
import {
decryptAssymmetric,
encryptAssymmetric,
verifyPrivateKey} from "@app/components/utilities/cryptography/crypto";
import { import {
Button, decryptAssymmetric,
Checkbox, encryptAssymmetric,
DeleteActionModal, verifyPrivateKey
EmptyState, } from "@app/components/utilities/cryptography/crypto";
FormControl, import {
IconButton, Button,
Input, Checkbox,
Modal, DeleteActionModal,
ModalClose, EmptyState,
ModalContent, FormControl,
Select, IconButton,
SelectItem, Input,
Table, Modal,
TableContainer, ModalClose,
TableSkeleton, ModalContent,
TBody, Select,
Td, SelectItem,
Th, Table,
THead, TableContainer,
Tr} from "@app/components/v2"; TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useCreateServiceAccountProjectLevelPermission, useCreateServiceAccountProjectLevelPermission,
useDeleteServiceAccountProjectLevelPermission, useDeleteServiceAccountProjectLevelPermission,
useGetServiceAccountById, useGetServiceAccountById,
useGetServiceAccountProjectLevelPermissions, useGetServiceAccountProjectLevelPermissions,
useGetUserWorkspaces useGetUserWorkspaces
} from "@app/hooks/api"; } from "@app/hooks/api";
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey"; import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
const createProjectLevelPermissionSchema = yup.object({ const createProjectLevelPermissionSchema = yup.object({
privateKey: yup.string().required().label("Private Key"), privateKey: yup.string().required().label("Private Key"),
workspace: yup.string().required().label("Workspace"), workspace: yup.string().required().label("Workspace"),
environment: yup.string().required().label("Environment"), environment: yup.string().required().label("Environment"),
permissions: yup.object().shape({ permissions: yup
read: yup.boolean().required(), .object()
write: yup.boolean().required() .shape({
}).defined().required() read: yup.boolean().required(),
write: yup.boolean().required()
})
.defined()
.required()
}); });
type CreateProjectLevelPermissionForm = yup.InferType<typeof createProjectLevelPermissionSchema>; type CreateProjectLevelPermissionForm = yup.InferType<typeof createProjectLevelPermissionSchema>;
type Props = { type Props = {
serviceAccountId: string; serviceAccountId: string;
} };
export const SAProjectLevelPermissionsTable = ({ export const SAProjectLevelPermissionsTable = ({ serviceAccountId }: Props): JSX.Element => {
serviceAccountId const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId);
}: Props): JSX.Element => { const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
const { data: serviceAccount } = useGetServiceAccountById(serviceAccountId); const [searchPermissions, setSearchPermissions] = useState("");
const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces();
const [searchPermissions, setSearchPermissions] = useState("");
const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); const { data: serviceAccountWorkspacePermissions, isLoading: isPermissionsLoading } =
useGetServiceAccountProjectLevelPermissions(serviceAccountId);
const createServiceAccountProjectLevelPermission = useCreateServiceAccountProjectLevelPermission();
const deleteServiceAccountProjectLevelPermission = useDeleteServiceAccountProjectLevelPermission();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const createServiceAccountProjectLevelPermission =
"addProjectLevelPermission", useCreateServiceAccountProjectLevelPermission();
"removeProjectLevelPermission", const deleteServiceAccountProjectLevelPermission =
] as const); useDeleteServiceAccountProjectLevelPermission();
const [, setSelectedWorkspace] = useState<undefined | string>(undefined);
const { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
control, "addProjectLevelPermission",
handleSubmit, "removeProjectLevelPermission"
reset, ] as const);
formState: { isSubmitting }
} = useForm<CreateProjectLevelPermissionForm>({ resolver: yupResolver(createProjectLevelPermissionSchema) })
const onAddProjectLevelPermission = async ({ const [, setSelectedWorkspace] = useState<undefined | string>(undefined);
privateKey,
workspace,
environment,
permissions: { read, write }
}: CreateProjectLevelPermissionForm) => {
// TODO: clean up / modularize this function
if (!serviceAccount) return;
const { latestKey } = await getLatestFileKey({
workspaceId: workspace
});
verifyPrivateKey({ const {
privateKey, control,
publicKey: serviceAccount.publicKey handleSubmit,
}); reset,
formState: { isSubmitting }
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; } = useForm<CreateProjectLevelPermissionForm>({
resolver: yupResolver(createProjectLevelPermissionSchema)
});
const key = decryptAssymmetric({ const onAddProjectLevelPermission = async ({
ciphertext: latestKey.encryptedKey, privateKey,
nonce: latestKey.nonce, workspace,
publicKey: latestKey.sender.publicKey, environment,
privateKey: PRIVATE_KEY permissions: { read, write }
}); }: CreateProjectLevelPermissionForm) => {
// TODO: clean up / modularize this function
const { ciphertext, nonce } = encryptAssymmetric({
plaintext: key,
publicKey: serviceAccount.publicKey,
privateKey
});
await createServiceAccountProjectLevelPermission.mutateAsync({
serviceAccountId,
workspaceId: workspace,
environment,
read,
write,
encryptedKey: ciphertext,
nonce
});
handlePopUpClose("addProjectLevelPermission");
}
const onRemoveProjectLevelPermission = async () => {
const serviceAccountWorkspacePermissionId = (popUp?.removeProjectLevelPermission?.data as { _id: string })?._id;
await deleteServiceAccountProjectLevelPermission.mutateAsync({
serviceAccountId,
serviceAccountWorkspacePermissionId
});
handlePopUpClose("removeProjectLevelPermission");
}
return ( if (!serviceAccount) return;
<div className="w-full bg-white/5 p-6">
<p className="mb-4 text-xl font-semibold">Project-Level Permissions</p> const { latestKey } = await getLatestFileKey({
<div className="mb-4 flex"> workspaceId: workspace
<div className="mr-4 flex-1"> });
<Input
value={searchPermissions} verifyPrivateKey({
onChange={(e) => setSearchPermissions(e.target.value)} privateKey,
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} publicKey: serviceAccount.publicKey
placeholder="Search service account project-level permissions..." });
/>
</div> const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} const key = decryptAssymmetric({
onClick={() => { ciphertext: latestKey.encryptedKey,
handlePopUpOpen("addProjectLevelPermission") nonce: latestKey.nonce,
reset(); publicKey: latestKey.sender.publicKey,
}} privateKey: PRIVATE_KEY
> });
Add Permission
</Button> const { ciphertext, nonce } = encryptAssymmetric({
</div> plaintext: key,
<TableContainer> publicKey: serviceAccount.publicKey,
<Table> privateKey
<THead> });
<Tr>
<Th>Project</Th> await createServiceAccountProjectLevelPermission.mutateAsync({
<Th>Environment</Th> serviceAccountId,
<Th>Read</Th> workspaceId: workspace,
<Th>Write</Th> environment,
<Th aria-label="actions" /> read,
</Tr> write,
</THead> encryptedKey: ciphertext,
<TBody> nonce
{isPermissionsLoading && <TableSkeleton columns={6} key="service-account-project-level-permissions" />} });
{!isPermissionsLoading && serviceAccountWorkspacePermissions && ( handlePopUpClose("addProjectLevelPermission");
serviceAccountWorkspacePermissions.map(({ };
_id,
workspace, const onRemoveProjectLevelPermission = async () => {
environment, const serviceAccountWorkspacePermissionId = (
read, popUp?.removeProjectLevelPermission?.data as { _id: string }
write )?._id;
}) => { await deleteServiceAccountProjectLevelPermission.mutateAsync({
const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name; serviceAccountId,
return ( serviceAccountWorkspacePermissionId
<Tr key={`service-account-project-level-permission-${_id}`} className="w-full"> });
<Td>{workspace.name}</Td> handlePopUpClose("removeProjectLevelPermission");
<Td>{environmentName}</Td> };
<Td>
<Checkbox return (
id="isReadPermissionEnabled" <div className="w-full bg-white/5 p-6">
isChecked={read} <p className="mb-4 text-xl font-semibold">Project-Level Permissions</p>
isDisabled <div className="mb-4 flex">
>{/**/}</Checkbox> <div className="mr-4 flex-1">
</Td> <Input
<Td> value={searchPermissions}
<Checkbox onChange={(e) => setSearchPermissions(e.target.value)}
id="isWritePermissionEnabled" leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
isChecked={write} placeholder="Search service account project-level permissions..."
isDisabled />
>{/**/}</Checkbox>
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeProjectLevelPermission", { _id })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
})
)}
{!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && (
<Tr>
<Td colSpan={7} className="py-6 text-center text-bunker-400">
<EmptyState title="No permissions found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addProjectLevelPermission?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addProjectLevelPermission", isOpen);
}}
>
<ModalContent
title="Add a Project-Level Permission"
subTitle="The service account will be granted scoped access to the specified project and environment"
>
<form onSubmit={handleSubmit(onAddProjectLevelPermission)}>
{!isUserWorkspacesLoading && userWorkspaces && (
<>
<Controller
control={control}
defaultValue=""
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Private Key"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="workspace"
defaultValue={String(userWorkspaces?.[0]?._id)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Project"
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
onChange(e);
setSelectedWorkspace(e);
}}
className="w-full border border-mine-shaft-500"
>
{userWorkspaces && userWorkspaces.length > 0 ? (
userWorkspaces.map((userWorkspace) => {
return (
<SelectItem value={userWorkspace._id} key={`project-${userWorkspace._id}`}>
{userWorkspace.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={String(userWorkspaces?.[0]?.environments?.[0]?.slug)}
render={({ field: { onChange, ...field } }) => {
/* eslint-disable-next-line no-underscore-dangle */
const environments = userWorkspaces?.find((userWorkspace) => userWorkspace._id === control?._formValues?.workspace)?.environments ?? [];
return (
<FormControl
label="Environment"
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full border border-mine-shaft-500"
>
{environments.length > 0 ? (
environments.map((environment) => {
return (
<SelectItem value={environment.slug} key={`environment-${environment.slug}`}>
{environment.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No environments found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
</>
)}
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error }}) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeProjectLevelPermission.isOpen}
deleteKey="remove"
title="Do you want to remove this permission from the service account?"
onChange={(isOpen) => handlePopUpToggle("removeProjectLevelPermission", isOpen)}
onDeleteApproved={onRemoveProjectLevelPermission}
/>
</div> </div>
); <Button
} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
handlePopUpOpen("addProjectLevelPermission");
reset();
}}
>
Add Permission
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Project</Th>
<Th>Environment</Th>
<Th>Read</Th>
<Th>Write</Th>
<Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isPermissionsLoading && (
<TableSkeleton columns={6} innerKey="service-account-project-level-permissions" />
)}
{!isPermissionsLoading &&
serviceAccountWorkspacePermissions &&
serviceAccountWorkspacePermissions.map(
({ _id, workspace, environment, read, write }) => {
const environmentName = workspace.environments.find(
(env) => env.slug === environment
)?.name;
return (
<Tr key={`service-account-project-level-permission-${_id}`} className="w-full">
<Td>{workspace.name}</Td>
<Td>{environmentName}</Td>
<Td>
<Checkbox id="isReadPermissionEnabled" isChecked={read} isDisabled>
{/**/}
</Checkbox>
</Td>
<Td>
<Checkbox id="isWritePermissionEnabled" isChecked={write} isDisabled>
{/**/}
</Checkbox>
</Td>
<Td>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeProjectLevelPermission", { _id })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
);
}
)}
{!isPermissionsLoading && serviceAccountWorkspacePermissions?.length === 0 && (
<Tr>
<Td colSpan={7} className="py-6 text-center text-bunker-400">
<EmptyState title="No permissions found" icon={faKey} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp?.addProjectLevelPermission?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addProjectLevelPermission", isOpen);
}}
>
<ModalContent
title="Add a Project-Level Permission"
subTitle="The service account will be granted scoped access to the specified project and environment"
>
<form onSubmit={handleSubmit(onAddProjectLevelPermission)}>
{!isUserWorkspacesLoading && userWorkspaces && (
<>
<Controller
control={control}
defaultValue=""
name="privateKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Private Key"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="workspace"
defaultValue={String(userWorkspaces?.[0]?._id)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Project" errorText={error?.message}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
onChange(e);
setSelectedWorkspace(e);
}}
className="border-mine-shaft-500 w-full border"
>
{userWorkspaces && userWorkspaces.length > 0 ? (
userWorkspaces.map((userWorkspace) => {
return (
<SelectItem
value={userWorkspace._id}
key={`project-${userWorkspace._id}`}
>
{userWorkspace.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No projects found
</SelectItem>
)}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="environment"
defaultValue={String(userWorkspaces?.[0]?.environments?.[0]?.slug)}
render={({ field: { onChange, ...field } }) => {
const environments =
userWorkspaces?.find(
/* eslint-disable-next-line no-underscore-dangle */
(userWorkspace) => userWorkspace._id === control?._formValues?.workspace
)?.environments ?? [];
return (
<FormControl label="Environment" className="mt-4">
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="border-mine-shaft-500 w-full border"
>
{environments.length > 0 ? (
environments.map((environment) => {
return (
<SelectItem
value={environment.slug}
key={`environment-${environment.slug}`}
>
{environment.name}
</SelectItem>
);
})
) : (
<SelectItem value="none" key="target-app-none">
No environments found
</SelectItem>
)}
</Select>
</FormControl>
);
}}
/>
</>
)}
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write",
value: "write"
}
];
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={value[optionValue]}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeProjectLevelPermission.isOpen}
deleteKey="remove"
title="Do you want to remove this permission from the service account?"
onChange={(isOpen) => handlePopUpToggle("removeProjectLevelPermission", isOpen)}
onDeleteApproved={onRemoveProjectLevelPermission}
/>
</div>
);
};
@@ -1,9 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { import { faContactBook, faMagnifyingGlass, faTrash } from "@fortawesome/free-solid-svg-icons";
faContactBook,
faMagnifyingGlass,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -23,13 +19,11 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import { useDeleteIncidentContact, useGetOrgIncidentContact } from "@app/hooks/api";
useDeleteIncidentContact,
useGetOrgIncidentContact} from "@app/hooks/api";
export const OrgIncidentContactsTable = () => { export const OrgIncidentContactsTable = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? ""); const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? "");
const [searchContact, setSearchContact] = useState(""); const [searchContact, setSearchContact] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -40,71 +34,71 @@ export const OrgIncidentContactsTable = () => {
const onRemoveIncidentContact = async () => { const onRemoveIncidentContact = async () => {
try { try {
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
if (!currentOrg?._id) return;
await mutateAsync({
orgId: currentOrg._id,
email: incidentContactEmail
});
createNotification({ if (!currentOrg?._id) return;
text: "Successfully removed incident contact", await mutateAsync({
type: "success" orgId: currentOrg._id,
}); email: incidentContactEmail
});
handlePopUpClose("removeContact");
createNotification({
text: "Successfully removed incident contact",
type: "success"
});
handlePopUpClose("removeContact");
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to remove incident contact", text: "Failed to remove incident contact",
type: "error" type: "error"
}); });
} }
}; };
const filteredContacts = contacts ? contacts.filter(({ email }) => const filteredContacts = contacts
email.toLocaleLowerCase().includes(searchContact) ? contacts.filter(({ email }) => email.toLocaleLowerCase().includes(searchContact))
) : []; : [];
return ( return (
<div> <div>
<Input <Input
value={searchContact} value={searchContact}
onChange={(e) => setSearchContact(e.target.value)} onChange={(e) => setSearchContact(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search incident contact by email..." placeholder="Search incident contact by email..."
/> />
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Email</Th> <Th>Email</Th>
<Th aria-label="actions" /> <Th aria-label="actions" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={2} innerKey="incident-contact" />}
{filteredContacts?.map(({ email }) => (
<Tr key={email}>
<Td className="w-full">{email}</Td>
<Td className="mr-4">
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr> </Tr>
</THead> ))}
<TBody> </TBody>
{isLoading && <TableSkeleton columns={2} key="incident-contact" />} </Table>
{filteredContacts?.map(({ email }) => ( {filteredContacts?.length === 0 && !isLoading && (
<Tr key={email}> <EmptyState title="No incident contacts found" icon={faContactBook} />
<Td className="w-full">{email}</Td> )}
<Td className="mr-4"> </TableContainer>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() => handlePopUpOpen("removeContact", { email })}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Td>
</Tr>
))}
</TBody>
</Table>
{filteredContacts?.length === 0 && !isLoading && (
<EmptyState title="No incident contacts found" icon={faContactBook} />
)}
</TableContainer>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeContact.isOpen} isOpen={popUp.removeContact.isOpen}
deleteKey="remove" deleteKey="remove"
@@ -1,17 +1,14 @@
import { useEffect, useMemo,useState } from "react"; import { useEffect, useMemo, useState } from "react";
import {
// Controller,
// useForm
} from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { import {
faCheck, faCheck,
faCopy, faCopy,
faMagnifyingGlass, faMagnifyingGlass,
faPencil, faPencil,
faPlus, faPlus,
faServer, faServer,
faTrash} from "@fortawesome/free-solid-svg-icons"; faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
// import { yupResolver } from "@hookform/resolvers/yup"; // import { yupResolver } from "@hookform/resolvers/yup";
@@ -21,13 +18,13 @@ import {
Button, Button,
DeleteActionModal, DeleteActionModal,
EmptyState, EmptyState,
// FormControl, // FormControl,
IconButton, IconButton,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
// Select, // Select,
// SelectItem, // SelectItem,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -40,11 +37,15 @@ import {
import { useOrganization, useWorkspace } from "@app/context"; import { useOrganization, useWorkspace } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { import {
// useCreateServiceAccount, // useCreateServiceAccount,
useDeleteServiceAccount, useDeleteServiceAccount,
useGetServiceAccounts useGetServiceAccounts
} from "@app/hooks/api"; } from "@app/hooks/api";
import // Controller,
// useForm
"react-hook-form";
// const serviceAccountExpiration = [ // const serviceAccountExpiration = [
// { label: "1 Day", value: 86400 }, // { label: "1 Day", value: 86400 },
// { label: "7 Days", value: 604800 }, // { label: "7 Days", value: 604800 },
@@ -62,314 +63,312 @@ import {
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>; // type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
export const OrgServiceAccountsTable = () => { export const OrgServiceAccountsTable = () => {
const router = useRouter(); const router = useRouter();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0);
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
const [accessKey] = useState("");
const [publicKey] = useState("");
const [privateKey] = useState("");
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
"removeServiceAccount",
] as const);
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); const orgId = currentOrg?._id || "";
const [step, setStep] = useState(0);
// const createServiceAccount = useCreateServiceAccount(); const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
const removeServiceAccount = useDeleteServiceAccount(); const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
useEffect(() => { const [accessKey] = useState("");
let timer: NodeJS.Timeout; const [publicKey] = useState("");
if (isAccessKeyCopied) { const [privateKey] = useState("");
timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
} const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addServiceAccount",
"removeServiceAccount"
] as const);
if (isPublicKeyCopied) { const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } =
timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000); useGetServiceAccounts(orgId);
}
if (isPrivateKeyCopied) { // const createServiceAccount = useCreateServiceAccount();
timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); const removeServiceAccount = useDeleteServiceAccount();
}
return () => clearTimeout(timer); useEffect(() => {
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]); let timer: NodeJS.Timeout;
if (isAccessKeyCopied) {
// const { timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000);
// control,
// handleSubmit,
// reset,
// formState: { isSubmitting }
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
// if (!currentOrg?._id) return;
// const keyPair = generateKeyPair();
// setPublicKey(keyPair.publicKey);
// setPrivateKey(keyPair.privateKey);
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
// name,
// organizationId: currentOrg?._id,
// publicKey: keyPair.publicKey,
// expiresIn: Number(expiresIn)
// });
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
// setStep(1);
// reset();
// }
const onRemoveServiceAccount = async () => {
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
await removeServiceAccount.mutateAsync(serviceAccountId);
handlePopUpClose("removeServiceAccount");
} }
const filteredServiceAccounts = useMemo( if (isPublicKeyCopied) {
() => timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000);
serviceAccounts.filter(
({ name }) =>
name.toLowerCase().includes(searchServiceAccountFilter)
),
[serviceAccounts, searchServiceAccountFilter]
);
const renderStep = (stepToRender: number) => {
switch (stepToRender) {
case 0:
return (
<div>
We are currently revising the service account mechanism. In the meantime,
please use service tokens or API key to fetch secrets via API request.
</div>
// <form onSubmit={handleSubmit(onAddServiceAccount)}>
// <Controller
// control={control}
// defaultValue=""
// name="name"
// render={({ field, fieldState: { error } }) => (
// <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
// <Input {...field} />
// </FormControl>
// )}
// />
// <Controller
// control={control}
// name="expiresIn"
// defaultValue={String(serviceAccountExpiration?.[0]?.value)}
// render={({ field: { onChange, ...field }, fieldState: { error } }) => {
// return (
// <FormControl
// label="Expiration"
// errorText={error?.message}
// isError={Boolean(error)}
// >
// <Select
// defaultValue={field.value}
// {...field}
// onValueChange={(e) => onChange(e)}
// className="w-full"
// >
// {serviceAccountExpiration.map(({ label, value }) => (
// <SelectItem value={String(value)} key={label}>
// {label}
// </SelectItem>
// ))}
// </Select>
// </FormControl>
// );
// }}
// />
// <div className="mt-8 flex items-center">
// <Button
// className="mr-4"
// size="sm"
// type="submit"
// isLoading={isSubmitting}
// isDisabled={isSubmitting}
// >
// Create Service Account
// </Button>
// <Button
// colorSchema="secondary"
// variant="plain"
// onClick={() => handlePopUpClose("addServiceAccount")}
// >
// Cancel
// </Button>
// </div>
// </form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Public Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{publicKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(publicKey);
setIsPublicKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md p-2 text-base text-gray-400 bg-white/[0.07]">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />
}
} }
return ( if (isPrivateKeyCopied) {
<div className="w-full"> timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000);
<div className="flex justify-between mb-4"> }
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button return () => clearTimeout(timer);
colorSchema="secondary" }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { // const {
setStep(0); // control,
// reset(); // handleSubmit,
handlePopUpOpen("addServiceAccount"); // reset,
}} // formState: { isSubmitting }
> // } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
Add Service Account
</Button> // const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
</div> // if (!currentOrg?._id) return;
<Input
value={searchServiceAccountFilter} // const keyPair = generateKeyPair();
onChange={(e) => setSearchServiceAccountFilter(e.target.value)} // setPublicKey(keyPair.publicKey);
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} // setPrivateKey(keyPair.privateKey);
placeholder="Search service accounts..."
/> // const serviceAccountDetails = await createServiceAccount.mutateAsync({
<TableContainer className="mt-4"> // name,
<Table> // organizationId: currentOrg?._id,
<THead> // publicKey: keyPair.publicKey,
<Th>Name</Th> // expiresIn: Number(expiresIn)
<Th className="w-full">Valid Until</Th> // });
<Th aria-label="actions" />
</THead> // setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
<TBody>
{isServiceAccountsLoading && <TableSkeleton columns={5} key="org-service-accounts" />} // setStep(1);
{!isServiceAccountsLoading && ( // reset();
filteredServiceAccounts.map(({ // }
name,
expiresAt, const onRemoveServiceAccount = async () => {
_id: serviceAccountId const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
}) => { await removeServiceAccount.mutateAsync(serviceAccountId);
return ( handlePopUpClose("removeServiceAccount");
<Tr key={`org-service-account-${serviceAccountId}`}> };
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td> const filteredServiceAccounts = useMemo(
<Td> () =>
<div className="flex"> serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)),
<IconButton [serviceAccounts, searchServiceAccountFilter]
ariaLabel="edit" );
colorSchema="secondary"
onClick={() => { const renderStep = (stepToRender: number) => {
if (currentWorkspace?._id) { switch (stepToRender) {
router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); case 0:
} return (
}} <div>
className="mr-2" We are currently revising the service account mechanism. In the meantime, please use
> service tokens or API key to fetch secrets via API request.
<FontAwesomeIcon icon={faPencil} /> </div>
</IconButton> // <form onSubmit={handleSubmit(onAddServiceAccount)}>
<IconButton // <Controller
ariaLabel="delete" // control={control}
colorSchema="danger" // defaultValue=""
onClick={() => handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })} // name="name"
> // render={({ field, fieldState: { error } }) => (
<FontAwesomeIcon icon={faTrash} /> // <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
</IconButton> // <Input {...field} />
</div> // </FormControl>
</Td> // )}
</Tr> // />
); // <Controller
}) // control={control}
)} // name="expiresIn"
</TBody> // defaultValue={String(serviceAccountExpiration?.[0]?.value)}
</Table> // render={({ field: { onChange, ...field }, fieldState: { error } }) => {
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( // return (
<EmptyState title="No service accounts found" icon={faServer} /> // <FormControl
)} // label="Expiration"
</TableContainer> // errorText={error?.message}
<Modal // isError={Boolean(error)}
isOpen={popUp?.addServiceAccount?.isOpen} // >
onOpenChange={(isOpen) => { // <Select
handlePopUpToggle("addServiceAccount", isOpen); // defaultValue={field.value}
// reset(); // {...field}
// onValueChange={(e) => onChange(e)}
// className="w-full"
// >
// {serviceAccountExpiration.map(({ label, value }) => (
// <SelectItem value={String(value)} key={label}>
// {label}
// </SelectItem>
// ))}
// </Select>
// </FormControl>
// );
// }}
// />
// <div className="mt-8 flex items-center">
// <Button
// className="mr-4"
// size="sm"
// type="submit"
// isLoading={isSubmitting}
// isDisabled={isSubmitting}
// >
// Create Service Account
// </Button>
// <Button
// colorSchema="secondary"
// variant="plain"
// onClick={() => handlePopUpClose("addServiceAccount")}
// >
// Cancel
// </Button>
// </div>
// </form>
);
case 1:
return (
<>
<p>Access Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{accessKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(accessKey);
setIsAccessKeyCopied.on();
}} }}
> >
<ModalContent <FontAwesomeIcon icon={isAccessKeyCopied ? faCheck : faCopy} />
title="Add Service Account" <span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
subTitle="A service account represents a machine identity such as a VM or application client." Copy
> </span>
{renderStep(step)} </IconButton>
</ModalContent> </div>
</Modal> <p className="mt-4">Public Key</p>
<DeleteActionModal <div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
isOpen={popUp.removeServiceAccount.isOpen} <p className="mr-4 break-all">{publicKey}</p>
deleteKey="remove" <IconButton
title="Do you want to remove this service account from the org?" ariaLabel="copy icon"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)} colorSchema="secondary"
onDeleteApproved={onRemoveServiceAccount} className="group relative"
/> onClick={() => {
</div> navigator.clipboard.writeText(publicKey);
); setIsPublicKeyCopied.on();
} }}
>
<FontAwesomeIcon icon={isPublicKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
<p className="mt-4">Private Key</p>
<div className="flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{privateKey}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(privateKey);
setIsPrivateKeyCopied.on();
}}
>
<FontAwesomeIcon icon={isPrivateKeyCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
</div>
</>
);
default:
return <div />;
}
};
return (
<div className="w-full">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
<Button
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
setStep(0);
// reset();
handlePopUpOpen("addServiceAccount");
}}
>
Add Service Account
</Button>
</div>
<Input
value={searchServiceAccountFilter}
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service accounts..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Th>Name</Th>
<Th className="w-full">Valid Until</Th>
<Th aria-label="actions" />
</THead>
<TBody>
{isServiceAccountsLoading && (
<TableSkeleton columns={5} innerKey="org-service-accounts" />
)}
{!isServiceAccountsLoading &&
filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => {
return (
<Tr key={`org-service-account-${serviceAccountId}`}>
<Td>{name}</Td>
<Td>{new Date(expiresAt).toUTCString()}</Td>
<Td>
<div className="flex">
<IconButton
ariaLabel="edit"
colorSchema="secondary"
onClick={() => {
if (currentWorkspace?._id) {
router.push(
`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`
);
}
}}
className="mr-2"
>
<FontAwesomeIcon icon={faPencil} />
</IconButton>
<IconButton
ariaLabel="delete"
colorSchema="danger"
onClick={() =>
handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId })
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && (
<EmptyState title="No service accounts found" icon={faServer} />
)}
</TableContainer>
<Modal
isOpen={popUp?.addServiceAccount?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("addServiceAccount", isOpen);
// reset();
}}
>
<ModalContent
title="Add Service Account"
subTitle="A service account represents a machine identity such as a VM or application client."
>
{renderStep(step)}
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeServiceAccount.isOpen}
deleteKey="remove"
title="Do you want to remove this service account from the org?"
onChange={(isOpen) => handlePopUpToggle("removeServiceAccount", isOpen)}
onDeleteApproved={onRemoveServiceAccount}
/>
</div>
);
};
@@ -1,4 +1,4 @@
import { faKey,faXmark } from "@fortawesome/free-solid-svg-icons"; import { faKey, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -14,99 +14,91 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { import { useDeleteAPIKey, useGetMyAPIKeys } from "@app/hooks/api";
useDeleteAPIKey,
useGetMyAPIKeys} from "@app/hooks/api";
export const APIKeyTable = () => { export const APIKeyTable = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { data, isLoading } = useGetMyAPIKeys(); const { data, isLoading } = useGetMyAPIKeys();
const { mutateAsync } = useDeleteAPIKey(); const { mutateAsync } = useDeleteAPIKey();
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => { const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
try { try {
await mutateAsync(apiKeyDataId); await mutateAsync(apiKeyDataId);
createNotification({ createNotification({
text: "Successfully deleted API key", text: "Successfully deleted API key",
type: "success" type: "success"
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ createNotification({
text: "Failed to delete API key", text: "Failed to delete API key",
type: "error" type: "error"
}); });
}
} }
};
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat); const formatDate = (dateToFormat: string) => {
const year = date.getFullYear(); const date = new Date(dateToFormat);
const month = date.getMonth() + 1; const year = date.getFullYear();
const day = date.getDate(); const month = date.getMonth() + 1;
const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`;
const formattedDate = `${day}/${month}/${year}`;
return formattedDate;
} return formattedDate;
};
return (
<div> return (
<TableContainer className=""> <div>
<Table> <TableContainer className="">
<THead> <Table>
<Tr> <THead>
<Th className="flex-1">Name</Th> <Tr>
<Th className="flex-1">Last active</Th> <Th className="flex-1">Name</Th>
<Th className="flex-1">Created</Th> <Th className="flex-1">Last active</Th>
<Th className="flex-1">Expiration</Th> <Th className="flex-1">Created</Th>
<Th className="w-5" /> <Th className="flex-1">Expiration</Th>
</Tr> <Th className="w-5" />
</THead> </Tr>
<TBody> </THead>
{isLoading && <TableSkeleton columns={4} key="api-keys" />} <TBody>
{!isLoading && data && data.length > 0 && data.map(({ {isLoading && <TableSkeleton columns={4} innerKey="api-keys" />}
_id, {!isLoading &&
name, data &&
createdAt, data.length > 0 &&
expiresAt, data.map(({ _id, name, createdAt, expiresAt, lastUsed }) => {
lastUsed return (
}) => { <Tr className="h-10" key={`api-key-${_id}`}>
return ( <Td>{name}</Td>
<Tr className="h-10" key={`api-key-${_id}`}> <Td>{formatDate(lastUsed)}</Td>
<Td>{name}</Td> <Td>{formatDate(createdAt)}</Td>
<Td>{formatDate(lastUsed)}</Td> <Td>{formatDate(expiresAt)}</Td>
<Td>{formatDate(createdAt)}</Td> <Td>
<Td>{formatDate(expiresAt)}</Td> <IconButton
<Td> onClick={async () => {
<IconButton await handleDeleteAPIKeyDataClick(_id);
onClick={async () => { }}
await handleDeleteAPIKeyDataClick(_id); size="lg"
}} colorSchema="danger"
size="lg" variant="plain"
colorSchema="danger" ariaLabel="update"
variant="plain" >
ariaLabel="update" <FontAwesomeIcon icon={faXmark} />
> </IconButton>
<FontAwesomeIcon icon={faXmark} /> </Td>
</IconButton> </Tr>
</Td> );
</Tr> })}
); {!isLoading && data && data?.length === 0 && (
})} <Tr>
{!isLoading && data && data?.length === 0 && ( <Td colSpan={5}>
<Tr> <EmptyState title="No API Keys on file" icon={faKey} />
<Td colSpan={5}> </Td>
<EmptyState </Tr>
title="No API Keys on file" )}
icon={faKey} </TBody>
/> </Table>
</Td> </TableContainer>
</Tr> </div>
)} );
</TBody> };
</Table>
</TableContainer>
</div>
);
}
@@ -14,61 +14,54 @@ import {
import { useGetMySessions } from "@app/hooks/api"; import { useGetMySessions } from "@app/hooks/api";
export const SessionsTable = () => { export const SessionsTable = () => {
const { data, isLoading } = useGetMySessions(); const { data, isLoading } = useGetMySessions();
const formatDate = (dateToFormat: string) => { const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat); const date = new Date(dateToFormat);
const year = date.getFullYear(); const year = date.getFullYear();
const month = date.getMonth() + 1; const month = date.getMonth() + 1;
const day = date.getDate(); const day = date.getDate();
const formattedDate = `${day}/${month}/${year}`; const formattedDate = `${day}/${month}/${year}`;
return formattedDate; return formattedDate;
} };
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Created</Th> <Th>Created</Th>
<Th>Last active</Th> <Th>Last active</Th>
<Th>IP address</Th> <Th>IP address</Th>
<Th>Device</Th> <Th>Device</Th>
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} key="sesssions" />} {isLoading && <TableSkeleton columns={4} innerKey="sesssions" />}
{!isLoading && data && data.length > 0 && data.map(({ {!isLoading &&
_id, data &&
createdAt, data.length > 0 &&
lastUsed, data.map(({ _id, createdAt, lastUsed, ip, userAgent }) => {
ip, return (
userAgent <Tr className="h-10" key={`session-${_id}`}>
}) => { <Td>{formatDate(createdAt)}</Td>
return ( <Td>{formatDate(lastUsed)}</Td>
<Tr className="h-10" key={`session-${_id}`}> <Td>{ip}</Td>
<Td>{formatDate(createdAt)}</Td> <Td>{userAgent}</Td>
<Td>{formatDate(lastUsed)}</Td> </Tr>
<Td>{ip}</Td> );
<Td>{userAgent}</Td> })}
</Tr> {!isLoading && data && data?.length === 0 && (
); <Tr>
})} <Td colSpan={4}>
{!isLoading && data && data?.length === 0 && ( <EmptyState title="No sessions on file" icon={faServer} />
<Tr> </Td>
<Td colSpan={4}> </Tr>
<EmptyState )}
title="No sessions on file" </TBody>
icon={faServer} </Table>
/> </TableContainer>
</Td> );
</Tr> };
)}
</TBody>
</Table>
</TableContainer>
);
}
@@ -11,79 +11,79 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tr, Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>, popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "upgradePlan"]>,
{ {
name, name,
slug slug
}: { }: {
name: string; name: string;
slug: string; slug: string;
} }
) => void; ) => void;
}; };
export const EnvironmentTable = ({ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => {
handlePopUpOpen const { currentWorkspace, isLoading } = useWorkspace();
}: Props) => { return (
const { currentWorkspace, isLoading } = useWorkspace(); <TableContainer>
return ( <Table>
<TableContainer> <THead>
<Table> <Tr>
<THead> <Th>Name</Th>
<Tr> <Th>Slug</Th>
<Th>Name</Th> <Th aria-label="button" />
<Th>Slug</Th> </Tr>
<Th aria-label="button" /> </THead>
</Tr> <TBody>
</THead> {isLoading && <TableSkeleton columns={3} innerKey="project-envs" />}
<TBody> {!isLoading &&
{isLoading && <TableSkeleton columns={3} key="project-envs" />} currentWorkspace &&
{!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => ( currentWorkspace.environments.map(({ name, slug }) => (
<Tr key={name}> <Tr key={name}>
<Td>{name}</Td> <Td>{name}</Td>
<Td>{slug}</Td> <Td>{slug}</Td>
<Td className="flex items-center justify-end"> <Td className="flex items-center justify-end">
<IconButton <IconButton
className="mr-3 py-2" className="mr-3 py-2"
onClick={() => { onClick={() => {
handlePopUpOpen("updateEnv", { name, slug }); handlePopUpOpen("updateEnv", { name, slug });
}} }}
colorSchema="primary" colorSchema="primary"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
> >
<FontAwesomeIcon icon={faPencil} /> <FontAwesomeIcon icon={faPencil} />
</IconButton> </IconButton>
<IconButton <IconButton
onClick={() => { onClick={() => {
handlePopUpOpen("deleteEnv", { name, slug }); handlePopUpOpen("deleteEnv", { name, slug });
}} }}
size="lg" size="lg"
colorSchema="danger" colorSchema="danger"
variant="plain" variant="plain"
ariaLabel="update" ariaLabel="update"
> >
<FontAwesomeIcon icon={faXmark} /> <FontAwesomeIcon icon={faXmark} />
</IconButton> </IconButton>
</Td> </Td>
</Tr> </Tr>
))} ))}
{!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && ( {!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && (
<Tr> <Tr>
<Td colSpan={3}> <Td colSpan={3}>
<EmptyState title="No environments found" /> <EmptyState title="No environments found" />
</Td> </Td>
</Tr> </Tr>
)} )}
</TBody> </TBody>
</Table> </Table>
</TableContainer> </TableContainer>
); );
} };
@@ -18,65 +18,65 @@ import { useGetWsTags } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>, popUpName: keyof UsePopUpState<["deleteTagConfirmation"]>,
{ {
name, name,
id id
}: { }: {
name: string; name: string;
id: string; id: string;
} }
) => void; ) => void;
}; };
export const SecretTagsTable = ({ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => {
handlePopUpOpen const { currentWorkspace } = useWorkspace();
}: Props) => { const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? "");
const { currentWorkspace }= useWorkspace();
const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? "");
return ( return (
<TableContainer className="mt-4"> <TableContainer className="mt-4">
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Tag</Th> <Th>Tag</Th>
<Th>Slug</Th> <Th>Slug</Th>
<Th aria-label="button" /> <Th aria-label="button" />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={3} key="secret-tags" />} {isLoading && <TableSkeleton columns={3} innerKey="secret-tags" />}
{!isLoading && data && data.map(({ _id, name, slug }) => ( {!isLoading &&
<Tr key={name}> data &&
<Td>{name}</Td> data.map(({ _id, name, slug }) => (
<Td>{slug}</Td> <Tr key={name}>
<Td className="flex items-center justify-end"> <Td>{name}</Td>
<IconButton <Td>{slug}</Td>
onClick={() => <Td className="flex items-center justify-end">
handlePopUpOpen("deleteTagConfirmation", { <IconButton
name, onClick={() =>
id: _id handlePopUpOpen("deleteTagConfirmation", {
}) name,
} id: _id
colorSchema="danger" })
ariaLabel="update" }
> colorSchema="danger"
<FontAwesomeIcon icon={faTrashCan} /> ariaLabel="update"
</IconButton> >
</Td> <FontAwesomeIcon icon={faTrashCan} />
</Tr> </IconButton>
))}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No secret tags found" icon={faTags} />
</Td> </Td>
</Tr> </Tr>
)} ))}
</TBody> {!isLoading && data && data?.length === 0 && (
</Table> <Tr>
</TableContainer> <Td colSpan={3}>
); <EmptyState title="No secret tags found" icon={faTags} />
} </Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};
@@ -48,7 +48,7 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} key="project-service-tokens" />} {isLoading && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!isLoading && {!isLoading &&
data && data &&
data.map((row) => ( data.map((row) => (
@@ -160,7 +160,7 @@ export const WebhooksTab = () => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isWebhooksLoading && <TableSkeleton columns={5} key="webhooks-loading" />} {isWebhooksLoading && <TableSkeleton columns={5} innerKey="webhooks-loading" />}
{!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( {!isWebhooksLoading && webhooks && webhooks?.length === 0 && (
<Tr> <Tr>
<Td colSpan={5}> <Td colSpan={5}>