From 7fe4089bb0c2f22e9722dec5ab5479a9983d49d0 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Wed, 5 Jul 2023 22:53:03 +0530 Subject: [PATCH] feat(secret-ref): implemented ui for service token changes --- frontend/src/hooks/api/serviceTokens/types.ts | 11 +- .../AddServiceTokenModal.tsx | 574 +++++++++--------- .../ServiceTokenSection.tsx | 56 +- .../ServiceTokenSection/ServiceTokenTable.tsx | 141 +++-- 4 files changed, 406 insertions(+), 376 deletions(-) diff --git a/frontend/src/hooks/api/serviceTokens/types.ts b/frontend/src/hooks/api/serviceTokens/types.ts index 0c68aea79..d6dc322cc 100644 --- a/frontend/src/hooks/api/serviceTokens/types.ts +++ b/frontend/src/hooks/api/serviceTokens/types.ts @@ -1,9 +1,13 @@ +export type ServiceTokenScope = { + environment: string; + secretPath: string; +}; + export type ServiceToken = { _id: string; name: string; workspace: string; - environment: string; - secretPath: string; + scopes: ServiceTokenScope[]; user: string; expiresAt: string; createdAt: string; @@ -14,9 +18,8 @@ export type ServiceToken = { export type CreateServiceTokenDTO = { name: string; workspaceId: string; - environment: string; + scopes: ServiceTokenScope[]; expiresIn: number; - secretPath: string; encryptedKey: string; iv: string; tag: string; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx index 338738ea2..82fbf9edc 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -1,9 +1,9 @@ import crypto from "crypto"; import { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { faCheck, faCopy, faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; @@ -27,10 +27,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useToggle } from "@app/hooks"; -import { - useCreateServiceToken, - useGetUserWsKey -} from "@app/hooks/api"; +import { useCreateServiceToken, useGetUserWsKey } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const apiTokenExpiry = [ @@ -44,8 +41,16 @@ const apiTokenExpiry = [ 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"), + scopes: yup + .array( + yup.object({ + environment: yup.string().max(50).required().label("Environment"), + secretPath: yup.string().required().default("/").label("Secret Path") + }) + ) + .min(1) + .required() + .label("Scope"), expiresIn: yup.string().optional().label("Service Token Expiration"), permissions: yup .object() @@ -60,284 +65,301 @@ const schema = yup.object({ export type FormData = yup.InferType; type Props = { - popUp: UsePopUpState<["createAPIToken"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void; + 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) - }); +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), + defaultValues: { + scopes: [{ secretPath: "/", environment: currentWorkspace?.environments?.[0]?.slug }] + } + }); - const [newToken, setToken] = useState(""); - const [isTokenCopied, setIsTokenCopied] = useToggle(false); + const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); - const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); - const createServiceToken = useCreateServiceToken(); - const hasServiceToken = Boolean(newToken); + const [newToken, setToken] = useState(""); + const [isTokenCopied, setIsTokenCopied] = useToggle(false); - useEffect(() => { - let timer: NodeJS.Timeout; - if (isTokenCopied) { - timer = setTimeout(() => setIsTokenCopied.off(), 2000); - } + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); + const createServiceToken = useCreateServiceToken(); + const hasServiceToken = Boolean(newToken); - return () => clearTimeout(timer); - }, [isTokenCopied]); + useEffect(() => { + let timer: NodeJS.Timeout; + if (isTokenCopied) { + timer = setTimeout(() => setIsTokenCopied.off(), 2000); + } - const copyTokenToClipboard = () => { - navigator.clipboard.writeText(newToken); - setIsTokenCopied.on(); - }; + return () => clearTimeout(timer); + }, [isTokenCopied]); - const onFormSubmit = async ({ + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newToken); + setIsTokenCopied.on(); + }; + + const onFormSubmit = async ({ name, scopes, 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, + scopes, + expiresIn: Number(expiresIn), name, - environment, - secretPath, - expiresIn, - permissions - }: FormData) => { - try { - if (!currentWorkspace?._id) return; - if (!latestFileKey) return; + workspaceId: currentWorkspace._id, + randomBytes, + permissions: Object.entries(permissions) + .filter(([, permissionsValue]) => permissionsValue) + .map(([permissionsKey]) => permissionsKey) + }); - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); + 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" + }); + } + }; - 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(""); + }} + > + { - 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")} - - -
+ subTitle={t("section.token.add-dialog.description") as string} + > + {!hasServiceToken ? ( +
+ ( + + + )} - - - ); -} \ No newline at end of file + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> + { + 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")} + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index fd31870f1..376328673 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -3,14 +3,9 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - DeleteActionModal, -} from "@app/components/v2"; +import { Button, DeleteActionModal } from "@app/components/v2"; import { usePopUp } from "@app/hooks"; -import { - useDeleteServiceToken -} from "@app/hooks/api"; +import { useDeleteServiceToken } from "@app/hooks/api"; import { AddServiceTokenModal } from "./AddServiceTokenModal"; import { ServiceTokenTable } from "./ServiceTokenTable"; @@ -29,7 +24,9 @@ export const ServiceTokenSection = () => { const onDeleteApproved = async () => { try { - deleteServiceToken.mutateAsync((popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id); + deleteServiceToken.mutateAsync( + (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id + ); createNotification({ text: "Successfully deleted service token", type: "success" @@ -46,32 +43,29 @@ export const ServiceTokenSection = () => { }; return ( -
-
-

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

- +
+
+

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

+
-

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

- - +

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

+ + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx index 69dab23df..ce39abde2 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -1,4 +1,4 @@ -import { faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -18,71 +18,82 @@ 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; + 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 || "" - }); +export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetUserWsServiceTokens({ + workspaceID: currentWorkspace?._id || "" + }); - return ( - - - - - - - - - - - - {isLoading && } - {!isLoading && data && data.map((row) => ( - - - - - - - + return ( + +
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" - > - - -
+ + + + + + + + + {isLoading && } + {!isLoading && + data && + data.map((row) => ( + + + - - - )} - -
Token NameEnvrionment - Secret PathValid Until +
{row.name} +
+ {row?.scopes.map(({ secretPath, environment }) => ( +
+
{environment}
+ + {secretPath} +
))} - {!isLoading && data && data?.length === 0 && ( -
- -
-
- ); -} \ No newline at end of file +
+ + {row.expiresAt && new Date(row.expiresAt).toUTCString()} + + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row._id + }) + } + colorSchema="danger" + ariaLabel="delete" + > + + + + + ))} + {!isLoading && data && data?.length === 0 && ( + + + + + + )} + + + + ); +};