From 956719f797d60f6cfd23b1a4a2a13d129ad165df Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Wed, 4 Sep 2024 23:06:30 +0800 Subject: [PATCH] feat: admin slack configuration --- backend/src/server/routes/index.ts | 10 +- backend/src/server/routes/v1/admin-router.ts | 81 ++++++++++++ backend/src/services/kms/kms-service.ts | 19 +++ backend/src/services/slack/slack-fns.ts | 70 +++++++++++ backend/src/services/slack/slack-service.ts | 30 ++++- .../super-admin/super-admin-service.ts | 63 +++++++++- .../services/super-admin/super-admin-types.ts | 5 + frontend/src/hooks/api/admin/index.ts | 14 ++- frontend/src/hooks/api/admin/mutation.ts | 24 +++- frontend/src/hooks/api/admin/queries.ts | 30 ++++- frontend/src/hooks/api/admin/types.ts | 10 ++ .../admin/DashboardPage/DashboardPage.tsx | 6 + .../admin/DashboardPage/IntegrationPanel.tsx | 118 ++++++++++++++++++ 13 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ede97b06d..f4c59ccdb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -182,6 +182,7 @@ import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/ import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service"; +import { adminSlackConfigDALFactory } from "@app/services/slack/admin-slack-config-dal"; import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; import { slackIntegrationDALFactory } from "@app/services/slack/slack-integration-dal"; import { slackServiceFactory } from "@app/services/slack/slack-service"; @@ -327,6 +328,7 @@ export const registerRoutes = async ( const slackIntegrationDAL = slackIntegrationDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db); + const adminSlackConfigDAL = adminSlackConfigDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -526,8 +528,11 @@ export const registerRoutes = async ( serverCfgDAL: superAdminDAL, orgService, keyStore, - licenseService + licenseService, + adminSlackConfigDAL, + kmsService }); + const orgAdminService = orgAdminServiceFactory({ projectDAL, permissionService, @@ -1162,7 +1167,8 @@ export const registerRoutes = async ( const slackService = slackServiceFactory({ permissionService, kmsService, - slackIntegrationDAL + slackIntegrationDAL, + adminSlackConfigDAL }); await superAdminService.initServerCfg(); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6e41df946..bbfe228be 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -123,6 +123,87 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/integrations/slack/bot-creation-url", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.string() + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const url = await server.services.superAdmin.getCustomSlackBotCreationUrl(); + + return url; + } + }); + + server.route({ + method: "GET", + url: "/integrations/slack/config", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const adminSlackConfig = await server.services.superAdmin.getAdminSlackConfig(); + + return adminSlackConfig; + } + }); + + server.route({ + method: "PUT", + url: "/integrations/slack/config", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + clientId: z.string(), + clientSecret: z.string() + }), + response: { + 200: z.object({ + clientId: z.string(), + clientSecret: z.string() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const adminSlackConfig = await server.services.superAdmin.updateAdminSlackConfig({ + ...req.body + }); + + return adminSlackConfig; + } + }); + server.route({ method: "DELETE", url: "/user-management/users/:userId", diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 351098f9d..b987f2e3b 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -208,6 +208,23 @@ export const kmsServiceFactory = ({ return org.kmsDefaultKeyId; }; + const encryptWithRootKey = async () => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + return ({ plainText }: { plainText: Buffer }) => { + const encryptedPlainTextBlob = cipher.encrypt(plainText, ROOT_ENCRYPTION_KEY); + + return Promise.resolve({ cipherTextBlob: encryptedPlainTextBlob }); + }; + }; + + const decryptWithRootKey = async () => { + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + return ({ cipherTextBlob }: { cipherTextBlob: Buffer }) => { + const decryptedBlob = cipher.decrypt(cipherTextBlob, ROOT_ENCRYPTION_KEY); + return Promise.resolve(decryptedBlob); + }; + }; + const decryptWithKmsKey = async ({ kmsId, depth = 0 @@ -808,6 +825,8 @@ export const kmsServiceFactory = ({ decryptWithKmsKey, encryptWithInputKey, decryptWithInputKey, + encryptWithRootKey, + decryptWithRootKey, getOrgKmsKeyId, getProjectSecretManagerKmsKeyId, updateProjectSecretManagerKmsKey, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index f12e72851..6eab272d2 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -1,13 +1,57 @@ import { Block, WebClient } from "@slack/web-api"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; +import { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; import { SlackTriggerFeature } from "./slack-types"; +const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; + +export const getCustomSlackBotManifest = () => { + const appCfg = getConfig(); + + return { + display_information: { + name: "Infisical", + description: "Get real-time Infisical updates in Slack", + background_color: "#c2d62b", + long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. + - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. + - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. + - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. + + By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` + }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: false, + messages_tab_read_only_enabled: true + }, + bot_user: { + display_name: "Infisical", + always_online: true + } + }, + oauth_config: { + redirect_urls: [`${appCfg.SITE_URL}/api/v1/workflow-integrations/slack/oauth_redirect`], + scopes: { + bot: ["chat:write.public", "chat:write", "channels:read", "groups:read", "im:read", "mpim:read"] + } + }, + settings: { + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false + } + }; +}; + export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { name: string; @@ -98,3 +142,29 @@ export const triggerSlackNotification = async ({ .catch((err) => void logger.error(err)); } }; + +export const getAdminSlackCredentials = async ({ + adminSlackConfigDAL, + kmsService +}: { + adminSlackConfigDAL: Pick; + kmsService: Pick; +}) => { + const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID); + let clientId = ""; + let clientSecret = ""; + const decrypt = await kmsService.decryptWithRootKey(); + + if (adminSlackConfig.encryptedClientId) { + clientId = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientId })).toString(); + } + + if (adminSlackConfig.encryptedClientSecret) { + clientSecret = (await decrypt({ cipherTextBlob: adminSlackConfig.encryptedClientSecret })).toString(); + } + + return { + clientId, + clientSecret + }; +}; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index f6ccf3c84..91433f5fc 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -8,7 +8,8 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; -import { fetchSlackChannels } from "./slack-fns"; +import { TAdminSlackConfigDALFactory } from "./admin-slack-config-dal"; +import { fetchSlackChannels, getAdminSlackCredentials } from "./slack-fns"; import { TSlackIntegrationDALFactory } from "./slack-integration-dal"; import { TCompleteSlackIntegrationDTO, @@ -25,7 +26,8 @@ import { type TSlackServiceFactoryDep = { slackIntegrationDAL: Pick; permissionService: Pick; - kmsService: Pick; + kmsService: Pick; + adminSlackConfigDAL: Pick; }; export type TSlackServiceFactory = ReturnType; @@ -33,7 +35,8 @@ export type TSlackServiceFactory = ReturnType; export const slackServiceFactory = ({ permissionService, slackIntegrationDAL, - kmsService + kmsService, + adminSlackConfigDAL }: TSlackServiceFactoryDep) => { const completeSlackIntegration = async ({ orgId, @@ -104,16 +107,31 @@ export const slackServiceFactory = ({ const getSlackInstaller = async () => { const appCfg = getConfig(); + const adminSlackCredentials = await getAdminSlackCredentials({ + kmsService, + adminSlackConfigDAL + }); - if (!appCfg.SLACK_CLIENT_ID || !appCfg.SLACK_CLIENT_SECRET) { + let slackClientId = ""; + let slackClientSecret = ""; + + if (adminSlackCredentials.clientId && adminSlackCredentials.clientSecret) { + slackClientId = adminSlackCredentials.clientId; + slackClientSecret = adminSlackCredentials.clientSecret; + } else { + slackClientId = appCfg.SLACK_CLIENT_ID as string; + slackClientSecret = appCfg.SLACK_CLIENT_SECRET as string; + } + + if (!slackClientId || !slackClientSecret) { throw new BadRequestError({ message: "Invalid slack configuration" }); } return new InstallProvider({ - clientId: appCfg.SLACK_CLIENT_ID, - clientSecret: appCfg.SLACK_CLIENT_SECRET, + clientId: slackClientId, + clientSecret: slackClientSecret, stateSecret: appCfg.AUTH_SECRET, legacyStateVerification: true, installationStore: { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index e7798500f..2ede9d415 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -10,15 +10,20 @@ import { BadRequestError } from "@app/lib/errors"; import { TAuthLoginFactory } from "../auth/auth-login-service"; import { AuthMethod } from "../auth/auth-type"; +import { TKmsServiceFactory } from "../kms/kms-service"; import { TOrgServiceFactory } from "../org/org-service"; +import { TAdminSlackConfigDALFactory } from "../slack/admin-slack-config-dal"; +import { getAdminSlackCredentials, getCustomSlackBotManifest } from "../slack/slack-fns"; import { TUserDALFactory } from "../user/user-dal"; import { TSuperAdminDALFactory } from "./super-admin-dal"; -import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types"; +import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO, TUpdateAdminSlackConfigDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; + adminSlackConfigDAL: Pick; authService: Pick; + kmsService: Pick; orgService: Pick; keyStore: Pick; licenseService: Pick; @@ -38,7 +43,9 @@ export const superAdminServiceFactory = ({ userDAL, authService, orgService, + adminSlackConfigDAL, keyStore, + kmsService, licenseService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { @@ -232,11 +239,63 @@ export const superAdminServiceFactory = ({ return user; }; + const getCustomSlackBotCreationUrl = async () => { + return `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( + JSON.stringify(getCustomSlackBotManifest()) + )}`; + }; + + const updateAdminSlackConfig = async ({ clientId, clientSecret }: TUpdateAdminSlackConfigDTO) => { + return adminSlackConfigDAL.transaction(async (tx) => { + const adminSlackConfig = await adminSlackConfigDAL.findById(ADMIN_CONFIG_DB_UUID, tx); + const encrypt = await kmsService.encryptWithRootKey(); + + const { cipherTextBlob: encryptedClientId } = await encrypt({ plainText: Buffer.from(clientId) }); + const { cipherTextBlob: encryptedClientSecret } = await encrypt({ plainText: Buffer.from(clientSecret) }); + + if (adminSlackConfig) { + await adminSlackConfigDAL.updateById( + ADMIN_CONFIG_DB_UUID, + { + encryptedClientId, + encryptedClientSecret + }, + tx + ); + } else { + await adminSlackConfigDAL.create( + { + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + id: ADMIN_CONFIG_DB_UUID, + encryptedClientId, + encryptedClientSecret + }, + tx + ); + } + + return { + clientId, + clientSecret + }; + }); + }; + + const getAdminSlackConfig = async () => { + return getAdminSlackCredentials({ + kmsService, + adminSlackConfigDAL + }); + }; + return { initServerCfg, updateServerCfg, adminSignUp, getUsers, - deleteUser + deleteUser, + getCustomSlackBotCreationUrl, + updateAdminSlackConfig, + getAdminSlackConfig }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index 2d10941b4..b194869ae 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -31,3 +31,8 @@ export enum LoginMethod { LDAP = "ldap", OIDC = "oidc" } + +export type TUpdateAdminSlackConfigDTO = { + clientId: string; + clientSecret: string; +}; diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index fc9fb2c24..6bd588d99 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,2 +1,12 @@ -export { useAdminDeleteUser, useCreateAdminUser, useUpdateServerConfig } from "./mutation"; -export { useAdminGetUsers, useGetServerConfig } from "./queries"; +export { + useAdminDeleteUser, + useCreateAdminUser, + useUpdateAdminSlackConfig, + useUpdateServerConfig +} from "./mutation"; +export { + useAdminGetUsers, + useGetAdminSlackConfig, + useGetCustomSlackAppCreationUrl, + useGetServerConfig +} from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b927a92b9..bd3f5fc70 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -5,7 +5,12 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; import { adminQueryKeys, adminStandaloneKeys } from "./queries"; -import { TCreateAdminUserDTO, TServerConfig } from "./types"; +import { + AdminSlackConfig, + TCreateAdminUserDTO, + TServerConfig, + TUpdateAdminSlackConfigDTO +} from "./types"; export const useCreateAdminUser = () => { const queryClient = useQueryClient(); @@ -59,3 +64,20 @@ export const useAdminDeleteUser = () => { } }); }; + +export const useUpdateAdminSlackConfig = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.put( + "/api/v1/admin/integrations/slack/config", + dto + ); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(adminQueryKeys.getAdminSlackConfig()); + } + }); +}; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 91368fb9e..fbcc6e946 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -3,7 +3,7 @@ import { useInfiniteQuery, useQuery, UseQueryOptions } from "@tanstack/react-que import { apiRequest } from "@app/config/request"; import { User } from "../types"; -import { AdminGetUsersFilters, TServerConfig } from "./types"; +import { AdminGetUsersFilters, AdminSlackConfig, TServerConfig } from "./types"; export const adminStandaloneKeys = { getUsers: "get-users" @@ -11,7 +11,9 @@ export const adminStandaloneKeys = { export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, - getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const + getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, + getCustomSlackAppCreationUrl: () => ["custom-slack-app-creation-url"] as const, + getAdminSlackConfig: () => ["admin-slack-config"] as const }; const fetchServerConfig = async () => { @@ -59,3 +61,27 @@ export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { lastPage.length !== 0 ? pages.length * filters.limit : undefined }); }; + +export const useGetCustomSlackAppCreationUrl = () => + useQuery({ + queryKey: adminQueryKeys.getCustomSlackAppCreationUrl(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/integrations/slack/bot-creation-url" + ); + + return data; + } + }); + +export const useGetAdminSlackConfig = () => + useQuery({ + queryKey: adminQueryKeys.getAdminSlackConfig(), + queryFn: async () => { + const { data } = await apiRequest.get( + "/api/v1/admin/integrations/slack/config" + ); + + return data; + } + }); diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index bfa2e3e36..cec0b614d 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -38,7 +38,17 @@ export type TCreateAdminUserDTO = { salt: string; }; +export type TUpdateAdminSlackConfigDTO = { + clientId: string; + clientSecret: string; +}; + export type AdminGetUsersFilters = { limit: number; searchTerm: string; }; + +export type AdminSlackConfig = { + clientId: string; + clientSecret: string; +}; diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index 715bb2e47..2bd0856f0 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -25,6 +25,7 @@ import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; import { AuthPanel } from "./AuthPanel"; +import { IntegrationPanel } from "./IntegrationPanel"; import { RateLimitPanel } from "./RateLimitPanel"; import { UserPanel } from "./UserPanel"; @@ -32,6 +33,7 @@ enum TabSections { Settings = "settings", Auth = "auth", RateLimit = "rate-limit", + Integrations = "integrations", Users = "users" } @@ -137,6 +139,7 @@ export const AdminDashboardPage = () => { General Authentication Rate Limit + Integrations Users @@ -323,6 +326,9 @@ export const AdminDashboardPage = () => { + + + diff --git a/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx new file mode 100644 index 000000000..4531c1c4b --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/IntegrationPanel.tsx @@ -0,0 +1,118 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { + useGetAdminSlackConfig, + useGetCustomSlackAppCreationUrl, + useUpdateAdminSlackConfig +} from "@app/hooks/api"; + +const slackFormSchema = z.object({ + clientId: z.string(), + clientSecret: z.string() +}); + +type TSlackForm = z.infer; + +export const IntegrationPanel = () => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const { data: customSlackAppCreationUrl } = useGetCustomSlackAppCreationUrl(); + const { data: adminSlackConfig } = useGetAdminSlackConfig(); + + const { mutateAsync: updateAdminSlackConfig } = useUpdateAdminSlackConfig(); + + useEffect(() => { + if (adminSlackConfig) { + setValue("clientId", adminSlackConfig.clientId); + setValue("clientSecret", adminSlackConfig.clientSecret); + } + }, [adminSlackConfig]); + + const onSlackFormSubmit = async (data: TSlackForm) => { + await updateAdminSlackConfig(data); + + createNotification({ + text: "Updated admin slack configuration", + type: "success" + }); + }; + + return ( +
+
+
Slack Integration
+
+ Step 1: Create your Infisical Slack App +
+
+ +
+
+ Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the + values from the App Credentials page of your custom Slack App. +
+ ( + + field.onChange(e.target.value)} + /> + + )} + /> + ( + + field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+ ); +};