diff --git a/.env.example b/.env.example index 67110d69af..a6f134ace8 100644 --- a/.env.example +++ b/.env.example @@ -67,3 +67,6 @@ CLIENT_SECRET_GITLAB_LOGIN= CAPTCHA_SECRET= NEXT_PUBLIC_CAPTCHA_SITE_KEY= + +PLAIN_API_KEY= +PLAIN_WISH_LABEL_IDS= diff --git a/backend/package-lock.json b/backend/package-lock.json index 20a5f7c21c..22484e79a0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", + "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", @@ -3913,6 +3914,14 @@ "yaml": "^2.2.2" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@hapi/bourne": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-2.1.0.tgz", @@ -6198,6 +6207,18 @@ "optional": true, "peer": true }, + "node_modules/@team-plain/typescript-sdk": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-4.6.1.tgz", + "integrity": "sha512-Uy9QJXu9U7bJb6WXL9sArGk7FXPpzdqBd6q8tAF1vexTm8fbTJRqcikTKxGtZmNADt+C2SapH3cApM4oHpO4lQ==", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", + "graphql": "^16.6.0", + "zod": "3.22.4" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -10314,6 +10335,14 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gtoken": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index b278be571c..f2c554a19c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -91,6 +91,7 @@ "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", + "@team-plain/typescript-sdk": "^4.6.1", "@ucast/mongo2js": "^1.3.4", "ajv": "^8.12.0", "argon2": "^0.31.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index c7e58fb099..166c5a3f99 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -65,6 +65,7 @@ import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; +import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; declare module "fastify" { @@ -157,6 +158,7 @@ declare module "fastify" { identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; secretSharing: TSecretSharingServiceFactory; rateLimit: TRateLimitServiceFactory; + userEngagement: TUserEngagementServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 1fe17dc4b7..e57d0ce50e 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -135,7 +135,9 @@ const envSchema = z .optional(), INFISICAL_CLOUD: zodStrBool.default("false"), MAINTENANCE_MODE: zodStrBool.default("false"), - CAPTCHA_SECRET: zpStr(z.string().optional()) + CAPTCHA_SECRET: zpStr(z.string().optional()), + PLAIN_API_KEY: zpStr(z.string().optional()), + PLAIN_WISH_LABEL_IDS: zpStr(z.string().optional()) }) .transform((data) => ({ ...data, diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index ad54a151a9..79b709ee60 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -82,3 +82,9 @@ export const publicSecretShareCreationLimit: RateLimitOptions = { max: 5, keyGenerator: (req) => req.realIp }; + +export const userEngagementLimit: RateLimitOptions = { + timeWindow: 60 * 1000, + max: 5, + keyGenerator: (req) => req.realIp +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e27d76af0c..bc8b153e03 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -164,6 +164,7 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; +import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; @@ -922,6 +923,10 @@ export const registerRoutes = async ( oidcConfigDAL }); + const userEngagementService = userEngagementServiceFactory({ + userDAL + }); + await superAdminService.initServerCfg(); // // setup the communication with license key server @@ -993,7 +998,8 @@ export const registerRoutes = async ( telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, - secretSharing: secretSharingService + secretSharing: secretSharingService, + userEngagement: userEngagementService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c2969b382e..d5c5acbcf8 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -25,6 +25,7 @@ import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; +import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; @@ -77,4 +78,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); + await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" }); }; diff --git a/backend/src/server/routes/v1/user-engagement-router.ts b/backend/src/server/routes/v1/user-engagement-router.ts new file mode 100644 index 0000000000..e3ce6532e1 --- /dev/null +++ b/backend/src/server/routes/v1/user-engagement-router.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { userEngagementLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserEngagementRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/wish", + config: { + rateLimit: userEngagementLimit + }, + schema: { + body: z.object({ + text: z.string().min(1) + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.userEngagement.createUserWish(req.permission.id, req.body.text); + } + }); +}; diff --git a/backend/src/services/user-engagement/user-engagement-service.ts b/backend/src/services/user-engagement/user-engagement-service.ts new file mode 100644 index 0000000000..5d7b549299 --- /dev/null +++ b/backend/src/services/user-engagement/user-engagement-service.ts @@ -0,0 +1,89 @@ +import { PlainClient } from "@team-plain/typescript-sdk"; + +import { getConfig } from "@app/lib/config/env"; +import { InternalServerError } from "@app/lib/errors"; + +import { TUserDALFactory } from "../user/user-dal"; + +type TUserEngagementServiceFactoryDep = { + userDAL: Pick; +}; + +export type TUserEngagementServiceFactory = ReturnType; + +export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => { + const createUserWish = async (userId: string, text: string) => { + const user = await userDAL.findById(userId); + const appCfg = getConfig(); + + if (!appCfg.PLAIN_API_KEY) { + throw new InternalServerError({ + message: "Plain is not configured." + }); + } + + const client = new PlainClient({ + apiKey: appCfg.PLAIN_API_KEY + }); + + const customerUpsertRes = await client.upsertCustomer({ + identifier: { + emailAddress: user.email + }, + onCreate: { + fullName: `${user.firstName} ${user.lastName}`, + shortName: user.firstName, + email: { + email: user.email as string, + isVerified: user.isEmailVerified as boolean + }, + + externalId: user.id + }, + + onUpdate: { + fullName: { + value: `${user.firstName} ${user.lastName}` + }, + shortName: { + value: user.firstName + }, + email: { + email: user.email as string, + isVerified: user.isEmailVerified as boolean + }, + externalId: { + value: user.id + } + } + }); + + if (customerUpsertRes.error) { + throw new InternalServerError({ message: customerUpsertRes.error.message }); + } + + const createThreadRes = await client.createThread({ + title: "Wish", + customerIdentifier: { + externalId: customerUpsertRes.data.customer.externalId + }, + components: [ + { + componentText: { + text + } + } + ], + labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",") + }); + + if (createThreadRes.error) { + throw new InternalServerError({ + message: createThreadRes.error.message + }); + } + }; + return { + createUserWish + }; +}; diff --git a/frontend/src/hooks/api/userEngagement/index.ts b/frontend/src/hooks/api/userEngagement/index.ts new file mode 100644 index 0000000000..5a4c29fa88 --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/index.ts @@ -0,0 +1 @@ +export { useCreateUserWish } from "./mutations"; diff --git a/frontend/src/hooks/api/userEngagement/mutations.tsx b/frontend/src/hooks/api/userEngagement/mutations.tsx new file mode 100644 index 0000000000..d876e65c8a --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/mutations.tsx @@ -0,0 +1,14 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCreateUserWishDto } from "./types"; + +export const useCreateUserWish = () => { + return useMutation<{}, {}, TCreateUserWishDto>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/user-engagement/me/wish", dto); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/userEngagement/types.ts b/frontend/src/hooks/api/userEngagement/types.ts new file mode 100644 index 0000000000..ad94d03d2e --- /dev/null +++ b/frontend/src/hooks/api/userEngagement/types.ts @@ -0,0 +1,3 @@ +export type TCreateUserWishDto = { + text: string; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index acf899a7ee..dc0ef370e0 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -8,7 +8,6 @@ import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; @@ -69,9 +68,7 @@ import { useGetAccessRequestsCount, useGetOrgTrialUrl, useGetSecretApprovalRequestCount, - useGetUserAction, useLogoutUser, - useRegisterUserAction, useSelectOrganization } from "@app/hooks/api"; import { Workspace } from "@app/hooks/api/types"; @@ -80,6 +77,8 @@ import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { CreateOrgModal } from "@app/views/Org/components"; +import { WishForm } from "./components/WishForm/WishForm"; + interface LayoutProps { children: React.ReactNode; } @@ -145,7 +144,6 @@ export const AppLayout = ({ children }: LayoutProps) => { const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; - const { data: updateClosed } = useGetUserAction("december_update_closed"); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId }); const { data: accessApprovalRequestCount } = useGetAccessRequestsCount({ projectSlug }); @@ -179,13 +177,8 @@ export const AppLayout = ({ children }: LayoutProps) => { const { t } = useTranslation(); - const registerUserAction = useRegisterUserAction(); const { mutateAsync: selectOrganization } = useSelectOrganization(); - const closeUpdate = async () => { - await registerUserAction.mutateAsync("december_update_closed"); - }; - const logout = useLogoutUser(); const logOutUser = async () => { try { @@ -765,49 +758,8 @@ export const AppLayout = ({ children }: LayoutProps) => { : "mb-4" } flex w-full cursor-default flex-col items-center px-3 text-sm text-mineshaft-400`} > - {/*
-
-
-
*/} -
-
- Infisical December update -
-
- Infisical Agent, new SDKs, Machine Identities, and more! -
-
- kubernetes image -
-
- - - Learn More{" "} - - -
-
+ {(window.location.origin.includes("https://app.infisical.com") || + window.location.origin.includes("https://gamma.infisical.com")) && } {router.asPath.includes("org") && (
null} diff --git a/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx new file mode 100644 index 0000000000..641230bf2a --- /dev/null +++ b/frontend/src/layouts/AppLayout/components/WishForm/WishForm.tsx @@ -0,0 +1,114 @@ +import { useForm } from "react-hook-form"; +import { faRocketchat } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Popover, + PopoverContent, + PopoverTrigger, + TextArea +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useCreateUserWish } from "@app/hooks/api/userEngagement"; + +const formSchema = z.object({ + text: z.string().trim().min(1) +}); + +type TFormData = z.infer; + +export const WishForm = () => { + const { + handleSubmit, + register, + reset, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema) + }); + const { mutateAsync } = useCreateUserWish(); + const [isOpen, setIsOpen] = useToggle(false); + + const createWish = async (data: TFormData) => { + try { + await mutateAsync({ + text: data.text + }); + + createNotification({ + text: "Your wish has been sent to the Infisical team!", + type: "success" + }); + + setIsOpen.off(); + } catch (err) { + createNotification({ + text: "An error occured while sending your wish to the Infisical team.", + type: "error" + }); + } + }; + + return ( + { + setIsOpen.toggle(); + reset(); + }} + open={isOpen} + > + +
+ + Make a wish +
+
+ +
+ +