diff --git a/backend/src/models/serviceTokenDataV3.ts b/backend/src/models/serviceTokenDataV3.ts index 198b60507c..86b89d63f5 100644 --- a/backend/src/models/serviceTokenDataV3.ts +++ b/backend/src/models/serviceTokenDataV3.ts @@ -100,7 +100,7 @@ const serviceTokenDataV3Schema = new Schema( default: 7200, required: true }, - scopes: { + scopes: { // TODO: consider switching this out for roles instead type: [ { environment: { diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 5b19b657cf..b70cc442e9 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -505,7 +505,7 @@ export const AppLayout = ({ children }: LayoutProps) => { } icon="system-outline-96-groups" > - {t("nav.menu.members")} + Access Control @@ -548,19 +548,6 @@ export const AppLayout = ({ children }: LayoutProps) => { - - {/* - - - IP Allowlist - - - */} { - const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?._id || ""; - const orgId = currentWorkspace?.organization || ""; - - const { data: roles, isLoading: isRolesLoading } = useGetRoles({ - orgId, - workspaceId - }); - return (

- {t("settings.members.title")} + Access Control

Members + Service Tokens Roles @@ -47,14 +37,14 @@ export const MembersPage = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - []} isRolesLoading={isRolesLoading} /> + + + + - []} - isRolesLoading={isRolesLoading} - /> +
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index f35b578e1c..c0499d2436 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -46,17 +46,11 @@ import { useAddUserToWs, useDeleteUserFromWorkspace, useGetOrgUsers, + useGetRoles, useGetUserWsKey, useGetWorkspaceUsers, useUpdateUserWorkspaceRole, - useUploadWsKey -} from "@app/hooks/api"; -import { TRole } from "@app/hooks/api/roles/types"; - -type Props = { - roles?: TRole[]; - isRolesLoading?: boolean; -}; + useUploadWsKey} from "@app/hooks/api"; const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() @@ -64,7 +58,7 @@ const addMemberFormSchema = z.object({ type TAddMemberForm = z.infer; -export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => { +export const MemberListTab = () => { const { createNotification } = useNotificationContext(); const { t } = useTranslation(); @@ -76,6 +70,10 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => { const orgId = currentOrg?._id || ""; const workspaceId = currentWorkspace?._id || ""; + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId, + workspaceId + }); const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: orgUsers } = useGetOrgUsers(orgId); @@ -162,7 +160,7 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => { const findRoleFromId = useCallback( (roleId: string) => { - return roles.find(({ _id: id }) => id === roleId); + return (roles || []).find(({ _id: id }) => id === roleId); }, [roles] ); @@ -307,7 +305,7 @@ export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => { onRoleChange(membershipId, selectedRole) } > - {roles + {(roles || []) .filter(({ slug }) => slug === "owner" ? isIamOwner || role === "owner" : true ) diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index b433ed7e4a..d3bd3b39c9 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -8,13 +8,8 @@ import { TRole } from "@app/hooks/api/roles/types"; import { ProjectRoleList } from "./components/ProjectRoleList"; import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; -type Props = { - roles?: TRole[]; - isRolesLoading?: boolean; -}; - export const ProjectRoleListTab = withProjectPermission( - ({ roles = [], isRolesLoading }: Props) => { + () => { const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); return popUp.editRole.isOpen ? ( @@ -38,11 +33,7 @@ export const ProjectRoleListTab = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} - /> + handlePopUpOpen("editRole", role)} /> ); }, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index ce0b42385c..cca666ba80 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -25,24 +25,26 @@ import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteRole } from "@app/hooks/api"; +import { useDeleteRole, useGetRoles } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; type Props = { - isRolesLoading?: boolean; - roles?: TRole[]; onSelectRole: (role?: TRole) => void; }; -export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Props) => { +export const ProjectRoleList = ({ onSelectRole }: Props) => { const [searchRoles, setSearchRoles] = useState(""); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); const orgId = currentOrg?._id || ""; const workspaceId = currentWorkspace?._id || ""; - - const { createNotification } = useNotificationContext(); - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); + + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId, + workspaceId + }); const { mutateAsync: deleteRole } = useDeleteRole(); @@ -62,6 +64,8 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr } }; + // roles={roles as TRole[]} + return (
@@ -97,7 +101,7 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr {isRolesLoading && } - {roles?.map((role) => { + {(roles as TRole[])?.map((role) => { const { _id: id, name, slug } = role; const isNonMutatable = ["admin", "member", "viewer"].includes(slug); diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx new file mode 100644 index 0000000000..86ae8bfe58 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/ServiceTokenTab.tsx @@ -0,0 +1,21 @@ +import { motion } from "framer-motion"; + +import { + ServiceTokenSection, + // ServiceTokenV3Section +} from "./components"; + +export const ServiceTokenTab = () => { + return ( + + {/* */} + + + ); +} diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx new file mode 100644 index 0000000000..7910652b50 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -0,0 +1,375 @@ +import crypto from "crypto"; + +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +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"; + +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"), + scopes: yup + .array( + yup.object({ + environment: yup.string().max(50).required().label("Environment"), + secretPath: yup + .string() + .required() + .default("/") + .label("Secret Path") + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) + }) + ) + .min(1) + .required() + .label("Scope"), + 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), + defaultValues: { + scopes: [{ + secretPath: "/", + environment: currentWorkspace?.environments?.[0]?.slug + }] + } + }); + + const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); + + 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, 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, + 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 ? ( +
+ ( + + + + )} + /> + {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/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx new file mode 100644 index 0000000000..94a1858712 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from "react-i18next"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { usePopUp } from "@app/hooks"; +import { useDeleteServiceToken } from "@app/hooks/api"; + +import { AddServiceTokenModal } from "./AddServiceTokenModal"; +import { ServiceTokenTable } from "./ServiceTokenTable"; + +type DeleteModalData = { name: string; id: string }; + +export const ServiceTokenSection = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const deleteServiceToken = useDeleteServiceToken(); + + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createAPIToken", + "deleteAPITokenConfirmation" + ] as const); + + const onDeleteApproved = async () => { + 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 ( +
+
+

+ Service Tokens +

+ + {(isAllowed) => ( + + )} + +
+

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

+ + + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} + deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx new file mode 100644 index 0000000000..6ac54ba97d --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -0,0 +1,108 @@ +import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, 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 NameEnvironment - Secret PathValid Until +
{row.name} +
+ {row?.scopes.map(({ secretPath, environment }) => ( +
+
{environment}
+ + {secretPath} +
+ ))} +
+
{row.expiresAt && new Date(row.expiresAt).toUTCString()} + + {(isAllowed) => ( + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row._id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx new file mode 100644 index 0000000000..588dd234d9 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenSection/index.tsx @@ -0,0 +1 @@ +export {ServiceTokenSection} from "./ServiceTokenSection" \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx new file mode 100644 index 0000000000..3d76b2435e --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/AddServiceTokenV3Modal.tsx @@ -0,0 +1,657 @@ +import { useEffect, useState } from "react"; +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faCheck, faCopy,faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { motion } from "framer-motion"; +import nacl from "tweetnacl"; +import { encodeBase64 } from "tweetnacl-util"; +import * as yup from "yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Switch, + Tab, + TabList, + TabPanel, + Tabs, + UpgradePlanModal} from "@app/components/v2"; +import { + useSubscription, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { + useCreateServiceTokenV3, + useGetUserWsKey, + useUpdateServiceTokenV3 +} from "@app/hooks/api"; +import { + Permission +} from "@app/hooks/api/serviceTokens/enums"; +import { + ServiceTokenV3Scope, + ServiceTokenV3TrustedIp +} from "@app/hooks/api/serviceTokens/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +enum TabSections { + General = "general", + Advanced = "advanced" +} + +const expirations = [ + { label: "Never", value: "" }, + { 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" } +]; + +const permissionsMap: { + [key: string]: Permission[] +} = { + "read": [Permission.READ], + "readWrite": [Permission.READ, Permission.WRITE], +} + +const schema = yup.object({ + name: yup.string().required("ST V3 name is required"), + expiresIn: yup.string(), + accessTokenTTL: yup + .string() + .test("is-positive-integer", "Access Token TTL must be a positive integer", (value) => { + if (typeof value === "undefined") { + return false; + } + + const num = parseInt(value, 10); + return !Number.isNaN(num) && num > 0 && String(num) === value; + }) + .required("Access Token TTL is required"), + scopes: yup + .array( + yup.object({ + permission: yup.string().oneOf(Object.keys(permissionsMap), "Invalid permission").required().label("Permission"), + environment: yup.string().max(50).required().label("Environment"), + secretPath: yup + .string() + .required() + .default("/") + .label("Secret Path") + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) + }) + ) + .min(1) + .required() + .label("Scope"), + trustedIps: yup + .array( + yup.object({ + ipAddress: yup.string().max(50).required().label("IP Address") + }) + ) + .min(1) + .required() + .label("Trusted IP"), + isRefreshTokenRotationEnabled: yup.boolean().default(false) +}).required(); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["serviceTokenV3", "upgradePlan"]>; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["serviceTokenV3", "upgradePlan"]>, state?: boolean) => void; +}; + +export const AddServiceTokenV3Modal = ({ + popUp, + handlePopUpOpen, + handlePopUpToggle +}: Props) => { + const [newServiceTokenJSON, setNewServiceTokenJSON] = useState(""); + const [isServiceTokenJSONCopied, setIsServiceTokenJSONCopied] = useToggle(false); + + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); + const { mutateAsync: createMutateAsync } = useCreateServiceTokenV3(); + const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); + const { createNotification } = useNotificationContext(); + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + name: "", + accessTokenTTL: "7200", + scopes: [{ + permission: "read", + environment: currentWorkspace?.environments?.[0]?.slug, + secretPath: "/", + }], + trustedIps: [{ + ipAddress: "0.0.0.0/0" + }] + } + }); + + useEffect(() => { + let timer: NodeJS.Timeout; + + if (isServiceTokenJSONCopied) { + timer = setTimeout(() => setIsServiceTokenJSONCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [setIsServiceTokenJSONCopied]); + + const copyTokenToClipboard = () => { + navigator.clipboard.writeText(newServiceTokenJSON); + setIsServiceTokenJSONCopied.on(); + }; + + useEffect(() => { + const serviceTokenData = popUp?.serviceTokenV3?.data as { + serviceTokenDataId: string; + name: string; + scopes: ServiceTokenV3Scope[]; + trustedIps: ServiceTokenV3TrustedIp[]; + accessTokenTTL: number; + isRefreshTokenRotationEnabled: boolean; + }; + + if (serviceTokenData) { + reset({ + name: serviceTokenData.name, + scopes: serviceTokenData.scopes.map(({ + environment, + secretPath, + permissions + }: ServiceTokenV3Scope) => { + let permission = "read"; + if (permissions.includes(Permission.WRITE)) { + permission = "readWrite"; + } + + return ({ + environment, + secretPath, + permission + }) + }), + trustedIps: serviceTokenData.trustedIps.map(({ + ipAddress, + prefix + }: ServiceTokenV3TrustedIp) => { + return ({ + ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` + }); + }), + accessTokenTTL: String(serviceTokenData.accessTokenTTL), + isRefreshTokenRotationEnabled: serviceTokenData.isRefreshTokenRotationEnabled + }); + } else { + reset({ + name: "", + accessTokenTTL: "7200", + scopes: [{ + permission: "read", + environment: currentWorkspace?.environments?.[0]?.slug, + secretPath: "/", + }], + trustedIps: [{ + ipAddress: "0.0.0.0/0" + }] + }); + } + }, [popUp?.serviceTokenV3?.data]); + + const { fields: tokenScopes, append, remove } = useFieldArray({ control, name: "scopes" }); + const { fields: tokenTrustedIps, append: appendTrustedIp, remove: removeTrustedIp } = useFieldArray({ control, name: "trustedIps" }); + + const onFormSubmit = async ({ + name, + expiresIn, + accessTokenTTL, + scopes, + trustedIps, + isRefreshTokenRotationEnabled + }: FormData) => { + try { + const serviceTokenData = popUp?.serviceTokenV3?.data as { + serviceTokenDataId: string; + name: string; + scopes: any; + }; + + // convert read/readWrite permission => ["read", "write"] format + const reformattedScopes = scopes.map((scope) => { + return ({ + environment: scope.environment, + secretPath: scope.secretPath, + permissions: permissionsMap[scope.permission] + }); + }); + + if (serviceTokenData) { + // update + + await updateMutateAsync({ + serviceTokenDataId: serviceTokenData.serviceTokenDataId, + name, + scopes: reformattedScopes, + trustedIps, + expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + accessTokenTTL: Number(accessTokenTTL), + isRefreshTokenRotationEnabled + }); + + handlePopUpToggle("serviceTokenV3", false); + } else { + // create + if (!currentWorkspace?._id) return; + if (!latestFileKey) return; + + const pair = nacl.box.keyPair(); + const secretKeyUint8Array = pair.secretKey; + const publicKeyUint8Array = pair.publicKey; + const privateKey = encodeBase64(secretKeyUint8Array); + const publicKey = encodeBase64(publicKeyUint8Array); + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string + }); + + const { refreshToken } = await createMutateAsync({ + name, + workspaceId: currentWorkspace._id, + publicKey, + scopes: reformattedScopes, + trustedIps, + expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + accessTokenTTL: Number(accessTokenTTL), + encryptedKey: ciphertext, + nonce, + isRefreshTokenRotationEnabled + }); + + const downloadData = { + public_key: publicKey, + private_key: privateKey, + refresh_token: refreshToken + }; + + const serviceTokenJSON = JSON.stringify(downloadData, null, 2); + setNewServiceTokenJSON(serviceTokenJSON); + + const blob = new Blob([serviceTokenJSON], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = href; + link.download = `infisical_${name}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + createNotification({ + text: `Successfully ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${popUp?.serviceTokenV3?.data ? "updated" : "created"} ST V3`, + type: "error" + }); + } + } + + const hasServiceTokenJSON = Boolean(newServiceTokenJSON); + + return ( + { + handlePopUpToggle("serviceTokenV3", isOpen); + reset(); + setNewServiceTokenJSON(""); + }} + > + + {!hasServiceTokenJSON ? ( +
+ + +
+ General + Advanced +
+
+ + + ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> +
+
+ +
+ {tokenTrustedIps.map(({ id }, index) => ( +
+ { + return ( + + { + if (subscription?.ipAllowlisting) { + field.onChange(e); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + placeholder="123.456.789.0" + /> + + ); + }} + /> + { + if (subscription?.ipAllowlisting) { + removeTrustedIp(index); + return; + } + + handlePopUpOpen("upgradePlan"); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="p-3" + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> +
+ ( + onChange(isChecked)} + isChecked={value} + > + Refresh Token Rotation + + )} + /> +

When enabled, as a result of exchanging a refresh token, a new refresh token will be issued and the existing token will be invalidated.

+
+
+
+
+
+ + +
+
+ ) : ( +
+

{newServiceTokenJSON}

+ + + + Click to copy + + +
+ )} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx new file mode 100644 index 0000000000..3b00202f97 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Section.tsx @@ -0,0 +1,98 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { + useDeleteServiceTokenV3 +} from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddServiceTokenV3Modal } from "./AddServiceTokenV3Modal"; +import { ServiceTokenV3Table } from "./ServiceTokenV3Table"; + +export const ServiceTokenV3Section = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { mutateAsync: deleteMutateAsync } = useDeleteServiceTokenV3(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "serviceTokenV3", + "deleteServiceTokenV3", + "upgradePlan" + ] as const); + + const onDeleteServiceTokenDataSubmit = async (serviceTokenDataId: string) => { + try { + await deleteMutateAsync({ + serviceTokenDataId + }); + createNotification({ + text: "Successfully deleted service token v3", + type: "success" + }); + + handlePopUpClose("deleteServiceTokenV3"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token v3", + type: "error" + }); + } + } + + return ( +
+
+

+ Service Tokens V3 (Beta) +

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteServiceTokenV3", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteServiceTokenDataSubmit( + (popUp?.deleteServiceTokenV3?.data as { serviceTokenDataId: string })?.serviceTokenDataId + ) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx new file mode 100644 index 0000000000..d41a0bd82e --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/ServiceTokenV3Table.tsx @@ -0,0 +1,232 @@ +import { faKey, faPencil,faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Switch, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; +import { + useGetWorkspaceServiceTokenDataV3, + useUpdateServiceTokenV3 +} from "@app/hooks/api"; +import { Permission } from "@app/hooks/api/serviceTokens/enums" +import { ServiceTokenV3Scope, ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types" +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteServiceTokenV3", "serviceTokenV3"]>, + data?: { + serviceTokenDataId?: string; + name?: string; + scopes?: ServiceTokenV3Scope[]; + trustedIps?: ServiceTokenV3TrustedIp[]; + accessTokenTTL?: number; + isRefreshTokenRotationEnabled?: boolean; + } + ) => void; + }; + +export const ServiceTokenV3Table = ({ + handlePopUpOpen +}: Props) => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetWorkspaceServiceTokenDataV3(currentWorkspace?._id || ""); + const { mutateAsync: updateMutateAsync } = useUpdateServiceTokenV3(); + + const handleToggleServiceTokenDataStatus = async ({ + serviceTokenDataId, + isActive + }: { + serviceTokenDataId: string; + isActive: boolean; + }) => { + try { + await updateMutateAsync({ + serviceTokenDataId, + isActive + }); + + createNotification({ + text: `Successfully ${isActive ? "enabled" : "disabled"} service token v3`, + type: "success" + }); + } catch (err) { + console.log(err); + createNotification({ + text: `Failed to ${isActive ? "enable" : "disable"} service token v3`, + type: "error" + }); + } + } + + return ( + + + + + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ + _id, + name, + isActive, + scopes, + trustedIps, + createdAt, + expiresAt, + accessTokenTTL, + isRefreshTokenRotationEnabled + }) => { + return ( + + + + + + + + + + + ); + })} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameStatusScopesTrusted IPsAccess Token TTLCreated AtValid Until +
{name} + + {(isAllowed) => ( + handleToggleServiceTokenDataStatus({ + serviceTokenDataId: _id, + isActive: value + })} + isChecked={isActive} + isDisabled={!isAllowed} + > +

{isActive ? "Active" : "Inactive"}

+
+ )} +
+
+ {scopes.map((scope) => { + let permissionText = "read" + if ( + scope.permissions.includes(Permission.WRITE) && + scope.permissions.includes(Permission.READ) + ) { + permissionText = "readWrite"; + } + + return ( +

+ + {permissionText} + + {` @${scope.environment} - ${scope.secretPath}`} +

+ ); + })} +
+ {trustedIps.map(({ + _id: trustedIpId, + ipAddress, + prefix + }) => { + return ( +

+ {`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`} +

+ ); + })} +
{accessTokenTTL}{format(new Date(createdAt), "yyyy-MM-dd")}{expiresAt ? format(new Date(expiresAt), "yyyy-MM-dd") : "-"} + + {(isAllowed) => ( + { + handlePopUpOpen("serviceTokenV3", { + serviceTokenDataId: _id, + name, + scopes, + trustedIps, + accessTokenTTL, + isRefreshTokenRotationEnabled + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteServiceTokenV3", { + serviceTokenDataId: _id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx new file mode 100644 index 0000000000..b6abc117c4 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/ServiceTokenV3Section/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx new file mode 100644 index 0000000000..18a8762cd6 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/components/index.tsx @@ -0,0 +1,2 @@ +export { ServiceTokenSection } from "./ServiceTokenSection"; +export { ServiceTokenV3Section } from "./ServiceTokenV3Section"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/index.tsx b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/index.tsx new file mode 100644 index 0000000000..762c29bde4 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ServiceTokenTab/index.tsx @@ -0,0 +1 @@ +export { ServiceTokenTab } from "./ServiceTokenTab"; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 8aa06594b1..e5d62fe65a 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -3,12 +3,10 @@ import { useTranslation } from "react-i18next"; import { Tab } from "@headlessui/react"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; -import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; import { WebhooksTab } from "./components/WebhooksTab"; const tabs = [ { name: "General", key: "tab-project-general" }, - { name: "Service Tokens", key: "tab-project-service-tokens" }, { name: "Webhooks", key: "tab-project-webhooks" } ]; @@ -41,9 +39,6 @@ export const ProjectSettingsPage = () => { - - - diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx deleted file mode 100644 index d4f8e79e77..0000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/ProjectServiceTokensTab.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ServiceTokenSection } from "../ServiceTokenSection"; -// import { ServiceTokenV3Section } from "../ServiceTokenV3Section"; - -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 deleted file mode 100644 index 0f1f58e258..0000000000 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectServiceTokensTab/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab"; \ 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 757dfc6b26..4a5bc4982b 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx @@ -3,5 +3,4 @@ export { DeleteProjectSection } from "./DeleteProjectSection"; export { E2EESection } from "./E2EESection"; export { EnvironmentSection } from "./EnvironmentSection"; export { ProjectNameChangeSection } from "./ProjectNameChangeSection"; -export { SecretTagsSection } from "./SecretTagsSection"; -export { ServiceTokenSection } from "./ServiceTokenSection"; +export { SecretTagsSection } from "./SecretTagsSection"; \ No newline at end of file