From 54fcc23a6c82abe465ab0d0f04a1a52760691781 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Fri, 19 Apr 2024 16:16:16 -0700 Subject: [PATCH 001/560] Begin groups phase 2b --- .../20240419200953_email-confirmation.ts | 15 ++ backend/src/db/schemas/users.ts | 3 +- .../services/license/__mocks__/licence-fns.ts | 4 +- .../src/ee/services/license/licence-fns.ts | 4 +- .../src/ee/services/license/license-types.ts | 4 +- backend/src/ee/services/scim/scim-service.ts | 24 ++- backend/src/server/routes/index.ts | 2 +- backend/src/server/routes/v2/user-router.ts | 63 +++++++- .../services/auth-token/auth-token-service.ts | 6 + .../services/auth-token/auth-token-types.ts | 1 + .../src/services/auth/auth-signup-service.ts | 2 +- backend/src/services/smtp/smtp-service.ts | 1 + .../templates/emailVerification.handlebars | 16 +- .../signupEmailVerification.handlebars | 17 ++ backend/src/services/user/user-service.ts | 73 ++++++++- frontend/src/hooks/api/auth/index.tsx | 5 +- frontend/src/hooks/api/auth/queries.tsx | 2 +- frontend/src/hooks/api/users/index.tsx | 7 +- frontend/src/hooks/api/users/mutation.tsx | 20 +++ frontend/src/pages/signup/index.tsx | 4 +- frontend/src/views/Signup/SignupSSO.tsx | 4 +- .../EmailConfirmationStep.tsx | 149 ++++++++++++++++++ .../EmailConfirmationStep/index.tsx | 1 + .../src/views/Signup/components/index.tsx | 1 + 24 files changed, 394 insertions(+), 34 deletions(-) create mode 100644 backend/src/db/migrations/20240419200953_email-confirmation.ts create mode 100644 backend/src/services/smtp/templates/signupEmailVerification.handlebars create mode 100644 frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx create mode 100644 frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx diff --git a/backend/src/db/migrations/20240419200953_email-confirmation.ts b/backend/src/db/migrations/20240419200953_email-confirmation.ts new file mode 100644 index 000000000..59d7b3d41 --- /dev/null +++ b/backend/src/db/migrations/20240419200953_email-confirmation.ts @@ -0,0 +1,15 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Users, (t) => { + t.boolean("isEmailVerified"); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Users, (t) => { + t.dropColumn("isEmailVerified"); + }); +} diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 86ee2fb74..3eee2683f 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -21,7 +21,8 @@ export const UsersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), isGhost: z.boolean().default(false), - username: z.string() + username: z.string(), + isEmailVerified: z.boolean().nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index b5cbf103e..20186718d 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -17,8 +17,8 @@ export const getDefaultOnPremFeatures = () => { customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: false, - scim: false, + samlSSO: true, + scim: true, ldap: false, groups: false, status: null, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1..9179fde32 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -24,8 +24,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ customAlerts: false, auditLogs: false, auditLogsRetentionDays: 0, - samlSSO: false, - scim: false, + samlSSO: true, + scim: true, ldap: false, groups: false, status: null, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 1cea39a83..2cc321373 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -40,8 +40,8 @@ export type TFeatureSet = { customAlerts: false; auditLogs: false; auditLogsRetentionDays: 0; - samlSSO: false; - scim: false; + samlSSO: true; + scim: true; ldap: false; groups: false; status: null; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 15ca67a10..120b41bd1 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -57,6 +57,8 @@ type TScimServiceFactoryDep = { export type TScimServiceFactory = ReturnType; +// TODO: finish updating all userId refs to orgMembershipId + export const scimServiceFactory = ({ licenseService, scimDAL, @@ -145,6 +147,7 @@ export const scimServiceFactory = ({ // SCIM server endpoints const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise => { + console.log("listScimUsers"); // done const org = await orgDAL.findById(orgId); if (!org.scimEnabled) @@ -178,9 +181,11 @@ export const scimServiceFactory = ({ findOpts ); - const scimUsers = users.map(({ userId, username, firstName, lastName, email }) => + console.log("orgDAL.findMembership users: ", users); + + const scimUsers = users.map(({ id, username, firstName, lastName, email }) => buildScimUser({ - userId: userId ?? "", + userId: id ?? "", username, firstName: firstName ?? "", lastName: lastName ?? "", @@ -197,6 +202,7 @@ export const scimServiceFactory = ({ }; const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => { + console.log("getScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -221,8 +227,10 @@ export const scimServiceFactory = ({ status: 403 }); + console.log("getScimUser membership: ", membership); + return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email ?? "", firstName: membership.firstName as string, @@ -232,6 +240,7 @@ export const scimServiceFactory = ({ }; const createScimUser = async ({ username, email, firstName, lastName, orgId }: TCreateScimUserDTO) => { + console.log("createScimUser"); // TODO: update implementation to always create a new user and be based on orgMembershipId const org = await orgDAL.findById(orgId); if (!org) @@ -331,6 +340,7 @@ export const scimServiceFactory = ({ }; const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => { + console.log("updateScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -380,7 +390,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -390,6 +400,7 @@ export const scimServiceFactory = ({ }; const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => { + console.log("replaceScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -426,7 +437,7 @@ export const scimServiceFactory = ({ } return buildScimUser({ - userId: membership.userId as string, + userId: membership.id, username: membership.username, email: membership.email, firstName: membership.firstName as string, @@ -436,6 +447,7 @@ export const scimServiceFactory = ({ }; const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { + console.log("deleteScimUser"); // done const [membership] = await orgDAL .findMembership({ userId, @@ -489,7 +501,7 @@ export const scimServiceFactory = ({ buildScimGroup({ groupId: group.id, name: group.name, - members: [] + members: [] // does this need to be populated? }) ); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5d77c340b..893f36770 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -315,7 +315,7 @@ export const registerRoutes = async ( }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL }); + const userService = userServiceFactory({ userDAL, tokenService, smtpService }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index d1e80702f..dd49f6d5e 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,11 +2,72 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/me/emails/code", + config: { + rateLimit: authRateLimit + }, + schema: { + response: { + 200: z.object({}) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.user.sendEmailVerificationCode(req.permission.id); + return {}; + } + }); + + server.route({ + method: "POST", + url: "/me/emails/verify", + config: { + rateLimit: authRateLimit + }, + schema: { + body: z.object({ + code: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.user.verifyEmailVerificationCode(req.permission.id, req.body.code); + return {}; + } + }); + + server.route({ + method: "GET", + url: "/me/users/same-email", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + users: UsersSchema.array() + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const users = await server.services.user.listUsersWithSameEmail(req.permission.id); + return { + users + }; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 59f336e5a..bd35f3ae1 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -27,6 +27,12 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_EMAIL_VERIFICATION: { + // generate random 6-digit code + const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const expiresAt = new Date(new Date().getTime() + 86400000); + return { token, expiresAt }; + } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 74787f4ac..630e36310 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -1,5 +1,6 @@ export enum TokenType { TOKEN_EMAIL_CONFIRMATION = "emailConfirmation", + TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 3db935769..b997e2293 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -60,7 +60,7 @@ export const authSignupServiceFactory = ({ }); await smtpService.sendMail({ - template: SmtpTemplates.EmailVerification, + template: SmtpTemplates.SignupEmailVerification, subjectLine: "Infisical confirmation code", recipients: [email], substitutions: { diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7ebeaa227..0b43ffb90 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -17,6 +17,7 @@ export type TSmtpSendMail = { export type TSmtpService = ReturnType; export enum SmtpTemplates { + SignupEmailVerification = "signupEmailVerification.handlebars", EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", diff --git a/backend/src/services/smtp/templates/emailVerification.handlebars b/backend/src/services/smtp/templates/emailVerification.handlebars index fc738d202..ad9694d5c 100644 --- a/backend/src/services/smtp/templates/emailVerification.handlebars +++ b/backend/src/services/smtp/templates/emailVerification.handlebars @@ -1,17 +1,15 @@ - - - - + + + Code - + - +

Confirm your email address

-

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

Your confirmation code is below — enter it in the browser window where you've started confirming your email.

{{code}}

-

Questions about setting up Infisical? Email us at support@infisical.com

- + \ No newline at end of file diff --git a/backend/src/services/smtp/templates/signupEmailVerification.handlebars b/backend/src/services/smtp/templates/signupEmailVerification.handlebars new file mode 100644 index 000000000..fc738d202 --- /dev/null +++ b/backend/src/services/smtp/templates/signupEmailVerification.handlebars @@ -0,0 +1,17 @@ + + + + + + + Code + + + +

Confirm your email address

+

Your confirmation code is below — enter it in the browser window where you've started signing up for Infisical.

+

{{code}}

+

Questions about setting up Infisical? Email us at support@infisical.com

+ + + \ No newline at end of file diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index c85e40eb3..453848bf7 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,15 +1,83 @@ import { BadRequestError } from "@app/lib/errors"; +import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { userDAL: TUserDALFactory; + tokenService: TAuthTokenServiceFactory; + smtpService: TSmtpService; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { + const sendEmailVerificationCode = async (userId: string) => { + console.log("sendEmailVerificationCode userId: ", userId); + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); + + console.log("sendEmailVerificationCode user: ", user); + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id + }); + + console.log("sendEmailVerificationCode 2"); + await smtpService.sendMail({ + template: SmtpTemplates.EmailVerification, + subjectLine: "Infisical confirmation code", + recipients: [user.email], + substitutions: { + code: token + } + }); + }; + + const verifyEmailVerificationCode = async (userId: string, code: string) => { + console.log("verifyEmailVerificationCode args: ", { + userId, + code + }); + + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (user.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_VERIFICATION, + userId: user.id, + code + }); + + await userDAL.updateById(userId, { isEmailVerified: true }); + }; + + // lists users with same verified email only + const listUsersWithSameEmail = async (userId: string) => { + const user = await userDAL.findById(userId); + if (!user) throw new BadRequestError({ name: "Failed to find user" }); + if (!user.email) + throw new BadRequestError({ name: "Failed to list users with same email due to no email on user" }); + if (!user.isEmailVerified) + throw new BadRequestError({ name: "Failed to list users with same email due to email not verified" }); + + const users = await userDAL.find({ + email: user.email, + isEmailVerified: true + }); + + return users; + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -72,6 +140,9 @@ export const userServiceFactory = ({ userDAL }: TUserServiceFactoryDep) => { }; return { + sendEmailVerificationCode, + verifyEmailVerificationCode, + listUsersWithSameEmail, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/frontend/src/hooks/api/auth/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 8b918c7ab..505f7b05f 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -5,7 +5,6 @@ export { useSendMfaToken, useSendPasswordResetEmail, useSendVerificationEmail, - useVerifyEmailVerificationCode, useVerifyMfaToken, - useVerifyPasswordResetCode -} from "./queries"; + useVerifyPasswordResetCode, + useVerifySignupEmailVerificationCode} from "./queries"; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 4d05fb963..20209df71 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -164,7 +164,7 @@ export const useSendVerificationEmail = () => { }); }; -export const useVerifyEmailVerificationCode = () => { +export const useVerifySignupEmailVerificationCode = () => { return useMutation({ mutationFn: async ({ email, code }: { email: string; code: string }) => { const { data } = await apiRequest.post("/api/v3/signup/email/verify", { diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 9c51948f2..a8ad89f4c 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,4 +1,9 @@ -export { useAddUserToWsE2EE, useAddUserToWsNonE2EE } from "./mutation"; +export { + useAddUserToWsE2EE, + useAddUserToWsNonE2EE, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode +} from "./mutation"; export { fetchOrgUsers, useAddUserToOrg, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index a5c77b15f..e1e939105 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -61,3 +61,23 @@ export const useAddUserToWsNonE2EE = () => { } }); }; + +export const useSendEmailVerificationCode = () => { + return useMutation({ + mutationFn: async () => { + await apiRequest.post("/api/v2/users/me/emails/code"); + return {}; + } + }); +}; + +export const useVerifyEmailVerificationCode = () => { + return useMutation({ + mutationFn: async ({ code }: { code: string }) => { + await apiRequest.post("/api/v2/users/me/emails/verify", { + code + }); + return {}; + } + }); +}; diff --git a/frontend/src/pages/signup/index.tsx b/frontend/src/pages/signup/index.tsx index 17316e1e6..0719111d6 100644 --- a/frontend/src/pages/signup/index.tsx +++ b/frontend/src/pages/signup/index.tsx @@ -13,7 +13,7 @@ import TeamInviteStep from "@app/components/signup/TeamInviteStep"; import UserInfoStep from "@app/components/signup/UserInfoStep"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { useServerConfig } from "@app/context"; -import { useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { useVerifySignupEmailVerificationCode } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -34,7 +34,7 @@ export default function SignUp() { const [isSignupWithEmail, setIsSignupWithEmail] = useState(false); const [isCodeInputCheckLoading, setIsCodeInputCheckLoading] = useState(false); const { t } = useTranslation(); - const { mutateAsync } = useVerifyEmailVerificationCode(); + const { mutateAsync } = useVerifySignupEmailVerificationCode(); const { config } = useServerConfig(); useEffect(() => { diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index 141865026..d74da3639 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, UserInfoSSOStep } from "./components"; +import { BackupPDFStep, EmailConfirmationStep,UserInfoSSOStep } from "./components"; type Props = { providerAuthToken: string; @@ -28,6 +28,8 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { /> ); case 1: + return ; + case 2: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx new file mode 100644 index 000000000..e9b0a6e84 --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -0,0 +1,149 @@ +// confirm email +// if same email exists, then trigger fn to merge automatically +import { useState } from "react"; +import ReactCodeInput from "react-code-input"; + +// import Error from "@app/components/basic/Error"; +import { createNotification } from "@app/components/notifications"; +import { Button } from "@app/components/v2"; +import { useUser } from "@app/context"; +import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; + +// The style for the verification code input +const props = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "55px", + borderRadius: "5px", + fontSize: "24px", + height: "55px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; +const propsPhone = { + inputStyle: { + fontFamily: "monospace", + margin: "4px", + MozAppearance: "textfield", + width: "40px", + borderRadius: "5px", + fontSize: "24px", + height: "40px", + paddingLeft: "7", + backgroundColor: "#0d1117", + color: "white", + border: "1px solid #2d2f33", + textAlign: "center", + outlineColor: "#8ca542", + borderColor: "#2d2f33" + } +} as const; + +export const EmailConfirmationStep = () => { + const { user } = useUser(); + const [code, setCode] = useState(""); + // const [codeError, setCodeError] = useState(false); + const [isResendingVerificationEmail] = useState(false); + const [isLoading] = useState(false); + + const { mutateAsync: sendEmailVerificationCode } = useSendEmailVerificationCode(); + const { mutateAsync: verifyEmailVerificationCode } = useVerifyEmailVerificationCode(); + + const checkCode = async () => { + try { + console.log("checkCode code: ", code); + await verifyEmailVerificationCode({ code }); + console.log("checkCode 2"); + } catch (err) { + createNotification({ + text: "Failed to verify code", + type: "error" + }); + } + }; + + const resendCode = async () => { + try { + console.log("resendCode"); + await sendEmailVerificationCode(); + console.log("resendCode"); + } catch (err) { + createNotification({ + text: "Failed to resend code", + type: "error" + }); + } + }; + + return ( +
+

+ We've sent a verification code to +

+

+ {user?.email} +

+
+ +
+
+ +
+ {/* {codeError && } */} +
+
+ +
+
+
+
+ Don't see the code? +
+ +
+
+

Make sure to check your spam inbox.

+
+
+ ); +}; diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx new file mode 100644 index 000000000..32f3a636e --- /dev/null +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/index.tsx @@ -0,0 +1 @@ +export { EmailConfirmationStep } from "./EmailConfirmationStep"; diff --git a/frontend/src/views/Signup/components/index.tsx b/frontend/src/views/Signup/components/index.tsx index a4628de35..7ab3d853c 100644 --- a/frontend/src/views/Signup/components/index.tsx +++ b/frontend/src/views/Signup/components/index.tsx @@ -1,2 +1,3 @@ export { BackupPDFStep } from "./BackupPDFStep"; +export { EmailConfirmationStep } from "./EmailConfirmationStep"; export { UserInfoSSOStep } from "./UserInfoSSOStep"; From c88923e0c6cab7b04513db878f3a5756e3914df8 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Mon, 22 Apr 2024 17:59:21 +0000 Subject: [PATCH 002/560] fix: backend/package.json to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-MYSQL2-6670046 --- backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index 0f7b5a590..06f6241bc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -109,7 +109,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.4", + "mysql2": "^3.9.7", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", From a85c59e3e2ac8d13b66573b68fd19b0e407a4644 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 22 Apr 2024 22:00:37 +0200 Subject: [PATCH 003/560] Fix: Improve user experience for machine identities --- .../IdentitySection/IdentityTable.tsx | 132 +++++++++++------- 1 file changed, 82 insertions(+), 50 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 804c2d054..ac1cf31c3 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -1,9 +1,22 @@ -import { faKey, faLock, faPencil, faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCopy, + faEllipsis, + faKey, + faLock, + faPencil, + faServer, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, IconButton, Select, @@ -44,7 +57,6 @@ type Props = { }; export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -83,7 +95,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { Name - ID Role Auth Method @@ -98,7 +109,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { return ( {name} - {id} { {authMethod ? identityAuthToNameMap[authMethod] : "Not configured"} -
+
{authMethod === IdentityAuthMethod.UNIVERSAL_AUTH && ( { colorSchema="primary" variant="plain" ariaLabel="update" - // isDisabled={!isAllowed} > @@ -168,7 +177,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { colorSchema="primary" variant="plain" ariaLabel="update" - className="ml-4" isDisabled={!isAllowed} > @@ -176,54 +184,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { )} - - {(isAllowed) => ( - { - handlePopUpOpen("identity", { - identityId: id, - name, - role, - customRole - }); - }} - size="lg" - colorSchema="primary" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + + + +
+ +
+
+
+ + - -
- )} -
- - {(isAllowed) => ( - ( + { + if (!isAllowed) return; + handlePopUpOpen("identity", { + identityId: id, + name, + role, + customRole + }); + }} + disabled={!isAllowed} + icon={} + > + Update identity + + )} + + + {(isAllowed) => ( + { + if (!isAllowed) return; + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + icon={} + > + Delete identity + + )} + + { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name + navigator.clipboard.writeText(id); + createNotification({ + text: "Copied identity internal ID to clipboard", + type: "success" }); }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + icon={} > - - - )} - + Copy internal ID + + +
From fdf5fcad0a26eb51ca6221044b12baf951b0be84 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Mon, 22 Apr 2024 23:09:46 +0200 Subject: [PATCH 004/560] Update IdentityTable.tsx --- .../components/IdentitySection/IdentityTable.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index ac1cf31c3..f621e3e18 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -185,13 +185,13 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { )} - - -
+ +
+ -
-
- + +
+
Date: Wed, 24 Apr 2024 18:45:40 +0530 Subject: [PATCH 005/560] feat(server): dynamic secret aws iam implemented --- .../dynamic-secret/providers/aws-iam.ts | 194 ++++++++++++++++++ .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 47 +++-- .../src/ee/services/license/licence-fns.ts | 2 +- 4 files changed, 228 insertions(+), 19 deletions(-) create mode 100644 backend/src/ee/services/dynamic-secret/providers/aws-iam.ts diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts new file mode 100644 index 000000000..3feafa534 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-iam.ts @@ -0,0 +1,194 @@ +import { + AddUserToGroupCommand, + AttachUserPolicyCommand, + CreateAccessKeyCommand, + CreateUserCommand, + DeleteAccessKeyCommand, + DeleteUserCommand, + DeleteUserPolicyCommand, + DetachUserPolicyCommand, + GetUserCommand, + IAMClient, + ListAccessKeysCommand, + ListAttachedUserPoliciesCommand, + ListGroupsForUserCommand, + ListUserPoliciesCommand, + PutUserPolicyCommand, + RemoveUserFromGroupCommand +} from "@aws-sdk/client-iam"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models"; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const AwsIamProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAwsIamSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = new IAMClient({ + region: providerInputs.region, + credentials: { + accessKeyId: providerInputs.accessKey, + secretAccessKey: providerInputs.secretAccessKey + } + }); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client.send(new GetUserCommand({})).then(() => true); + return isConnected; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs; + const createUserRes = await client.send( + new CreateUserCommand({ + Path: awsPath, + PermissionsBoundary: permissionBoundaryPolicyArn || undefined, + Tags: [{ Key: "createdBy", Value: "infisical-dynamic-secret" }], + UserName: username + }) + ); + if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" }); + if (userGroups) { + await Promise.all( + userGroups + .split(",") + .filter(Boolean) + .map((group) => + client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group })) + ) + ); + } + if (policyArns) { + await Promise.all( + policyArns + .split(",") + .filter(Boolean) + .map((policyArn) => + client.send(new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn })) + ) + ); + } + if (policyDocument) { + await client.send( + new PutUserPolicyCommand({ + UserName: createUserRes.User.UserName, + PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`, + PolicyDocument: policyDocument + }) + ); + } + + const createAccessKeyRes = await client.send( + new CreateAccessKeyCommand({ + UserName: createUserRes.User.UserName + }) + ); + if (!createAccessKeyRes.AccessKey) + throw new BadRequestError({ message: "Failed to create AWS IAM User access key" }); + + return { + entityId: username, + data: { + ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId, + SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey, + USERNAME: username + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + + // remove user from groups + const userGroups = await client.send(new ListGroupsForUserCommand({ UserName: username })); + await Promise.all( + (userGroups.Groups || []).map(({ GroupName }) => + client.send( + new RemoveUserFromGroupCommand({ + GroupName, + UserName: username + }) + ) + ) + ); + + // remove user access keys + const userAccessKeys = await client.send(new ListAccessKeysCommand({ UserName: username })); + await Promise.all( + (userAccessKeys.AccessKeyMetadata || []).map(({ AccessKeyId }) => + client.send( + new DeleteAccessKeyCommand({ + AccessKeyId, + UserName: username + }) + ) + ) + ); + + // remove user inline policies + const userInlinePolicies = await client.send(new ListUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userInlinePolicies.PolicyNames || []).map((policyName) => + client.send( + new DeleteUserPolicyCommand({ + PolicyName: policyName, + UserName: username + }) + ) + ) + ); + + // remove user attached policies + const userAttachedPolicies = await client.send(new ListAttachedUserPoliciesCommand({ UserName: username })); + await Promise.all( + (userAttachedPolicies.AttachedPolicies || []).map((policy) => + client.send( + new DetachUserPolicyCommand({ + PolicyArn: policy.PolicyArn, + UserName: username + }) + ) + ) + ); + + await client.send(new DeleteUserCommand({ UserName: username })); + return { entityId: username }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // do nothing + const username = entityId; + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 34c049553..beb6c428e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,8 +1,10 @@ +import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), - [DynamicSecretProviders.Cassandra]: CassandraProvider() + [DynamicSecretProviders.Cassandra]: CassandraProvider(), + [DynamicSecretProviders.AwsIam]: AwsIamProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index edb60d4b2..c11f6ddfb 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -8,38 +8,51 @@ export enum SqlProviders { export const DynamicSecretSqlDBSchema = z.object({ client: z.nativeEnum(SqlProviders), - host: z.string().toLowerCase(), + host: z.string().trim().toLowerCase(), port: z.number(), - database: z.string(), - username: z.string(), - password: z.string(), - creationStatement: z.string(), - revocationStatement: z.string(), - renewStatement: z.string().optional(), + database: z.string().trim(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), ca: z.string().optional() }); export const DynamicSecretCassandraSchema = z.object({ - host: z.string().toLowerCase(), + host: z.string().trim().toLowerCase(), port: z.number(), - localDataCenter: z.string().min(1), - keyspace: z.string().optional(), - username: z.string(), - password: z.string(), - creationStatement: z.string(), - revocationStatement: z.string(), - renewStatement: z.string().optional(), + localDataCenter: z.string().trim().min(1), + keyspace: z.string().trim().optional(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), ca: z.string().optional() }); +export const DynamicSecretAwsIamSchema = z.object({ + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", - Cassandra = "cassandra" + Cassandra = "cassandra", + AwsIam = "aws-iam" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 8a4de57f1..de9a73c4e 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -15,7 +15,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ membersUsed: 0, environmentLimit: null, environmentsUsed: 0, - dynamicSecret: false, + dynamicSecret: true, secretVersioning: true, pitRecovery: false, ipAllowlisting: false, From 1a2508d91a5b0572ed380af31b6aa9c7f433f7b3 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 24 Apr 2024 18:46:01 +0530 Subject: [PATCH 006/560] feat(ui): dynamic secret aws iam ui implemented --- frontend/src/hooks/api/dynamicSecret/types.ts | 15 +- .../AwsIamInputForm.tsx | 303 +++++++++++++++++ .../CreateDynamicSecretForm.tsx | 25 ++ .../CreateDynamicSecretLease.tsx | 21 +- .../EditDynamicSecretAwsIamForm.tsx | 313 ++++++++++++++++++ .../EditDynamicSecretForm.tsx | 18 + 6 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretAwsIamForm.tsx diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 27c4c5ddf..a9aab8318 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -17,7 +17,8 @@ export type TDynamicSecret = { export enum DynamicSecretProviders { SqlDatabase = "sql-database", - Cassandra = "cassandra" + Cassandra = "cassandra", + AwsIam = "aws-iam" } export enum SqlProviders { @@ -56,6 +57,18 @@ export type TDynamicSecretProvider = renewStatement?: string; ca?: string | undefined; }; + } + | { + type: DynamicSecretProviders.AwsIam; + inputs: { + accessKey: string; + secretAccessKey: string; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx new file mode 100644 index 000000000..d1ff003c3 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsIamInputForm.tsx @@ -0,0 +1,303 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, TextArea } from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + accessKey: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + region: z.string().trim().min(1), + awsPath: z.string().trim().optional(), + permissionBoundaryPolicyArn: z.string().trim().optional(), + policyDocument: z.string().trim().optional(), + userGroups: z.string().trim().optional(), + policyArns: z.string().trim().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const AwsIamInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.AwsIam, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + +