Merge pull request #2074 from Infisical/feat/make-a-wish-feature

feat: make a wish feature
This commit is contained in:
Maidul Islam
2024-07-05 12:09:53 -04:00
committed by GitHub
15 changed files with 305 additions and 54 deletions

View File

@@ -67,3 +67,6 @@ CLIENT_SECRET_GITLAB_LOGIN=
CAPTCHA_SECRET=
NEXT_PUBLIC_CAPTCHA_SITE_KEY=
PLAIN_API_KEY=
PLAIN_WISH_LABEL_IDS=

View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View File

@@ -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
};

View File

@@ -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[] = [];

View File

@@ -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" });
};

View File

@@ -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);
}
});
};

View File

@@ -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<TUserDALFactory, "findById">;
};
export type TUserEngagementServiceFactory = ReturnType<typeof userEngagementServiceFactory>;
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
};
};

View File

@@ -0,0 +1 @@
export { useCreateUserWish } from "./mutations";

View File

@@ -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;
}
});
};

View File

@@ -0,0 +1,3 @@
export type TCreateUserWishDto = {
text: string;
};

View File

@@ -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`}
>
{/* <div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[9.9rem] ${router.asPath.includes("org") ? "bottom-[8.4rem]" : "bottom-[5.4rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-30`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[10.7rem] ${router.asPath.includes("org") ? "bottom-[8.15rem]" : "bottom-[5.15rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-50`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[11.5rem] ${router.asPath.includes("org") ? "bottom-[7.9rem]" : "bottom-[4.9rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-70`}/>
<div className={`${isLearningNoteOpen ? "block" : "hidden"} z-0 absolute h-60 w-[12.3rem] ${router.asPath.includes("org") ? "bottom-[7.65rem]" : "bottom-[4.65rem]"} bg-mineshaft-900 border border-mineshaft-600 mb-4 rounded-md opacity-90`}/> */}
<div
className={`${
!updateClosed ? "block" : "hidden"
} relative z-10 mb-6 flex h-64 w-52 flex-col items-center justify-start rounded-md border border-mineshaft-600 bg-mineshaft-900 px-3`}
>
<div className="text-md mt-2 w-full font-semibold text-mineshaft-100">
Infisical December update
</div>
<div className="mt-1 mb-1 w-full text-sm font-normal leading-[1.2rem] text-mineshaft-300">
Infisical Agent, new SDKs, Machine Identities, and more!
</div>
<div className="mt-2 h-[6.77rem] w-full rounded-md border border-mineshaft-700">
<Image
src="/images/infisical-update-december-2023.png"
height={319}
width={539}
alt="kubernetes image"
className="rounded-sm"
/>
</div>
<div className="mt-3 flex w-full items-center justify-between px-0.5">
<button
type="button"
onClick={() => closeUpdate()}
className="text-mineshaft-400 duration-200 hover:text-mineshaft-100"
>
Close
</button>
<a
href="https://infisical.com/blog/infisical-update-december-2023"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-normal leading-[1.2rem] text-mineshaft-400 duration-200 hover:text-mineshaft-100"
>
Learn More{" "}
<FontAwesomeIcon icon={faArrowUpRightFromSquare} className="pl-0.5 text-xs" />
</a>
</div>
</div>
{(window.location.origin.includes("https://app.infisical.com") ||
window.location.origin.includes("https://gamma.infisical.com")) && <WishForm />}
{router.asPath.includes("org") && (
<div
onKeyDown={() => null}

View File

@@ -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<typeof formSchema>;
export const WishForm = () => {
const {
handleSubmit,
register,
reset,
formState: { isSubmitting, errors }
} = useForm<TFormData>({
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 (
<Popover
onOpenChange={() => {
setIsOpen.toggle();
reset();
}}
open={isOpen}
>
<PopoverTrigger asChild>
<div className="text-md mb-3 w-full pl-5 duration-200 hover:text-mineshaft-200">
<FontAwesomeIcon icon={faRocketchat} className="mr-2" />
Make a wish
</div>
</PopoverTrigger>
<PopoverContent
hideCloseBtn
align="start"
alignOffset={25}
className="mb-1 w-auto border border-mineshaft-600 bg-mineshaft-900 p-4 drop-shadow-2xl"
sticky="always"
>
<form onSubmit={handleSubmit(createWish)}>
<FormControl
className="mb-0"
isError={Boolean(errors?.text)}
errorText={errors?.text?.message}
>
<TextArea
className="border border-mineshaft-600 text-sm focus:ring-0"
variant="plain"
placeholder="Wish for anything! Help us improve the platform"
reSize="none"
rows={6}
cols={40}
{...register("text")}
/>
</FormControl>
<div className="mt-2 flex justify-between border-t border-mineshaft-500 pt-4">
<PopoverPrimitive.Close asChild>
<Button className="mr-2 w-min" colorSchema="secondary">
Cancel
</Button>
</PopoverPrimitive.Close>
<Button
className="w-min"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
>
Send
</Button>
</div>
</form>
</PopoverContent>
</Popover>
);
};