From 104c752f9abfe8205a8630e39787f4ac161c743b Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 30 Jun 2023 16:38:54 +0700 Subject: [PATCH] Finish preliminary standardization of project settings page --- backend/src/controllers/v2/tagController.ts | 8 +- .../BillingDetailsTab/CompanyNameSection.tsx | 1 + .../ProjectSettingsPage.tsx | 440 +----------------- .../AutoCapitalizationSection.tsx | 48 +- .../CopyProjectIDSection.tsx | 51 -- .../components/CopyProjectIDSection/index.tsx | 1 - .../DeleteProjectSection.tsx | 84 ++++ .../components/DeleteProjectSection/index.tsx | 1 + .../components/E2EESection/E2EESection.tsx | 38 +- .../AddEnvironmentModal.tsx | 132 ++++++ .../EnvironmentSection/EnvironmentSection.tsx | 252 +++------- .../EnvironmentSection/EnvironmentTable.tsx | 89 ++++ .../UpdateEnvironmentModal.tsx | 135 ++++++ .../ProjectGeneralTab/ProjectGeneralTab.tsx | 21 + .../components/ProjectGeneralTab/index.tsx | 1 + .../ProjectIndexSecretsSection.tsx | 72 ++- .../ProjectNameChangeSection.tsx | 84 ++-- .../ProjectServiceTokensTab.tsx | 7 + .../ProjectServiceTokensTab/index.tsx | 1 + .../ProjectTabGroup/ProjectTabGroup.tsx | 39 ++ .../components/ProjectTabGroup/index.tsx | 1 + .../SecretTagsSection/AddSecretTagModal.tsx | 120 +++++ .../SecretTagsSection/SecretTagsSection.tsx | 213 +++------ .../SecretTagsSection/SecretTagsTable.tsx | 82 ++++ .../AddServiceTokenModal.tsx | 343 ++++++++++++++ .../ServiceTokenSection.tsx | 391 ++-------------- .../ServiceTokenSection/ServiceTokenTable.tsx | 88 ++++ .../ProjectSettingsPage/components/index.tsx | 8 +- 28 files changed, 1497 insertions(+), 1254 deletions(-) delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx diff --git a/backend/src/controllers/v2/tagController.ts b/backend/src/controllers/v2/tagController.ts index f714e4348..2d32a8c2f 100644 --- a/backend/src/controllers/v2/tagController.ts +++ b/backend/src/controllers/v2/tagController.ts @@ -16,7 +16,7 @@ export const createWorkspaceTag = async (req: Request, res: Response) => { user: new Types.ObjectId(req.user._id), }; - const createdTag = await new Tag(tagToCreate); + const createdTag = await new Tag(tagToCreate).save(); res.json(createdTag); }; @@ -49,7 +49,11 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => { export const getWorkspaceTags = async (req: Request, res: Response) => { const { workspaceId } = req.params; - const workspaceTags = await Tag.find({ workspace: workspaceId }); + + const workspaceTags = await Tag.find({ + workspace: new Types.ObjectId(workspaceId) + }); + return res.json({ workspaceTags }); diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx index 101f1991e..61204eb3b 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx @@ -88,6 +88,7 @@ export const CompanyNameSection = () => { type="submit" colorSchema="secondary" isLoading={isLoading} + isDisabled={isLoading} > Save diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 1a5da7a59..c3f59784d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -1,443 +1,23 @@ -import crypto from "crypto"; - -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useRouter } from "next/router"; -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import NavHeader from "@app/components/navigation/NavHeader"; -// TODO(akhilmhdh):Refactor this into a better utility module package -import { - decryptAssymmetric, - decryptSymmetric, - encryptSymmetric -} from "@app/components/utilities/cryptography/crypto"; -import { Button, FormControl, Input } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks"; -import { - useCreateServiceToken, - useCreateWsEnvironment, - useCreateWsTag, - useDeleteServiceToken, - useDeleteWorkspace, - useDeleteWsEnvironment, - useDeleteWsTag, - useGetUserWsKey, - useGetUserWsServiceTokens, - useGetWorkspaceIndexStatus, - useGetWorkspaceSecrets, - useGetWsTags, - useNameWorkspaceSecrets, - useRenameWorkspace, - useToggleAutoCapitalization, - useUpdateWsEnvironment -} from "@app/hooks/api"; -import { AutoCapitalizationSection } from "./components/AutoCapitalizationSection/AutoCapitalizationSection"; -import { SecretTagsSection } from "./components/SecretTagsSection"; -import { - CopyProjectIDSection, - CreateServiceToken, - CreateUpdateEnvFormData, - CreateWsTag, - E2EESection, - EnvironmentSection, - ProjectIndexSecretsSection, - ProjectNameChangeSection, - ServiceTokenSection} from "./components"; +import { ProjectTabGroup } from "./components"; export const ProjectSettingsPage = () => { const { t } = useTranslation(); - const { currentWorkspace, workspaces, isLoading: isWorkspaceLoading } = useWorkspace(); - const router = useRouter(); - - const workspaceID = currentWorkspace?._id || ""; - const { createNotification } = useNotificationContext(); - // delete action worksapce - const [deleteProjectInput, setDeleteProjectInput] = useState(""); - const [isDeleting, setIsDeleting] = useToggle(); - - const renameWorkspace = useRenameWorkspace(); - const nameWorkspaceSecrets = useNameWorkspaceSecrets(); - const toggleAutoCapitalization = useToggleAutoCapitalization(); - - const deleteWorkspace = useDeleteWorkspace(); - // env crud operation - const createWsEnv = useCreateWsEnvironment(); - const updateWsEnv = useUpdateWsEnvironment(); - const deleteWsEnv = useDeleteWsEnvironment(); - - const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = - useGetWorkspaceIndexStatus(workspaceID); - - // service token - const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({ - workspaceID: currentWorkspace?._id || "" - }); - - const { data: latestFileKey } = useGetUserWsKey(workspaceID); - const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID); - - const createServiceToken = useCreateServiceToken(); - const deleteServiceToken = useDeleteServiceToken(); - - // tag - const { data: wsTags, isLoading: isTagLoading } = useGetWsTags(workspaceID); - const createWsTag = useCreateWsTag(); - const deleteWsTag = useDeleteWsTag(); - - // get user subscription - const { subscription } = useSubscription(); - - const isEnvServiceAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true; - - const onRenameWorkspace = async (name: string) => { - try { - await renameWorkspace.mutateAsync({ workspaceID, newWorkspaceName: name }); - createNotification({ - text: "Successfully renamed workspace", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to rename workspace", - type: "error" - }); - } - }; - - const onAutoCapitalizationToggle = async (state: boolean) => { - try { - await toggleAutoCapitalization.mutateAsync({ - workspaceID, - state - }); - const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; - createNotification({ - text, - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update auto capitalization", - type: "error" - }); - } - }; - - const onDeleteWorkspace = async () => { - setIsDeleting.on(); - try { - await deleteWorkspace.mutateAsync({ workspaceID }); - // redirect user to first workspace user is part of - const ws = workspaces.find(({ _id }) => _id !== workspaceID); - if (!ws) { - router.push("/noprojects"); - } - router.push(`/dashboard/${ws?._id}`); - createNotification({ - text: "Successfully deleted workspace", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to delete workspace", - type: "error" - }); - } finally { - setIsDeleting.off(); - } - }; - - // workspace environment operation - const onCreateWsEnv = async ({ environmentName, environmentSlug }: CreateUpdateEnvFormData) => { - try { - await createWsEnv.mutateAsync({ workspaceID, environmentName, environmentSlug }); - createNotification({ - text: "Successfully created environment", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create environment", - type: "error" - }); - } - }; - - const onUpdateWsEnv = async ( - oldEnvironmentSlug: string, - { environmentName, environmentSlug }: CreateUpdateEnvFormData - ) => { - try { - await updateWsEnv.mutateAsync({ - workspaceID, - environmentName, - environmentSlug, - oldEnvironmentSlug - }); - createNotification({ - text: "Successfully updated environment", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update environment", - type: "error" - }); - } - }; - - const onDeleteWsEnv = async (environmentSlug: string) => { - try { - await deleteWsEnv.mutateAsync({ - workspaceID, - environmentSlug - }); - createNotification({ - text: "Successfully deleted environment", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to delete environment", - type: "error" - }); - } - }; - - const onCreateServiceToken = async ({ - environment, - expiresIn, - name, - permissions, - secretPath - }: CreateServiceToken) => { - // type guard - if (!latestFileKey) return ""; - try { - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); - - const randomBytes = crypto.randomBytes(16).toString("hex"); - - const { ciphertext, iv, tag } = encryptSymmetric({ - plaintext: key, - key: randomBytes - }); - - const res = await createServiceToken.mutateAsync({ - encryptedKey: ciphertext, - iv, - tag, - environment, - secretPath, - expiresIn: Number(expiresIn), - name, - workspaceId: workspaceID, - randomBytes, - permissions: Object.entries(permissions) - .filter(([, permissionsValue]) => permissionsValue) - .map(([permissionsKey]) => permissionsKey) - }); - - createNotification({ - text: "Successfully created a service token", - type: "success" - }); - return res.serviceToken; - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create a service token", - type: "error" - }); - } - return ""; - }; - - const onCreateWsTag = async ({ name }: CreateWsTag) => { - try { - const res = await createWsTag.mutateAsync({ - workspaceID, - tagName: name, - tagSlug: name.replace(" ", "_") - }); - createNotification({ - text: "Successfully created a tag", - type: "success" - }); - return res.name; - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create a tag", - type: "error" - }); - } - return ""; - }; - - const onDeleteTag = async (tagID: string) => { - try { - await deleteWsTag.mutateAsync({ tagID }); - createNotification({ - text: "Successfully deleted tag", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to delete the tag", - type: "error" - }); - } - }; - - const onDeleteServiceToken = async (tokenID: string) => { - try { - await deleteServiceToken.mutateAsync(tokenID); - createNotification({ - text: "Successfully revoked service token", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to delete service token", - type: "error" - }); - } - }; - - const onEnableBlindIndices = async () => { - if (!currentWorkspace?._id) return; - if (!encryptedSecrets) return; - if (!latestFileKey) return; - - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); - - const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { - const secretName = decryptSymmetric({ - ciphertext: encryptedSecret.secretKeyCiphertext, - iv: encryptedSecret.secretKeyIV, - tag: encryptedSecret.secretKeyTag, - key - }); - - return { - secretName, - _id: encryptedSecret._id - }; - }); - - await nameWorkspaceSecrets.mutateAsync({ - workspaceId: currentWorkspace._id, - secretsToUpdate - }); - }; - return ( -
- {/* TODO(akhilmhdh): Remove this right when layout is refactored */} -
- -
-
-
-

{t("settings.project.title")}

-

- {t("settings.project.description")} -

+
+
+
+
-
- - - - - - - {!isBlindIndexedLoading && !isBlindIndexed && ( - - )} - -
-

{t("settings.project.danger-zone")}

-

{t("settings.project.danger-zone-note")}

-
- - Type {currentWorkspace?.name} to delete the - workspace -
- } - > - setDeleteProjectInput(e.target.value)} - value={deleteProjectInput} - placeholder="Type the project name to delete" - className="bg-mineshaft-800" - /> - +
+

+ {t("settings.project.title")} +

- -

- {t("settings.project.delete-project-note")} -

+
); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx index 76fc9197d..b99a07443 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx @@ -1,26 +1,48 @@ import { useTranslation } from "react-i18next"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Checkbox } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggleAutoCapitalization } from "@app/hooks/api"; -type Props = { - workspaceAutoCapitalization?: boolean; - onAutoCapitalizationChange: (state: boolean) => Promise; -}; - -export const AutoCapitalizationSection = ({ - workspaceAutoCapitalization, - onAutoCapitalizationChange -}: Props) => { +export const AutoCapitalizationSection = () => { const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync } = useToggleAutoCapitalization(); + + const handleToggleCapitalizationToggle = async (state: boolean) => { + try { + if (!currentWorkspace?._id) return; + + await mutateAsync({ + workspaceID: currentWorkspace._id, + state + }); + + const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; + createNotification({ + text, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update auto capitalization", + type: "error" + }); + } + } + return ( -
-

{t("settings.project.auto-capitalization")}

+
+

{t("settings.project.auto-capitalization")}

{ - onAutoCapitalizationChange(state as boolean); + handleToggleCapitalizationToggle(state as boolean); }} > {t("settings.project.auto-capitalization-description")} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx deleted file mode 100644 index 39675f3f4..000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/CopyProjectIDSection.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { IconButton } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -type Props = { - workspaceID: string; -}; - -export const CopyProjectIDSection = ({ workspaceID }: Props): JSX.Element => { - const { t } = useTranslation(); - const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false); - - useEffect(() => { - let timer: NodeJS.Timeout; - if (isProjectIdCopied) { - timer = setTimeout(() => setIsProjectIdCopied.off(), 2000); - } - return () => clearTimeout(timer); - }, [isProjectIdCopied]); - - const copyProjectIdToClipboard = () => { - navigator.clipboard.writeText(workspaceID); - setIsProjectIdCopied.on(); - }; - - return ( -
-

{t("common.project-id")}

-

{t("settings.project.auto-generated")}

-
-

{`${t("common.project-id")}:`}

-

{workspaceID}

- copyProjectIdToClipboard()} - > - - - {t("common.click-to-copy")} - - -
-
- ); -}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx deleted file mode 100644 index a82b2b5dd..000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/CopyProjectIDSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { CopyProjectIDSection } from "./CopyProjectIDSection"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx new file mode 100644 index 000000000..1e02ae6b6 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "next/router"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + useDeleteWorkspace +} from "@app/hooks/api"; + +export const DeleteProjectSection = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace, workspaces } = useWorkspace(); + const [isDeleting, setIsDeleting] = useToggle(); + const [deleteProjectInput, setDeleteProjectInput] = useState(""); + const deleteWorkspace = useDeleteWorkspace(); + + const onDeleteWorkspace = async () => { + setIsDeleting.on(); + try { + if (!currentWorkspace?._id) return; + await deleteWorkspace.mutateAsync({ + workspaceID: currentWorkspace?._id + }); + // redirect user to first workspace user is part of + const ws = workspaces.find(({ _id }) => _id !== currentWorkspace?._id); + if (!ws) { + router.push("/noprojects"); + } + router.push(`/dashboard/${ws?._id}`); + createNotification({ + text: "Successfully deleted workspace", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to delete workspace", + type: "error" + }); + } finally { + setIsDeleting.off(); + } + }; + + return ( +
+

{t("settings.project.danger-zone")}

+

{t("settings.project.danger-zone-note")}

+
+ + Type {currentWorkspace?.name} to delete the + workspace +
+ } + > + setDeleteProjectInput(e.target.value)} + value={deleteProjectInput} + placeholder="Type the project name to delete" + className="bg-mineshaft-800" + /> + +
+ +

+ {t("settings.project.delete-project-note")} +

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/index.tsx new file mode 100644 index 000000000..e69f029ab --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/index.tsx @@ -0,0 +1 @@ +export { DeleteProjectSection } from "./DeleteProjectSection"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx index 382f9b1fa..474d2faab 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx @@ -4,29 +4,27 @@ import { decryptAssymmetric, encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; -import { - Checkbox -} from "@app/components/v2"; +import { Checkbox } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; import getBot from "../../../../../pages/api/bot/getBot"; import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus"; import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey"; -type Props = { - workspaceId: string; -} - -export const E2EESection = ({ - workspaceId -}: Props) => { +export const E2EESection = () => { + const { currentWorkspace } = useWorkspace(); const [bot, setBot] = useState(null); useEffect(() => { (async () => { - // get project bot - setBot(await getBot({ workspaceId })); + if (currentWorkspace) { + // get project bot + setBot(await getBot({ + workspaceId: currentWorkspace._id + })); + } })(); - }, []); + }, [currentWorkspace]); /** * Activate bot for project by performing the following steps: @@ -38,12 +36,16 @@ export const E2EESection = ({ const toggleBotActivate = async () => { let botKey; try { + if (!currentWorkspace?._id) return; + if (bot) { // case: there is a bot if (!bot.isActive) { // bot is not active -> activate bot - const key = await getLatestFileKey({ workspaceId }); + const key = await getLatestFileKey({ + workspaceId: currentWorkspace._id + }); const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); if (!PRIVATE_KEY) { @@ -91,12 +93,12 @@ export const E2EESection = ({ }; return bot ? ( -
-

End-to-End Encryption

-

+

+

End-to-End Encryption

+

Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.

-

+

Note that, even with E2EE disabled, your secrets are always encrypted at rest.

; + handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void; +}; + +const schema = yup.object({ + environmentName: yup.string().label("Environment Name").required(), + environmentSlug: yup.string().label("Environment Slug").required() +}); + +export type FormData = yup.InferType; + +export const AddEnvironmentModal = ({ + popUp, + handlePopUpClose, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync, isLoading } = useCreateWsEnvironment(); + const { + control, + handleSubmit, + reset + } = useForm({ + resolver: yupResolver(schema) + }); + + const onFormSubmit = async ({ + environmentName, + environmentSlug + }: FormData) => { + try { + if (!currentWorkspace?._id) return; + + await mutateAsync({ + workspaceID: currentWorkspace._id, + environmentName, + environmentSlug + }); + + createNotification({ + text: "Successfully created environment", + type: "success" + }); + + handlePopUpClose("createEnv"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create environment", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("createEnv", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 415638c15..f6ab8306b 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -1,224 +1,100 @@ -import { Controller, useForm } from "react-hook-form"; -import { faPencil, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, DeleteActionModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr, UpgradePlanModal } from "@app/components/v2"; +import { useSubscription,useWorkspace } from "@app/context"; +import { + useDeleteWsEnvironment +} from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -type Props = { - environments: Array<{ name: string; slug: string }>; - isLoading?: boolean; - isEnvServiceAllowed: boolean; - onCreate: (data: CreateUpdateEnvFormData) => Promise; - onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise; - onDelete: (envSlug: string) => Promise; -}; +import { AddEnvironmentModal } from "./AddEnvironmentModal"; +import { EnvironmentTable } from "./EnvironmentTable"; +import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal"; -const createUpdateEnvSchema = yup.object({ - environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup.string().label("Environment Slug").required() -}); +export const EnvironmentSection = () => { + const { createNotification } = useNotificationContext(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); -export type CreateUpdateEnvFormData = yup.InferType; + const deleteWsEnvironment = useDeleteWsEnvironment(); -export const EnvironmentSection = ({ - environments, - isEnvServiceAllowed, - onCreate, - onDelete, - isLoading, - onUpdate -}: Props): JSX.Element => { + const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "createUpdateEnv", + "createEnv", + "updateEnv", "deleteEnv", "upgradePlan" ] as const); - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: yupResolver(createUpdateEnvSchema) - }); + const onEnvDeleteSubmit = async (environmentSlug: string) => { + try { + if (!currentWorkspace?._id) return; + + await deleteWsEnvironment.mutateAsync({ + workspaceID: currentWorkspace._id, + environmentSlug + }); - const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data); - const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug; - - const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => { - if (isEnvUpdate) { - await onUpdate(oldEnvSlug, data); - } else { - await onCreate(data); + createNotification({ + text: "Successfully deleted environment", + type: "success" + }); + + handlePopUpClose("deleteEnv"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete environment", + type: "error" + }); } - handlePopUpClose("createUpdateEnv"); - }; - - const onEnvDeleteSubmit = async (envSlug: string) => { - await onDelete(envSlug); - handlePopUpClose("deleteEnv"); }; return ( -
-
-
-

Project Environments

-

- Choose which environments will show up in your dashboard like development, staging, - production -

-

- Note: the text in slugs shows how these environmant should be accessed in CLI. -

-
+
+
+

+ Environments +

- - - - - - - - - - {isLoading && } - {!isLoading && - environments.map(({ name, slug }) => ( - - - - - - ))} - {!isLoading && environments?.length === 0 && ( - - - - )} - -
NameSlug -
{name}{slug} - { - handlePopUpOpen("createUpdateEnv", { name, slug }); - reset({ environmentName: name, environmentSlug: slug }); - }} - colorSchema="primary" - variant="plain" - ariaLabel="update" - > - - - { - handlePopUpOpen("deleteEnv", { name, slug }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - > - - -
- -
-
- { - handlePopUpToggle("createUpdateEnv", isOpen); - reset(); - }} - > - -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - - -
- -
-
+

+ Choose which environments will show up in your dashboard like development, staging, production +

+ + + , + { + name, + slug + }: { + name: string; + slug: string; + } + ) => void; +}; + +export const EnvironmentTable = ({ + handlePopUpOpen +}: Props) => { + const { currentWorkspace, isLoading } = useWorkspace(); + return ( + + + + + + + + + + {isLoading && } + {!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => ( + + + + + + ))} + {!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && ( + + + + )} + +
NameSlug +
{name}{slug} + { + handlePopUpOpen("updateEnv", { name, slug }); + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + { + handlePopUpOpen("deleteEnv", { name, slug }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx new file mode 100644 index 000000000..ec6e556c9 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -0,0 +1,135 @@ +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useUpdateWsEnvironment } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["updateEnv"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["updateEnv"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void; +}; + +const schema = yup.object({ + environmentName: yup.string().label("Environment Name").required(), + environmentSlug: yup.string().label("Environment Slug").required() +}); + +export type FormData = yup.InferType; + +export const UpdateEnvironmentModal = ({ + popUp, + handlePopUpClose, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync, isLoading } = useUpdateWsEnvironment(); + const { + control, + handleSubmit, + reset + } = useForm({ + resolver: yupResolver(schema) + }); + + const oldEnvironmentSlug = (popUp?.updateEnv?.data as { slug: string })?.slug; + + const onFormSubmit = async ({ + environmentName, + environmentSlug + }: FormData) => { + try { + if (!currentWorkspace?._id) return; + + await mutateAsync({ + workspaceID: currentWorkspace._id, + environmentName, + environmentSlug, + oldEnvironmentSlug + }); + + createNotification({ + text: "Successfully updated environment", + type: "success" + }); + + handlePopUpClose("updateEnv"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update environment", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("updateEnv", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx new file mode 100644 index 000000000..cacfda8bb --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -0,0 +1,21 @@ +import { AutoCapitalizationSection } from "../AutoCapitalizationSection"; +import { DeleteProjectSection } from "../DeleteProjectSection"; +import { E2EESection } from "../E2EESection"; +import { EnvironmentSection } from "../EnvironmentSection"; +import { ProjectIndexSecretsSection } from "../ProjectIndexSecretsSection"; +import { ProjectNameChangeSection } from "../ProjectNameChangeSection"; +import { SecretTagsSection } from "../SecretTagsSection"; + +export const ProjectGeneralTab = () => { + return ( +
+ + + + + + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/index.tsx new file mode 100644 index 000000000..b78677b96 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/index.tsx @@ -0,0 +1 @@ +export { ProjectGeneralTab } from "./ProjectGeneralTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx index 105fd6c8b..69597e545 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx @@ -1,24 +1,64 @@ +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; import { Button } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + useGetUserWsKey, + useGetWorkspaceIndexStatus, + useGetWorkspaceSecrets, + useNameWorkspaceSecrets +} from "@app/hooks/api"; // TODO: add check so that this only shows up if user is // an admin in the workspace - -type Props = { - onEnableBlindIndices: () => Promise; -} -export const ProjectIndexSecretsSection = ({ - onEnableBlindIndices -}: Props) => { - return ( -
-

Blind Indices

-

+export const ProjectIndexSecretsSection = () => { + const { currentWorkspace } = useWorkspace(); + const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? ""); + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); + const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? ""); + const nameWorkspaceSecrets = useNameWorkspaceSecrets(); + + const onEnableBlindIndices = async () => { + if (!currentWorkspace?._id) return; + if (!encryptedSecrets) return; + if (!latestFileKey) return; + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { + const secretName = decryptSymmetric({ + ciphertext: encryptedSecret.secretKeyCiphertext, + iv: encryptedSecret.secretKeyIV, + tag: encryptedSecret.secretKeyTag, + key + }); + + return { + secretName, + _id: encryptedSecret._id + }; + }); + + await nameWorkspaceSecrets.mutateAsync({ + workspaceId: currentWorkspace._id, + secretsToUpdate + }); + }; + + return (!isBlindIndexedLoading && !isBlindIndexed) ? ( +

+

Blind Indices

+

Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.

-

- Learn more about it here. -

- ); + ) : ( +
+ ) } \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 7a9541192..a4e1ef6a5 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -1,17 +1,15 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { faCheck } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, FormControl, Input } from "@app/components/v2"; - -type Props = { - workspaceName?: string; - onProjectNameChange: (name: string) => Promise; -}; +import { useWorkspace } from "@app/context"; +import { + useRenameWorkspace +} from "@app/hooks/api"; const formSchema = yup.object({ name: yup.string().required().label("Project Name") @@ -19,36 +17,68 @@ const formSchema = yup.object({ type FormData = yup.InferType; -export const ProjectNameChangeSection = ({ - workspaceName, - onProjectNameChange -}: Props): JSX.Element => { +export const ProjectNameChangeSection = () => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync, isLoading } = useRenameWorkspace(); + const { handleSubmit, control, - reset, - formState: { isDirty, isSubmitting } + reset } = useForm({ resolver: yupResolver(formSchema) }); const { t } = useTranslation(); useEffect(() => { - reset({ name: workspaceName }); - }, [workspaceName]); + if (currentWorkspace) { + reset({ + name: currentWorkspace.name + }); + } + + }, [currentWorkspace]); const onFormSubmit = async ({ name }: FormData) => { - await onProjectNameChange(name); + try { + if (!currentWorkspace?._id) return; + + await mutateAsync({ + workspaceID: currentWorkspace._id, + newWorkspaceName: name + }); + + createNotification({ + text: "Successfully renamed workspace", + type: "success" + }); + + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to rename workspace", + type: "error" + }); + } }; return ( -
-
-

{t("common.display-name")}

-
+ +

+ {t("common.display-name")} +

+
( - + )} control={control} @@ -56,17 +86,13 @@ export const ProjectNameChangeSection = ({ />
-
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx new file mode 100644 index 000000000..cfa325b41 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx @@ -0,0 +1,7 @@ +import { ServiceTokenSection } from "../ServiceTokenSection"; + +export const ProjectServiceTokensTab = () => { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/index.tsx new file mode 100644 index 000000000..0f1f58e25 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/index.tsx @@ -0,0 +1 @@ +export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx new file mode 100644 index 000000000..811134822 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/ProjectTabGroup.tsx @@ -0,0 +1,39 @@ +import { Fragment } from "react" +import { Tab } from "@headlessui/react" + +import { ProjectGeneralTab } from "../ProjectGeneralTab"; +import { ProjectServiceTokensTab } from "../ProjectServiceTokensTab"; + +const tabs = [ + { name: "General", key: "tab-project-general" }, + { name: "Service Tokens", key: "tab-project-service-tokens" } +]; + +export const ProjectTabGroup = () => { + return ( + + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx new file mode 100644 index 000000000..ac1f5c50d --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectTabGroup/index.tsx @@ -0,0 +1 @@ +export { ProjectTabGroup } from "./ProjectTabGroup"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx new file mode 100644 index 000000000..a3b004c6a --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/AddSecretTagModal.tsx @@ -0,0 +1,120 @@ +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + Input, + Modal, + ModalClose, + ModalContent +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateWsTag } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = yup.object({ + name: yup.string().required().label("Tag Name") +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["CreateSecretTag", "deleteTagConfirmation"]>, state?: boolean) => void; +}; + +export const AddSecretTagModal = ({ + popUp, + handlePopUpClose, + handlePopUpToggle +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace }= useWorkspace(); + const createWsTag = useCreateWsTag(); + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const onFormSubmit = async ({ + name + }: FormData) => { + try { + if (!currentWorkspace?._id) return; + + await createWsTag.mutateAsync({ + workspaceID: currentWorkspace?._id, + tagName: name, + tagSlug: name.replace(" ", "_") + }); + + handlePopUpClose("CreateSecretTag"); + + createNotification({ + text: "Successfully created a tag", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create a tag", + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("CreateSecretTag", open); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index e4a1e058a..990e38e2d 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -1,182 +1,77 @@ -import { Controller, useForm } from "react-hook-form"; -import { faPlus, faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, - DeleteActionModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalClose, - ModalContent, - ModalTrigger, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr} from "@app/components/v2"; + DeleteActionModal +} from "@app/components/v2"; import { usePopUp } from "@app/hooks"; -import { WorkspaceTag } from "@app/hooks/api/types"; +import { useDeleteWsTag } from "@app/hooks/api"; -const createTagSchema = yup.object({ - name: yup.string().required().label("Tag Name") -}); - -export type CreateWsTag = yup.InferType; - -type Props = { - tags: WorkspaceTag[]; - isLoading?: boolean; - workspaceName: string; - onDeleteTag: (tagID: string) => Promise; - onCreateTag: (data: CreateWsTag) => Promise; -}; +import { AddSecretTagModal } from "./AddSecretTagModal"; +import { SecretTagsTable } from "./SecretTagsTable"; type DeleteModalData = { name: string; id: string }; -export const SecretTagsSection = ({ - tags = [], - isLoading, - onDeleteTag, - workspaceName, - onCreateTag -}: Props): JSX.Element => { +export const SecretTagsSection = (): JSX.Element => { + const { createNotification } = useNotificationContext(); const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ "CreateSecretTag", "deleteTagConfirmation" ] as const); - const { - control, - reset, - handleSubmit, - formState: { isSubmitting } - } = useForm({ - resolver: yupResolver(createTagSchema) - }); - - const onFormSubmit = async (data: CreateWsTag) => { - await onCreateTag(data); - handlePopUpClose("CreateSecretTag"); - }; + const deleteWsTag = useDeleteWsTag(); const onDeleteApproved = async () => { - await onDeleteTag((popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id); - handlePopUpClose("deleteTagConfirmation"); + try { + await deleteWsTag.mutateAsync({ + tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id + }); + + createNotification({ + text: "Successfully deleted tag", + type: "success" + }); + + handlePopUpClose("deleteTagConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete the tag", + type: "error" + }); + } }; return ( -
-
-
-

Secret Tags

-

- Every secret can be assigned to one or more tags. Here you can add and remove tags for - the current project. -

-
-
- { - handlePopUpToggle("CreateSecretTag", open); - reset(); - }} - > - - - - -
- ( - - - - )} - /> -
- - - - -
- -
-
-
+
+
+

Secret Tags

+
- - - - - - - - - - {isLoading && } - {!isLoading && - tags.map(({ _id, name, slug }) => ( - - - - - - ))} - {!isLoading && tags?.length === 0 && ( - - - - )} - -
TagSlug -
{name}{slug} - - handlePopUpOpen("deleteTagConfirmation", { - name, - id: _id - }) - } - colorSchema="danger" - ariaLabel="update" - > - - -
- -
-
+

+ Every secret can be assigned to one or more tags. Here you can add and remove tags for + the current project. +

+ + , + { + name, + id + }: { + name: string; + id: string; + } + ) => void; +}; + +export const SecretTagsTable = ({ + handlePopUpOpen +}: Props) => { + const { currentWorkspace }= useWorkspace(); + const { data, isLoading } = useGetWsTags(currentWorkspace?._id ?? ""); + + return ( + + + + + + + + + + {isLoading && } + {!isLoading && data && data.map(({ _id, name, slug }) => ( + + + + + + ))} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
TagSlug +
{name}{slug} + + handlePopUpOpen("deleteTagConfirmation", { + name, + id: _id + }) + } + colorSchema="danger" + ariaLabel="update" + > + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx new file mode 100644 index 000000000..338738ea2 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -0,0 +1,343 @@ +import crypto from "crypto"; + +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + decryptAssymmetric, + encryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + Checkbox, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + useCreateServiceToken, + useGetUserWsKey +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const apiTokenExpiry = [ + { label: "1 Day", value: 86400 }, + { label: "7 Days", value: 604800 }, + { label: "1 Month", value: 2592000 }, + { label: "6 months", value: 15552000 }, + { label: "12 months", value: 31104000 }, + { label: "Never", value: null } +]; + +const schema = yup.object({ + name: yup.string().max(100).required().label("Service Token Name"), + environment: yup.string().max(50).required().label("Environment"), + secretPath: yup.string().required().default("/").label("Secret Path"), + expiresIn: yup.string().optional().label("Service Token Expiration"), + permissions: yup + .object() + .shape({ + read: yup.boolean().required(), + write: yup.boolean().required() + }) + .defined() + .required() +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["createAPIToken"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void; +}; + +export const AddServiceTokenModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const [newToken, setToken] = useState(""); + const [isTokenCopied, setIsTokenCopied] = useToggle(false); + + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); + const createServiceToken = useCreateServiceToken(); + const hasServiceToken = Boolean(newToken); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isTokenCopied) { + timer = setTimeout(() => setIsTokenCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isTokenCopied]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newToken); + setIsTokenCopied.on(); + }; + + const onFormSubmit = async ({ + name, + environment, + secretPath, + expiresIn, + permissions + }: FormData) => { + try { + if (!currentWorkspace?._id) return; + if (!latestFileKey) return; + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const randomBytes = crypto.randomBytes(16).toString("hex"); + + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: key, + key: randomBytes + }); + + const { serviceToken } = await createServiceToken.mutateAsync({ + encryptedKey: ciphertext, + iv, + tag, + environment, + secretPath, + expiresIn: Number(expiresIn), + name, + workspaceId: currentWorkspace._id, + randomBytes, + permissions: Object.entries(permissions) + .filter(([, permissionsValue]) => permissionsValue) + .map(([permissionsKey]) => permissionsKey) + }); + + setToken(serviceToken); + + createNotification({ + text: "Successfully created a service token", + type: "success" + }); + + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create a service token", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("createAPIToken", open); + reset(); + setToken(""); + }} + > + + {!hasServiceToken ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + { + const options = [ + { + label: "Read (default)", + value: "read" + }, + { + label: "Write (optional)", + value: "write" + } + ]; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ + ) : ( +
+

{newToken}

+ + + + {t("common.click-to-copy")} + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index 1d80c54e6..fd31870f1 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -1,379 +1,82 @@ -import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { faCheck, faCopy, faKey, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, - Checkbox, DeleteActionModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalClose, - ModalContent, - ModalTrigger, - Select, - SelectItem, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr } from "@app/components/v2"; -import { usePopUp, useToggle } from "@app/hooks"; -import { ServiceToken, WorkspaceEnv } from "@app/hooks/api/types"; +import { usePopUp } from "@app/hooks"; +import { + useDeleteServiceToken +} from "@app/hooks/api"; -const apiTokenExpiry = [ - { label: "1 Day", value: 86400 }, - { label: "7 Days", value: 604800 }, - { label: "1 Month", value: 2592000 }, - { label: "6 months", value: 15552000 }, - { label: "12 months", value: 31104000 }, - { label: "Never", value: null } -]; - -const createServiceTokenSchema = yup.object({ - name: yup.string().max(100).required().label("Service Token Name"), - environment: yup.string().max(50).required().label("Environment"), - secretPath: yup.string().required().default("/").label("Secret Path"), - expiresIn: yup.string().optional().label("Service Token Expiration"), - permissions: yup - .object() - .shape({ - read: yup.boolean().required(), - write: yup.boolean().required() - }) - .defined() - .required() -}); - -export type CreateServiceToken = yup.InferType; - -type Props = { - tokens: ServiceToken[]; - isLoading?: boolean; - workspaceName: string; - environments: WorkspaceEnv[]; - onDeleteToken: (serviceTokenID: string) => Promise; - onCreateToken: (data: CreateServiceToken) => Promise; -}; +import { AddServiceTokenModal } from "./AddServiceTokenModal"; +import { ServiceTokenTable } from "./ServiceTokenTable"; type DeleteModalData = { name: string; id: string }; -export const ServiceTokenSection = ({ - tokens = [], - isLoading, - onDeleteToken, - workspaceName, - environments = [], - onCreateToken -}: Props): JSX.Element => { - const [newToken, setToken] = useState(""); +export const ServiceTokenSection = () => { const { t } = useTranslation(); - const [isTokenCopied, setIsTokenCopied] = useToggle(false); - - useEffect(() => { - let timer: NodeJS.Timeout; - if (isTokenCopied) { - timer = setTimeout(() => setIsTokenCopied.off(), 2000); - } - return () => clearTimeout(timer); - }, [isTokenCopied]); - - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(newToken); - setIsTokenCopied.on(); - }; + const { createNotification } = useNotificationContext(); + const deleteServiceToken = useDeleteServiceToken(); const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ "createAPIToken", "deleteAPITokenConfirmation" ] as const); - const { - control, - reset, - handleSubmit, - formState: { isSubmitting } - } = useForm({ - resolver: yupResolver(createServiceTokenSchema) - }); - - const hasServiceToken = Boolean(newToken); - - const onFormSubmit = async (data: CreateServiceToken) => { - const token = await onCreateToken(data); - setToken(token); - }; - const onDeleteApproved = async () => { - await onDeleteToken((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id); - handlePopUpClose("deleteAPITokenConfirmation"); + try { + deleteServiceToken.mutateAsync((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id); + createNotification({ + text: "Successfully deleted service token", + type: "success" + }); + + handlePopUpClose("deleteAPITokenConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token", + type: "error" + }); + } }; return ( -
-
-
-

{t("section.token.service-tokens")}

-

{t("section.token.service-tokens-description")}

-
-
- { - handlePopUpToggle("createAPIToken", open); - reset(); - setToken(""); +
+
+

{t("section.token.service-tokens")}

+ - - - {!hasServiceToken ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - - ( - - - - )} - /> - { - const options = [ - { - label: "Read (default)", - value: "read" - }, - { - label: "Write (optional)", - value: "write" - } - ]; - - return ( - - <> - {options.map(({ label, value: optionValue }) => { - // TODO: refactor - return ( - { - onChange({ - ...value, - [optionValue]: state - }); - }} - > - {label} - - ); - })} - - - ); - }} - /> -
- - - - -
- - ) : ( -
-

{newToken}

- - - - {t("common.click-to-copy")} - - -
- )} -
- -
+ Create token +
+

{t("section.token.service-tokens-description")}

+ + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} onDeleteApproved={onDeleteApproved} /> - - - - - - - - - - - - {isLoading && } - {!isLoading && - tokens.map((row) => ( - - - - - - - - ))} - {!isLoading && tokens?.length === 0 && ( - - - - )} - -
Token NameEnvironmentSecret PathValid Until -
{row.name}{row.environment}{row.secretPath}{row.expiresAt && new Date(row.expiresAt).toUTCString()} - - handlePopUpOpen("deleteAPITokenConfirmation", { - name: row.name, - id: row._id - }) - } - colorSchema="danger" - ariaLabel="delete" - > - - -
- -
-
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx new file mode 100644 index 000000000..69dab23df --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -0,0 +1,88 @@ +import { faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetUserWsServiceTokens } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteAPITokenConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; +}; + +export const ServiceTokenTable = ({ + handlePopUpOpen +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetUserWsServiceTokens({ + workspaceID: currentWorkspace?._id || "" + }); + + return ( + + + + + + + + + + + + {isLoading && } + {!isLoading && data && data.map((row) => ( + + + + + + + + ))} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
Token NameEnvironmentSecret PathValid Until +
{row.name}{row.environment}{row.secretPath}{row.expiresAt && new Date(row.expiresAt).toUTCString()} + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row._id + }) + } + colorSchema="danger" + ariaLabel="delete" + > + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx index 3595f2625..b00af72c9 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx @@ -1,9 +1,9 @@ -export { CopyProjectIDSection } from "./CopyProjectIDSection"; +export { AutoCapitalizationSection } from "./AutoCapitalizationSection"; +export { DeleteProjectSection } from "./DeleteProjectSection"; export { E2EESection } from "./E2EESection"; export { EnvironmentSection } from "./EnvironmentSection"; -export type { CreateUpdateEnvFormData } from "./EnvironmentSection/EnvironmentSection"; export { ProjectIndexSecretsSection } from "./ProjectIndexSecretsSection"; export { ProjectNameChangeSection } from "./ProjectNameChangeSection"; -export type { CreateWsTag } from "./SecretTagsSection/SecretTagsSection"; +export { ProjectTabGroup } from "./ProjectTabGroup"; +export { SecretTagsSection } from "./SecretTagsSection"; export { ServiceTokenSection } from "./ServiceTokenSection"; -export type { CreateServiceToken } from "./ServiceTokenSection/ServiceTokenSection";