diff --git a/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx
index c11b01cbca6..c9acec0bad3 100644
--- a/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx
+++ b/packages/twenty-emails/src/emails/send-email-verification-link.email.tsx
@@ -9,22 +9,33 @@ import { type APP_LOCALES } from 'twenty-shared/translations';
type SendEmailVerificationLinkEmailProps = {
link: string;
locale: keyof typeof APP_LOCALES;
+ isEmailUpdate?: boolean;
};
export const SendEmailVerificationLinkEmail = ({
link,
locale,
+ isEmailUpdate = false,
}: SendEmailVerificationLinkEmailProps) => {
const i18n = createI18nInstance(locale);
+ const title = isEmailUpdate
+ ? i18n._('Confirm your new email address')
+ : i18n._('Confirm your email address');
+ const bodyId = isEmailUpdate
+ ? 'We received a request to change the email address associated with your Twenty account. Click below to confirm this change.'
+ : 'Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address.';
+ const ctaLabel = isEmailUpdate
+ ? i18n._('Confirm new email')
+ : i18n._('Verify Email');
return (
-
+
-
+
-
+
@@ -34,6 +45,7 @@ export const SendEmailVerificationLinkEmail = ({
SendEmailVerificationLinkEmail.PreviewProps = {
link: 'https://app.twenty.com/verify-email/123',
locale: 'en',
+ isEmailUpdate: false,
};
export default SendEmailVerificationLinkEmail;
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 9aa68d15d55..35c0712058d 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -1460,12 +1460,6 @@ export type GetAuthorizationUrlForSsoOutput = {
type: Scalars['String'];
};
-export type GetLoginTokenFromEmailVerificationTokenOutput = {
- __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput';
- loginToken: AuthToken;
- workspaceUrls: WorkspaceUrls;
-};
-
export type GetServerlessFunctionSourceCodeInput = {
/** The id of the function. */
id: Scalars['ID'];
@@ -1853,8 +1847,6 @@ export type Mutation = {
getAuthTokensFromOTP: AuthTokens;
getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput;
getLoginTokenFromCredentials: LoginTokenOutput;
- getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput;
- getWorkspaceAgnosticTokenFromEmailVerificationToken: AvailableWorkspacesAndAccessTokensOutput;
impersonate: ImpersonateOutput;
initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput;
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput;
@@ -1914,6 +1906,7 @@ export type Mutation = {
updatePageLayoutWidget: PageLayoutWidget;
updatePageLayoutWithTabsAndWidgets: PageLayout;
updatePasswordViaResetToken: InvalidatePasswordOutput;
+ updateUserEmail: Scalars['Boolean'];
updateWebhook?: Maybe;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
@@ -1930,6 +1923,8 @@ export type Mutation = {
upsertPermissionFlags: Array;
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
+ verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput;
+ verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokensOutput;
verifyEmailingDomain: EmailingDomain;
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput;
};
@@ -2440,21 +2435,6 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
};
-export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
- captchaToken?: InputMaybe;
- email: Scalars['String'];
- emailVerificationToken: Scalars['String'];
- origin: Scalars['String'];
-};
-
-
-export type MutationGetWorkspaceAgnosticTokenFromEmailVerificationTokenArgs = {
- captchaToken?: InputMaybe;
- email: Scalars['String'];
- emailVerificationToken: Scalars['String'];
-};
-
-
export type MutationImpersonateArgs = {
userId: Scalars['UUID'];
workspaceId: Scalars['UUID'];
@@ -2758,6 +2738,12 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
};
+export type MutationUpdateUserEmailArgs = {
+ newEmail: Scalars['String'];
+ verifyEmailRedirectPath?: InputMaybe;
+};
+
+
export type MutationUpdateWebhookArgs = {
input: UpdateWebhookInput;
};
@@ -2843,6 +2829,21 @@ export type MutationValidateApprovedAccessDomainArgs = {
};
+export type MutationVerifyEmailAndGetLoginTokenArgs = {
+ captchaToken?: InputMaybe;
+ email: Scalars['String'];
+ emailVerificationToken: Scalars['String'];
+ origin: Scalars['String'];
+};
+
+
+export type MutationVerifyEmailAndGetWorkspaceAgnosticTokenArgs = {
+ captchaToken?: InputMaybe;
+ email: Scalars['String'];
+ emailVerificationToken: Scalars['String'];
+};
+
+
export type MutationVerifyEmailingDomainArgs = {
id: Scalars['String'];
};
@@ -3101,6 +3102,7 @@ export enum PermissionFlagType {
IMPERSONATE = 'IMPERSONATE',
IMPORT_CSV = 'IMPORT_CSV',
LAYOUTS = 'LAYOUTS',
+ PROFILE_INFORMATION = 'PROFILE_INFORMATION',
ROLES = 'ROLES',
SECURITY = 'SECURITY',
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
@@ -4451,6 +4453,7 @@ export type UpdateWorkspaceInput = {
customDomain?: InputMaybe;
defaultRoleId?: InputMaybe;
displayName?: InputMaybe;
+ editableProfileFields?: InputMaybe>;
inviteHash?: InputMaybe;
isGoogleAuthBypassEnabled?: InputMaybe;
isGoogleAuthEnabled?: InputMaybe;
@@ -4585,6 +4588,12 @@ export type VerificationRecord = {
value: Scalars['String'];
};
+export type VerifyEmailAndGetLoginTokenOutput = {
+ __typename?: 'VerifyEmailAndGetLoginTokenOutput';
+ loginToken: AuthToken;
+ workspaceUrls: WorkspaceUrls;
+};
+
export type VerifyTwoFactorAuthenticationMethodOutput = {
__typename?: 'VerifyTwoFactorAuthenticationMethodOutput';
success: Scalars['Boolean'];
@@ -4791,6 +4800,7 @@ export type Workspace = {
defaultRole?: Maybe;
deletedAt?: Maybe;
displayName?: Maybe;
+ editableProfileFields?: Maybe>;
featureFlags?: Maybe>;
hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID'];
@@ -5140,25 +5150,6 @@ export type GetLoginTokenFromCredentialsMutationVariables = Exact<{
export type GetLoginTokenFromCredentialsMutation = { __typename?: 'Mutation', getLoginTokenFromCredentials: { __typename?: 'LoginTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } };
-export type GetLoginTokenFromEmailVerificationTokenMutationVariables = Exact<{
- emailVerificationToken: Scalars['String'];
- email: Scalars['String'];
- captchaToken?: InputMaybe;
- origin: Scalars['String'];
-}>;
-
-
-export type GetLoginTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getLoginTokenFromEmailVerificationToken: { __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } };
-
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationVariables = Exact<{
- emailVerificationToken: Scalars['String'];
- email: Scalars['String'];
- captchaToken?: InputMaybe;
-}>;
-
-
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation = { __typename?: 'Mutation', getWorkspaceAgnosticTokenFromEmailVerificationToken: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
-
export type ImpersonateMutationVariables = Exact<{
userId: Scalars['UUID'];
workspaceId: Scalars['UUID'];
@@ -5249,6 +5240,25 @@ export type UpdatePasswordViaResetTokenMutationVariables = Exact<{
export type UpdatePasswordViaResetTokenMutation = { __typename?: 'Mutation', updatePasswordViaResetToken: { __typename?: 'InvalidatePasswordOutput', success: boolean } };
+export type VerifyEmailAndGetLoginTokenMutationVariables = Exact<{
+ emailVerificationToken: Scalars['String'];
+ email: Scalars['String'];
+ captchaToken?: InputMaybe;
+ origin: Scalars['String'];
+}>;
+
+
+export type VerifyEmailAndGetLoginTokenMutation = { __typename?: 'Mutation', verifyEmailAndGetLoginToken: { __typename?: 'VerifyEmailAndGetLoginTokenOutput', loginToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null } } };
+
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationVariables = Exact<{
+ emailVerificationToken: Scalars['String'];
+ email: Scalars['String'];
+ captchaToken?: InputMaybe;
+}>;
+
+
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutation = { __typename?: 'Mutation', verifyEmailAndGetWorkspaceAgnosticToken: { __typename?: 'AvailableWorkspacesAndAccessTokensOutput', availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> }, tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
+
export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String'];
captchaToken?: InputMaybe;
@@ -5749,6 +5759,14 @@ export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{
export type UpdateLabPublicFeatureFlagMutation = { __typename?: 'Mutation', updateLabPublicFeatureFlag: { __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean } };
+export type UpdateUserEmailMutationVariables = Exact<{
+ newEmail: Scalars['String'];
+ verifyEmailRedirectPath?: InputMaybe;
+}>;
+
+
+export type UpdateUserEmailMutation = { __typename?: 'Mutation', updateUserEmail: boolean };
+
export type ApiKeyForRoleFragmentFragment = { __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null };
export type FieldPermissionFragmentFragment = { __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string };
@@ -5947,7 +5965,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri
export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null };
-export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } };
+export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } };
export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null };
@@ -5973,7 +5991,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } };
+export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, customDomain?: string | null, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, routerModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, editableProfileFields?: Array | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } };
export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null };
@@ -6983,6 +7001,7 @@ export const UserQueryFragmentFragmentDoc = gql`
routerModel
isTwoFactorAuthenticationEnforced
trashRetentionDays
+ editableProfileFields
}
availableWorkspaces {
...AvailableWorkspacesFragment
@@ -8137,98 +8156,6 @@ export function useGetLoginTokenFromCredentialsMutation(baseOptions?: Apollo.Mut
export type GetLoginTokenFromCredentialsMutationHookResult = ReturnType;
export type GetLoginTokenFromCredentialsMutationResult = Apollo.MutationResult;
export type GetLoginTokenFromCredentialsMutationOptions = Apollo.BaseMutationOptions;
-export const GetLoginTokenFromEmailVerificationTokenDocument = gql`
- mutation GetLoginTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) {
- getLoginTokenFromEmailVerificationToken(
- emailVerificationToken: $emailVerificationToken
- email: $email
- captchaToken: $captchaToken
- origin: $origin
- ) {
- loginToken {
- ...AuthTokenFragment
- }
- workspaceUrls {
- ...WorkspaceUrlsFragment
- }
- }
-}
- ${AuthTokenFragmentFragmentDoc}
-${WorkspaceUrlsFragmentFragmentDoc}`;
-export type GetLoginTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction;
-
-/**
- * __useGetLoginTokenFromEmailVerificationTokenMutation__
- *
- * To run a mutation, you first call `useGetLoginTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
- * When your component renders, `useGetLoginTokenFromEmailVerificationTokenMutation` returns a tuple that includes:
- * - A mutate function that you can call at any time to execute the mutation
- * - An object with fields that represent the current status of the mutation's execution
- *
- * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
- *
- * @example
- * const [getLoginTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetLoginTokenFromEmailVerificationTokenMutation({
- * variables: {
- * emailVerificationToken: // value for 'emailVerificationToken'
- * email: // value for 'email'
- * captchaToken: // value for 'captchaToken'
- * origin: // value for 'origin'
- * },
- * });
- */
-export function useGetLoginTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
- const options = {...defaultOptions, ...baseOptions}
- return Apollo.useMutation(GetLoginTokenFromEmailVerificationTokenDocument, options);
- }
-export type GetLoginTokenFromEmailVerificationTokenMutationHookResult = ReturnType;
-export type GetLoginTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult;
-export type GetLoginTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions;
-export const GetWorkspaceAgnosticTokenFromEmailVerificationTokenDocument = gql`
- mutation GetWorkspaceAgnosticTokenFromEmailVerificationToken($emailVerificationToken: String!, $email: String!, $captchaToken: String) {
- getWorkspaceAgnosticTokenFromEmailVerificationToken(
- emailVerificationToken: $emailVerificationToken
- email: $email
- captchaToken: $captchaToken
- ) {
- availableWorkspaces {
- ...AvailableWorkspacesFragment
- }
- tokens {
- ...AuthTokenPairFragment
- }
- }
-}
- ${AvailableWorkspacesFragmentFragmentDoc}
-${AuthTokenPairFragmentFragmentDoc}`;
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationFn = Apollo.MutationFunction;
-
-/**
- * __useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation__
- *
- * To run a mutation, you first call `useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation` within a React component and pass it any options that fit your needs.
- * When your component renders, `useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation` returns a tuple that includes:
- * - A mutate function that you can call at any time to execute the mutation
- * - An object with fields that represent the current status of the mutation's execution
- *
- * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
- *
- * @example
- * const [getWorkspaceAgnosticTokenFromEmailVerificationTokenMutation, { data, loading, error }] = useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation({
- * variables: {
- * emailVerificationToken: // value for 'emailVerificationToken'
- * email: // value for 'email'
- * captchaToken: // value for 'captchaToken'
- * },
- * });
- */
-export function useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
- const options = {...defaultOptions, ...baseOptions}
- return Apollo.useMutation(GetWorkspaceAgnosticTokenFromEmailVerificationTokenDocument, options);
- }
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationHookResult = ReturnType;
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationResult = Apollo.MutationResult;
-export type GetWorkspaceAgnosticTokenFromEmailVerificationTokenMutationOptions = Apollo.BaseMutationOptions;
export const ImpersonateDocument = gql`
mutation Impersonate($userId: UUID!, $workspaceId: UUID!) {
impersonate(userId: $userId, workspaceId: $workspaceId) {
@@ -8668,6 +8595,98 @@ export function useUpdatePasswordViaResetTokenMutation(baseOptions?: Apollo.Muta
export type UpdatePasswordViaResetTokenMutationHookResult = ReturnType;
export type UpdatePasswordViaResetTokenMutationResult = Apollo.MutationResult;
export type UpdatePasswordViaResetTokenMutationOptions = Apollo.BaseMutationOptions;
+export const VerifyEmailAndGetLoginTokenDocument = gql`
+ mutation VerifyEmailAndGetLoginToken($emailVerificationToken: String!, $email: String!, $captchaToken: String, $origin: String!) {
+ verifyEmailAndGetLoginToken(
+ emailVerificationToken: $emailVerificationToken
+ email: $email
+ captchaToken: $captchaToken
+ origin: $origin
+ ) {
+ loginToken {
+ ...AuthTokenFragment
+ }
+ workspaceUrls {
+ ...WorkspaceUrlsFragment
+ }
+ }
+}
+ ${AuthTokenFragmentFragmentDoc}
+${WorkspaceUrlsFragmentFragmentDoc}`;
+export type VerifyEmailAndGetLoginTokenMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useVerifyEmailAndGetLoginTokenMutation__
+ *
+ * To run a mutation, you first call `useVerifyEmailAndGetLoginTokenMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useVerifyEmailAndGetLoginTokenMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [verifyEmailAndGetLoginTokenMutation, { data, loading, error }] = useVerifyEmailAndGetLoginTokenMutation({
+ * variables: {
+ * emailVerificationToken: // value for 'emailVerificationToken'
+ * email: // value for 'email'
+ * captchaToken: // value for 'captchaToken'
+ * origin: // value for 'origin'
+ * },
+ * });
+ */
+export function useVerifyEmailAndGetLoginTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(VerifyEmailAndGetLoginTokenDocument, options);
+ }
+export type VerifyEmailAndGetLoginTokenMutationHookResult = ReturnType;
+export type VerifyEmailAndGetLoginTokenMutationResult = Apollo.MutationResult;
+export type VerifyEmailAndGetLoginTokenMutationOptions = Apollo.BaseMutationOptions;
+export const VerifyEmailAndGetWorkspaceAgnosticTokenDocument = gql`
+ mutation VerifyEmailAndGetWorkspaceAgnosticToken($emailVerificationToken: String!, $email: String!, $captchaToken: String) {
+ verifyEmailAndGetWorkspaceAgnosticToken(
+ emailVerificationToken: $emailVerificationToken
+ email: $email
+ captchaToken: $captchaToken
+ ) {
+ availableWorkspaces {
+ ...AvailableWorkspacesFragment
+ }
+ tokens {
+ ...AuthTokenPairFragment
+ }
+ }
+}
+ ${AvailableWorkspacesFragmentFragmentDoc}
+${AuthTokenPairFragmentFragmentDoc}`;
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useVerifyEmailAndGetWorkspaceAgnosticTokenMutation__
+ *
+ * To run a mutation, you first call `useVerifyEmailAndGetWorkspaceAgnosticTokenMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useVerifyEmailAndGetWorkspaceAgnosticTokenMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [verifyEmailAndGetWorkspaceAgnosticTokenMutation, { data, loading, error }] = useVerifyEmailAndGetWorkspaceAgnosticTokenMutation({
+ * variables: {
+ * emailVerificationToken: // value for 'emailVerificationToken'
+ * email: // value for 'email'
+ * captchaToken: // value for 'captchaToken'
+ * },
+ * });
+ */
+export function useVerifyEmailAndGetWorkspaceAgnosticTokenMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(VerifyEmailAndGetWorkspaceAgnosticTokenDocument, options);
+ }
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationHookResult = ReturnType;
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationResult = Apollo.MutationResult;
+export type VerifyEmailAndGetWorkspaceAgnosticTokenMutationOptions = Apollo.BaseMutationOptions;
export const CheckUserExistsDocument = gql`
query CheckUserExists($email: String!, $captchaToken: String) {
checkUserExists(email: $email, captchaToken: $captchaToken) {
@@ -11455,6 +11474,41 @@ export function useUpdateLabPublicFeatureFlagMutation(baseOptions?: Apollo.Mutat
export type UpdateLabPublicFeatureFlagMutationHookResult = ReturnType;
export type UpdateLabPublicFeatureFlagMutationResult = Apollo.MutationResult;
export type UpdateLabPublicFeatureFlagMutationOptions = Apollo.BaseMutationOptions;
+export const UpdateUserEmailDocument = gql`
+ mutation UpdateUserEmail($newEmail: String!, $verifyEmailRedirectPath: String) {
+ updateUserEmail(
+ newEmail: $newEmail
+ verifyEmailRedirectPath: $verifyEmailRedirectPath
+ )
+}
+ `;
+export type UpdateUserEmailMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUpdateUserEmailMutation__
+ *
+ * To run a mutation, you first call `useUpdateUserEmailMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateUserEmailMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [updateUserEmailMutation, { data, loading, error }] = useUpdateUserEmailMutation({
+ * variables: {
+ * newEmail: // value for 'newEmail'
+ * verifyEmailRedirectPath: // value for 'verifyEmailRedirectPath'
+ * },
+ * });
+ */
+export function useUpdateUserEmailMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UpdateUserEmailDocument, options);
+ }
+export type UpdateUserEmailMutationHookResult = ReturnType;
+export type UpdateUserEmailMutationResult = Apollo.MutationResult;
+export type UpdateUserEmailMutationOptions = Apollo.BaseMutationOptions;
export const CreateOneRoleDocument = gql`
mutation CreateOneRole($createRoleInput: CreateRoleInput!) {
createOneRole(createRoleInput: $createRoleInput) {
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index b25e2578739..e5d51ea1fc5 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -1394,12 +1394,6 @@ export type GetAuthorizationUrlForSsoOutput = {
type: Scalars['String'];
};
-export type GetLoginTokenFromEmailVerificationTokenOutput = {
- __typename?: 'GetLoginTokenFromEmailVerificationTokenOutput';
- loginToken: AuthToken;
- workspaceUrls: WorkspaceUrls;
-};
-
export type GetServerlessFunctionSourceCodeInput = {
/** The id of the function. */
id: Scalars['ID'];
@@ -1781,8 +1775,6 @@ export type Mutation = {
getAuthTokensFromOTP: AuthTokens;
getAuthorizationUrlForSSO: GetAuthorizationUrlForSsoOutput;
getLoginTokenFromCredentials: LoginTokenOutput;
- getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput;
- getWorkspaceAgnosticTokenFromEmailVerificationToken: AvailableWorkspacesAndAccessTokensOutput;
impersonate: ImpersonateOutput;
initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput;
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput;
@@ -1836,6 +1828,7 @@ export type Mutation = {
updatePageLayoutWidget: PageLayoutWidget;
updatePageLayoutWithTabsAndWidgets: PageLayout;
updatePasswordViaResetToken: InvalidatePasswordOutput;
+ updateUserEmail: Scalars['Boolean'];
updateWebhook?: Maybe;
updateWorkflowRunStep: WorkflowAction;
updateWorkflowVersionPositions: Scalars['Boolean'];
@@ -1852,6 +1845,8 @@ export type Mutation = {
upsertPermissionFlags: Array;
userLookupAdminPanel: UserLookup;
validateApprovedAccessDomain: ApprovedAccessDomain;
+ verifyEmailAndGetLoginToken: VerifyEmailAndGetLoginTokenOutput;
+ verifyEmailAndGetWorkspaceAgnosticToken: AvailableWorkspacesAndAccessTokensOutput;
verifyEmailingDomain: EmailingDomain;
verifyTwoFactorAuthenticationMethodForAuthenticatedUser: VerifyTwoFactorAuthenticationMethodOutput;
};
@@ -2327,21 +2322,6 @@ export type MutationGetLoginTokenFromCredentialsArgs = {
};
-export type MutationGetLoginTokenFromEmailVerificationTokenArgs = {
- captchaToken?: InputMaybe;
- email: Scalars['String'];
- emailVerificationToken: Scalars['String'];
- origin: Scalars['String'];
-};
-
-
-export type MutationGetWorkspaceAgnosticTokenFromEmailVerificationTokenArgs = {
- captchaToken?: InputMaybe;
- email: Scalars['String'];
- emailVerificationToken: Scalars['String'];
-};
-
-
export type MutationImpersonateArgs = {
userId: Scalars['UUID'];
workspaceId: Scalars['UUID'];
@@ -2615,6 +2595,12 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
};
+export type MutationUpdateUserEmailArgs = {
+ newEmail: Scalars['String'];
+ verifyEmailRedirectPath?: InputMaybe;
+};
+
+
export type MutationUpdateWebhookArgs = {
input: UpdateWebhookInput;
};
@@ -2700,6 +2686,21 @@ export type MutationValidateApprovedAccessDomainArgs = {
};
+export type MutationVerifyEmailAndGetLoginTokenArgs = {
+ captchaToken?: InputMaybe;
+ email: Scalars['String'];
+ emailVerificationToken: Scalars['String'];
+ origin: Scalars['String'];
+};
+
+
+export type MutationVerifyEmailAndGetWorkspaceAgnosticTokenArgs = {
+ captchaToken?: InputMaybe;
+ email: Scalars['String'];
+ emailVerificationToken: Scalars['String'];
+};
+
+
export type MutationVerifyEmailingDomainArgs = {
id: Scalars['String'];
};
@@ -2958,6 +2959,7 @@ export enum PermissionFlagType {
IMPERSONATE = 'IMPERSONATE',
IMPORT_CSV = 'IMPORT_CSV',
LAYOUTS = 'LAYOUTS',
+ PROFILE_INFORMATION = 'PROFILE_INFORMATION',
ROLES = 'ROLES',
SECURITY = 'SECURITY',
SEND_EMAIL_TOOL = 'SEND_EMAIL_TOOL',
@@ -4185,6 +4187,7 @@ export type UpdateWorkspaceInput = {
customDomain?: InputMaybe;
defaultRoleId?: InputMaybe;
displayName?: InputMaybe;
+ editableProfileFields?: InputMaybe>;
inviteHash?: InputMaybe;
isGoogleAuthBypassEnabled?: InputMaybe;
isGoogleAuthEnabled?: InputMaybe;
@@ -4309,6 +4312,12 @@ export type VerificationRecord = {
value: Scalars['String'];
};
+export type VerifyEmailAndGetLoginTokenOutput = {
+ __typename?: 'VerifyEmailAndGetLoginTokenOutput';
+ loginToken: AuthToken;
+ workspaceUrls: WorkspaceUrls;
+};
+
export type VerifyTwoFactorAuthenticationMethodOutput = {
__typename?: 'VerifyTwoFactorAuthenticationMethodOutput';
success: Scalars['Boolean'];
@@ -4515,6 +4524,7 @@ export type Workspace = {
defaultRole?: Maybe;
deletedAt?: Maybe;
displayName?: Maybe;
+ editableProfileFields?: Maybe>;
featureFlags?: Maybe>;
hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID'];
diff --git a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
index 90c70373c6e..7d1306eb660 100644
--- a/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
+++ b/packages/twenty-front/src/modules/auth/components/VerifyEmailEffect.tsx
@@ -20,8 +20,8 @@ import { EmailVerificationSent } from '../sign-in-up/components/EmailVerificatio
export const VerifyEmailEffect = () => {
const {
- getLoginTokenFromEmailVerificationToken,
- getWorkspaceAgnosticTokenFromEmailVerificationToken,
+ verifyEmailAndGetLoginToken,
+ verifyEmailAndGetWorkspaceAgnosticToken,
} = useAuth();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
@@ -65,7 +65,7 @@ export const VerifyEmailEffect = () => {
try {
if (!isOnAWorkspace) {
- await getWorkspaceAgnosticTokenFromEmailVerificationToken(
+ await verifyEmailAndGetWorkspaceAgnosticToken(
emailVerificationToken,
email,
);
@@ -73,11 +73,10 @@ export const VerifyEmailEffect = () => {
return enqueueSuccessSnackBar(successSnackbarParams);
}
- const { loginToken, workspaceUrls } =
- await getLoginTokenFromEmailVerificationToken(
- emailVerificationToken,
- email,
- );
+ const { loginToken, workspaceUrls } = await verifyEmailAndGetLoginToken(
+ emailVerificationToken,
+ email,
+ );
enqueueSuccessSnackBar(successSnackbarParams);
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts
similarity index 83%
rename from packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts
rename to packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts
index 1503dcf8c52..3e57cadecda 100644
--- a/packages/twenty-front/src/modules/auth/graphql/mutations/getLoginTokenFromEmailVerificationToken.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetLoginToken.ts
@@ -1,13 +1,13 @@
import { gql } from '@apollo/client';
export const GET_LOGIN_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
- mutation GetLoginTokenFromEmailVerificationToken(
+ mutation VerifyEmailAndGetLoginToken(
$emailVerificationToken: String!
$email: String!
$captchaToken: String
$origin: String!
) {
- getLoginTokenFromEmailVerificationToken(
+ verifyEmailAndGetLoginToken(
emailVerificationToken: $emailVerificationToken
email: $email
captchaToken: $captchaToken
diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts
similarity index 79%
rename from packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts
rename to packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts
index bff7821e84d..8803bed7b9b 100644
--- a/packages/twenty-front/src/modules/auth/graphql/mutations/getWorkspaceAgnosticTokenFromEmailVerificationToken.ts
+++ b/packages/twenty-front/src/modules/auth/graphql/mutations/verifyEmailAndGetWorkspaceAgnosticToken.ts
@@ -1,12 +1,12 @@
import { gql } from '@apollo/client';
export const GET_WORKSPACE_AGNOSTIC_TOKEN_FROM_EMAIL_VERIFICATION_TOKEN = gql`
- mutation GetWorkspaceAgnosticTokenFromEmailVerificationToken(
+ mutation VerifyEmailAndGetWorkspaceAgnosticToken(
$emailVerificationToken: String!
$email: String!
$captchaToken: String
) {
- getWorkspaceAgnosticTokenFromEmailVerificationToken(
+ verifyEmailAndGetWorkspaceAgnosticToken(
emailVerificationToken: $emailVerificationToken
email: $email
captchaToken: $captchaToken
diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
index 290820d3767..d73a41bf650 100644
--- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts
@@ -18,17 +18,18 @@ import {
useGetAuthTokensFromLoginTokenMutation,
useGetAuthTokensFromOtpMutation,
useGetLoginTokenFromCredentialsMutation,
- useGetLoginTokenFromEmailVerificationTokenMutation,
- useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation,
useSignInMutation,
useSignUpInWorkspaceMutation,
useSignUpMutation,
+ useVerifyEmailAndGetLoginTokenMutation,
+ useVerifyEmailAndGetWorkspaceAgnosticTokenMutation,
type AuthTokenPair,
} from '~/generated-metadata/graphql';
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { tokenPairState } from '../states/tokenPairState';
+import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
import { useSignUpInNewWorkspace } from '@/auth/sign-in-up/hooks/useSignUpInNewWorkspace';
import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadedState';
import {
@@ -66,7 +67,6 @@ import { type AuthToken } from '~/generated/graphql';
import { cookieStorage } from '~/utils/cookie-storage';
import { getWorkspaceUrl } from '~/utils/getWorkspaceUrl';
import { loginTokenState } from '../states/loginTokenState';
-import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
@@ -98,10 +98,10 @@ export const useAuth = () => {
const [signUpInWorkspace] = useSignUpInWorkspaceMutation();
const [getAuthTokensFromLoginToken] =
useGetAuthTokensFromLoginTokenMutation();
- const [getLoginTokenFromEmailVerificationToken] =
- useGetLoginTokenFromEmailVerificationTokenMutation();
- const [getWorkspaceAgnosticTokenFromEmailVerificationToken] =
- useGetWorkspaceAgnosticTokenFromEmailVerificationTokenMutation();
+ const [verifyEmailAndGetLoginToken] =
+ useVerifyEmailAndGetLoginTokenMutation();
+ const [verifyEmailAndGetWorkspaceAgnosticToken] =
+ useVerifyEmailAndGetWorkspaceAgnosticTokenMutation();
const [getAuthTokensFromOtp] = useGetAuthTokensFromOtpMutation();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
@@ -238,13 +238,13 @@ export const useAuth = () => {
[getLoginTokenFromCredentials, setSearchParams, setSignInUpStep, origin],
);
- const handleGetLoginTokenFromEmailVerificationToken = useCallback(
+ const handleverifyEmailAndGetLoginToken = useCallback(
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
- const loginTokenResult = await getLoginTokenFromEmailVerificationToken({
+ const loginTokenResult = await verifyEmailAndGetLoginToken({
variables: {
email,
emailVerificationToken,
@@ -257,41 +257,38 @@ export const useAuth = () => {
throw loginTokenResult.errors;
}
- if (!loginTokenResult.data?.getLoginTokenFromEmailVerificationToken) {
+ if (!loginTokenResult.data?.verifyEmailAndGetLoginToken) {
throw new Error('No login token');
}
- return loginTokenResult.data.getLoginTokenFromEmailVerificationToken;
+ return loginTokenResult.data.verifyEmailAndGetLoginToken;
},
- [getLoginTokenFromEmailVerificationToken, origin],
+ [verifyEmailAndGetLoginToken, origin],
);
- const handleGetWorkspaceAgnosticTokenFromEmailVerificationToken = useCallback(
+ const handleverifyEmailAndGetWorkspaceAgnosticToken = useCallback(
async (
emailVerificationToken: string,
email: string,
captchaToken?: string,
) => {
- const { data, errors } =
- await getWorkspaceAgnosticTokenFromEmailVerificationToken({
- variables: {
- email,
- emailVerificationToken,
- captchaToken,
- },
- });
+ const { data, errors } = await verifyEmailAndGetWorkspaceAgnosticToken({
+ variables: {
+ email,
+ emailVerificationToken,
+ captchaToken,
+ },
+ });
if (isDefined(errors)) {
throw errors;
}
- if (!data?.getWorkspaceAgnosticTokenFromEmailVerificationToken) {
+ if (!data?.verifyEmailAndGetWorkspaceAgnosticToken) {
throw new Error('No workspace agnostic token in result');
}
- handleSetAuthTokens(
- data.getWorkspaceAgnosticTokenFromEmailVerificationToken.tokens,
- );
+ handleSetAuthTokens(data.verifyEmailAndGetWorkspaceAgnosticToken.tokens);
const { user } = await loadCurrentUser();
@@ -303,7 +300,7 @@ export const useAuth = () => {
},
[
createWorkspace,
- getWorkspaceAgnosticTokenFromEmailVerificationToken,
+ verifyEmailAndGetWorkspaceAgnosticToken,
handleSetAuthTokens,
loadCurrentUser,
setSignInUpStep,
@@ -681,10 +678,9 @@ export const useAuth = () => {
return {
getLoginTokenFromCredentials: handleGetLoginTokenFromCredentials,
- getWorkspaceAgnosticTokenFromEmailVerificationToken:
- handleGetWorkspaceAgnosticTokenFromEmailVerificationToken,
- getLoginTokenFromEmailVerificationToken:
- handleGetLoginTokenFromEmailVerificationToken,
+ verifyEmailAndGetWorkspaceAgnosticToken:
+ handleverifyEmailAndGetWorkspaceAgnosticToken,
+ verifyEmailAndGetLoginToken: handleverifyEmailAndGetLoginToken,
getAuthTokensFromLoginToken: handleGetAuthTokensFromLoginToken,
checkUserExists: { checkUserExistsData, checkUserExistsQuery },
clearSession,
diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
index 69b3120fe46..e8d15079c50 100644
--- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
+++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
@@ -29,6 +29,7 @@ export type CurrentWorkspace = Pick<
| 'isTwoFactorAuthenticationEnforced'
| 'trashRetentionDays'
| 'routerModel'
+ | 'editableProfileFields'
> & {
defaultRole?: Omit | null;
};
diff --git a/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx b/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx
index f51563fd166..da5beaf6096 100644
--- a/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx
+++ b/packages/twenty-front/src/modules/settings/profile/components/EmailField.tsx
@@ -1,18 +1,131 @@
+import styled from '@emotion/styled';
+import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
+import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
+import { useUpdateEmail } from '@/settings/profile/hooks/useUpdateEmail';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
+import { IconCheck, IconPencil, IconX } from 'twenty-ui/display';
+import { Button } from 'twenty-ui/input';
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledFieldRow = styled.div`
+ display: flex;
+ align-items: stretch;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledActionWrapper = styled.div`
+ display: flex;
+ align-items: stretch;
+
+ & > button + button {
+ border-left: none;
+ }
+`;
+
+const StyledActionButton = styled(Button)`
+ height: 100%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+`;
export const EmailField = () => {
const currentUser = useRecoilValue(currentUserState);
+ const { canEdit } = useCanEditProfileField('email');
+ const { updateEmail } = useUpdateEmail();
+
+ const [draftEmail, setDraftEmail] = useState('');
+ const [isEditing, setIsEditing] = useState(false);
+
+ const currentEmail = currentUser?.email ?? '';
+
+ const normalizedDraftEmail = draftEmail.trim().toLowerCase();
+
+ const isEmailChanged =
+ normalizedDraftEmail.length > 0 && normalizedDraftEmail !== currentEmail;
+ const isEmailFormatValid =
+ normalizedDraftEmail.includes('@') && !normalizedDraftEmail.endsWith('@');
+
+ const isSaveDisabled =
+ !canEdit || !isEditing || !isEmailChanged || !isEmailFormatValid;
+
+ const handleStartEditing = () => {
+ if (!canEdit) {
+ return;
+ }
+
+ setDraftEmail(currentEmail);
+ setIsEditing(true);
+ };
+
+ const handleCancelEditing = () => {
+ setIsEditing(false);
+ };
+
+ const handleSave = async () => {
+ if (isSaveDisabled) {
+ return;
+ }
+
+ setIsEditing(false);
+ await updateEmail(normalizedDraftEmail);
+ };
+
+ const currentUserId = currentUser?.id;
return (
-
+
+
+
+ {isEditing ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
);
};
diff --git a/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx b/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx
index d0194d6c7dc..86c36483a32 100644
--- a/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx
+++ b/packages/twenty-front/src/modules/settings/profile/components/NameFields.tsx
@@ -8,6 +8,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
+import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { logError } from '~/utils/logError';
@@ -21,20 +22,16 @@ const StyledComboInputContainer = styled.div`
type NameFieldsProps = {
autoSave?: boolean;
- onFirstNameUpdate?: (firstName: string) => void;
- onLastNameUpdate?: (lastName: string) => void;
};
-export const NameFields = ({
- autoSave = true,
- onFirstNameUpdate,
- onLastNameUpdate,
-}: NameFieldsProps) => {
+export const NameFields = ({ autoSave = true }: NameFieldsProps) => {
const { t } = useLingui();
const currentUser = useRecoilValue(currentUserState);
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState,
);
+ const { canEdit: canEditFirstName } = useCanEditProfileField('firstName');
+ const { canEdit: canEditLastName } = useCanEditProfileField('lastName');
const [firstName, setFirstName] = useState(
currentWorkspaceMember?.name?.firstName ?? '',
@@ -49,9 +46,6 @@ export const NameFields = ({
// TODO: Enhance this with react-web-hook-form (https://www.react-hook-form.com)
const debouncedUpdate = useDebouncedCallback(async () => {
- onFirstNameUpdate?.(firstName);
- onLastNameUpdate?.(lastName);
-
try {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
@@ -107,6 +101,8 @@ export const NameFields = ({
debouncedUpdate,
autoSave,
currentWorkspaceMember,
+ canEditFirstName,
+ canEditLastName,
]);
const firstNameTextInputId = `${currentWorkspaceMember?.id}-first-name`;
@@ -121,6 +117,7 @@ export const NameFields = ({
onChange={setFirstName}
placeholder="Tim"
fullWidth
+ disabled={!canEditFirstName}
/>
);
diff --git a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx b/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
index 60064c24cf9..df5ef7181da 100644
--- a/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
+++ b/packages/twenty-front/src/modules/settings/profile/components/ProfilePictureUploader.tsx
@@ -4,6 +4,7 @@ import { useRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
+import { useCanEditProfileField } from '@/settings/profile/hooks/useCanEditProfileField';
import { ImageInput } from '@/ui/input/components/ImageInput';
import { buildSignedPath, isDefined } from 'twenty-shared/utils';
import { useUploadProfilePictureMutation } from '~/generated-metadata/graphql';
@@ -25,8 +26,11 @@ export const ProfilePictureUploader = () => {
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
+ const { canEdit: canEditProfilePicture } =
+ useCanEditProfileField('profilePicture');
+
const handleUpload = async (file: File) => {
- if (isUndefinedOrNull(file)) {
+ if (isUndefinedOrNull(file) || !canEditProfilePicture) {
return;
}
@@ -76,6 +80,10 @@ export const ProfilePictureUploader = () => {
};
const handleAbort = async () => {
+ if (!canEditProfilePicture) {
+ return;
+ }
+
if (isDefined(uploadController)) {
uploadController.abort();
setUploadController(null);
@@ -83,6 +91,10 @@ export const ProfilePictureUploader = () => {
};
const handleRemove = async () => {
+ if (!canEditProfilePicture) {
+ return;
+ }
+
try {
if (!currentWorkspaceMember?.id) {
throw new Error('User is not logged in');
@@ -109,6 +121,7 @@ export const ProfilePictureUploader = () => {
onAbort={handleAbort}
isUploading={isUploading}
errorMessage={errorMessage}
+ disabled={!canEditProfilePicture}
/>
);
};
diff --git a/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts
new file mode 100644
index 00000000000..c957de5bcd7
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/profile/graphql/mutations/updateUserEmail.ts
@@ -0,0 +1,13 @@
+import { gql } from '@apollo/client';
+
+export const UPDATE_USER_EMAIL = gql`
+ mutation UpdateUserEmail(
+ $newEmail: String!
+ $verifyEmailRedirectPath: String
+ ) {
+ updateUserEmail(
+ newEmail: $newEmail
+ verifyEmailRedirectPath: $verifyEmailRedirectPath
+ )
+ }
+`;
diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts
new file mode 100644
index 00000000000..02f389ea86f
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/profile/hooks/useCanEditProfileField.ts
@@ -0,0 +1,40 @@
+import { availableWorkspacesState } from '@/auth/states/availableWorkspacesState';
+import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { countAvailableWorkspaces } from '@/auth/utils/availableWorkspacesUtils';
+import { useRecoilValue } from 'recoil';
+import { PermissionFlagType } from '~/generated-metadata/graphql';
+
+export type EditableProfileField =
+ | 'email'
+ | 'firstName'
+ | 'lastName'
+ | 'profilePicture';
+
+export const useCanEditProfileField = (field: EditableProfileField) => {
+ const currentWorkspace = useRecoilValue(currentWorkspaceState);
+ const currentUserWorkspace = useRecoilValue(currentUserWorkspaceState);
+ const availableWorkspaces = useRecoilValue(availableWorkspacesState);
+
+ if (!currentWorkspace || !currentUserWorkspace) {
+ return { canEdit: false };
+ }
+
+ const editableFields = currentWorkspace.editableProfileFields ?? [];
+ const workspaceAllowsField = editableFields.includes(field);
+
+ const permissionFlags = currentUserWorkspace.permissionFlags ?? [];
+ const hasProfilePermission = permissionFlags.includes(
+ PermissionFlagType.PROFILE_INFORMATION,
+ );
+
+ const requiresSingleWorkspace = field === 'email';
+ const isSingleWorkspaceUser =
+ countAvailableWorkspaces(availableWorkspaces) <= 1;
+ const meetsWorkspaceLimit = !requiresSingleWorkspace || isSingleWorkspaceUser;
+
+ return {
+ canEdit:
+ workspaceAllowsField && hasProfilePermission && meetsWorkspaceLimit,
+ };
+};
diff --git a/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts
new file mode 100644
index 00000000000..c2955aec8bf
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/profile/hooks/useUpdateEmail.ts
@@ -0,0 +1,41 @@
+import { ApolloError } from '@apollo/client';
+
+import { useRecoilValue } from 'recoil';
+
+import { currentUserState } from '@/auth/states/currentUserState';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useUpdateUserEmailMutation } from '~/generated-metadata/graphql';
+
+export const useUpdateEmail = () => {
+ const { enqueueErrorSnackBar, enqueueInfoSnackBar } = useSnackBar();
+
+ const currentUser = useRecoilValue(currentUserState);
+
+ const [updateUserEmail] = useUpdateUserEmailMutation();
+
+ const handleUpdate = async (email: string) => {
+ if (!currentUser) {
+ return;
+ }
+
+ try {
+ await updateUserEmail({
+ variables: {
+ newEmail: email,
+ },
+ });
+
+ enqueueInfoSnackBar({
+ message: 'Check your inbox to verify your new email address.',
+ });
+ } catch (error) {
+ if (error instanceof ApolloError) {
+ enqueueErrorSnackBar({ apolloError: error });
+ }
+ }
+ };
+
+ return {
+ updateEmail: handleUpdate,
+ };
+};
diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx
index d0fe8d84fb1..b96b2c748e4 100644
--- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx
+++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/components/SettingsRolePermissionsToolSection.tsx
@@ -19,6 +19,7 @@ import {
IconSparkles,
IconTable,
IconTool,
+ IconUser,
} from 'twenty-ui/display';
import { AnimatedExpandableContainer, Card, Section } from 'twenty-ui/layout';
import {
@@ -104,6 +105,13 @@ export const SettingsRolePermissionsToolSection = ({
Icon: IconAt,
isToolPermission: true,
},
+ {
+ key: PermissionFlagType.PROFILE_INFORMATION,
+ name: t`Edit Profile`,
+ description: t`Edit own profile information`,
+ Icon: IconUser,
+ isToolPermission: true,
+ },
{
key: PermissionFlagType.VIEWS,
name: t`Manage Views`,
diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx
new file mode 100644
index 00000000000..d9f1dbf5cea
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityEditableProfileFields.tsx
@@ -0,0 +1,147 @@
+import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
+import { EDITABLE_PROFILE_FIELDS_DROPDOWN_ID } from '@/settings/security/constants/EditableProfileFields.constants';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { SelectControl } from '@/ui/input/components/SelectControl';
+import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
+import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { ApolloError } from '@apollo/client';
+import styled from '@emotion/styled';
+import { useLingui } from '@lingui/react/macro';
+import { useRecoilState } from 'recoil';
+import { isDefined } from 'twenty-shared/utils';
+import {
+ IconMail,
+ IconPhoto,
+ IconUser,
+ IconUserCircle,
+ type IconComponent,
+} from 'twenty-ui/display';
+import { type SelectOption } from 'twenty-ui/input';
+import { MenuItemMultiSelect } from 'twenty-ui/navigation';
+import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
+
+const StyledDropdownContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing(3)};
+`;
+
+type ProfileFieldOption = {
+ value: string;
+ label: string;
+ Icon: IconComponent;
+};
+
+export const SettingsSecurityEditableProfileFields = () => {
+ const { t } = useLingui();
+ const { enqueueErrorSnackBar } = useSnackBar();
+
+ const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
+ currentWorkspaceState,
+ );
+ const [updateWorkspace] = useUpdateWorkspaceMutation();
+
+ const profileFieldOptions: ProfileFieldOption[] = [
+ { value: 'email', label: t`Email`, Icon: IconMail },
+ { value: 'firstName', label: t`First Name`, Icon: IconUserCircle },
+ { value: 'lastName', label: t`Last Name`, Icon: IconUser },
+ { value: 'profilePicture', label: t`Profile Picture`, Icon: IconPhoto },
+ ];
+
+ const selectedFields =
+ currentWorkspace?.editableProfileFields?.filter(isDefined) ?? [];
+
+ const optionByValue = new Map(
+ profileFieldOptions.map((option) => [option.value, option]),
+ );
+
+ const selectedLabelList = selectedFields
+ .map((value) => optionByValue.get(value)?.label ?? value)
+ .filter(isDefined);
+
+ const selectedDisplayLabel =
+ selectedLabelList.length > 0
+ ? selectedLabelList.join(', ')
+ : t`No fields selected`;
+
+ const firstSelectedIcon =
+ selectedFields.length === 1
+ ? optionByValue.get(selectedFields[0])?.Icon
+ : undefined;
+
+ const selectedOption: SelectOption = {
+ value: selectedDisplayLabel,
+ label: selectedDisplayLabel,
+ Icon: firstSelectedIcon,
+ };
+
+ const toggleField = (field: string) => {
+ if (!currentWorkspace?.id) {
+ enqueueErrorSnackBar({ message: t`User is not logged in` });
+ return;
+ }
+
+ const previousFields = currentWorkspace.editableProfileFields ?? [];
+
+ const nextFields = previousFields.includes(field)
+ ? previousFields.filter((value) => value !== field)
+ : [...previousFields, field];
+
+ const normalizedFields = profileFieldOptions
+ .map((option) => option.value)
+ .filter((value) => nextFields.includes(value));
+
+ setCurrentWorkspace((prev) =>
+ prev ? { ...prev, editableProfileFields: normalizedFields } : prev,
+ );
+
+ updateWorkspace({
+ variables: {
+ input: {
+ editableProfileFields: normalizedFields,
+ },
+ },
+ }).catch((err) => {
+ setCurrentWorkspace((prev) =>
+ prev ? { ...prev, editableProfileFields: previousFields } : prev,
+ );
+ enqueueErrorSnackBar({
+ apolloError: err instanceof ApolloError ? err : undefined,
+ });
+ });
+ };
+
+ return (
+
+
+ }
+ dropdownComponents={
+
+
+ {profileFieldOptions.map((option) => (
+ toggleField(option.value)}
+ />
+ ))}
+
+
+ }
+ />
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts b/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts
new file mode 100644
index 00000000000..d1ee185d948
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/security/constants/EditableProfileFields.constants.ts
@@ -0,0 +1,2 @@
+export const EDITABLE_PROFILE_FIELDS_DROPDOWN_ID =
+ 'editable-profile-fields-dropdown';
diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
index 12b838e3e51..f7c12e8e29a 100644
--- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
+++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts
@@ -83,6 +83,7 @@ export const USER_QUERY_FRAGMENT = gql`
routerModel
isTwoFactorAuthenticationEnforced
trashRetentionDays
+ editableProfileFields
}
availableWorkspaces {
...AvailableWorkspacesFragment
diff --git a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx
index 64452d24a09..2f4feb91c28 100644
--- a/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx
+++ b/packages/twenty-front/src/pages/settings/security/SettingsSecurity.tsx
@@ -10,6 +10,7 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsSSOIdentitiesProvidersListCard } from '@/settings/security/components/SSO/SettingsSSOIdentitiesProvidersListCard';
import { SettingsSecurityAuthBypassOptionsList } from '@/settings/security/components/SettingsSecurityAuthBypassOptionsList';
import { SettingsSecurityAuthProvidersOptionsList } from '@/settings/security/components/SettingsSecurityAuthProvidersOptionsList';
+import { SettingsSecurityEditableProfileFields } from '@/settings/security/components/SettingsSecurityEditableProfileFields';
import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProvidersState';
import { ToggleImpersonate } from '@/settings/workspace/components/ToggleImpersonate';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
@@ -139,6 +140,15 @@ export const SettingsSecurity = () => {
+
{shouldShowBypassSection && (
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts
new file mode 100644
index 00000000000..82ced1e3ca0
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1762884796640-editableProfileFields.ts
@@ -0,0 +1,17 @@
+import { type MigrationInterface, type QueryRunner } from 'typeorm';
+
+export class EditableProfileFields1762884796640 implements MigrationInterface {
+ name = 'EditableProfileFields1762884796640';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."workspace" ADD "editableProfileFields" character varying array DEFAULT '{email,profilePicture,firstName,lastName}'`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."workspace" DROP COLUMN "editableProfileFields"`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
index 83b6226cf57..a11ee5e2124 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts
@@ -33,7 +33,6 @@ import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-module
import { GetAuthTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-auth-token-from-email-verification-token.input';
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
-import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
@@ -51,6 +50,7 @@ import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { CaptchaGraphqlApiExceptionFilter } from 'src/engine/core-modules/captcha/filters/captcha-graphql-api-exception.filter';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EmailVerificationExceptionFilter } from 'src/engine/core-modules/email-verification/email-verification-exception-filter.util';
+import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -76,6 +76,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
+import { VerifyEmailAndGetLoginTokenOutput } from 'src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output';
import { ApiKeyToken } from './dto/api-key-token.dto';
import { AuthTokens } from './dto/auth-tokens.dto';
@@ -239,9 +240,9 @@ export class AuthResolver {
};
}
- @Mutation(() => GetLoginTokenFromEmailVerificationTokenOutput)
+ @Mutation(() => VerifyEmailAndGetLoginTokenOutput)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
- async getLoginTokenFromEmailVerificationToken(
+ async verifyEmailAndGetLoginToken(
@Args()
getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput,
@Args('origin') origin: string,
@@ -252,19 +253,25 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
+ if (appToken.context && appToken.context.email !== appToken.user.email) {
+ await this.userService.updateEmailFromVerificationToken(
+ appToken.user.id,
+ appToken.context.email,
+ );
+ }
+
+ const user = await this.userService.markEmailAsVerified(appToken.user.id);
+
+ await this.appTokenRepository.remove(appToken);
+
const workspace =
(await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
)) ??
- (await this.userWorkspaceService.findFirstWorkspaceByUserId(
- appToken.user.id,
- ));
-
- await this.userService.markEmailAsVerified(appToken.user.id);
- await this.appTokenRepository.remove(appToken);
+ (await this.userWorkspaceService.findFirstWorkspaceByUserId(user.id));
const loginToken = await this.loginTokenService.generateLoginToken(
- appToken.user.email,
+ user.email,
workspace.id,
authProvider,
);
@@ -277,7 +284,7 @@ export class AuthResolver {
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
- async getWorkspaceAgnosticTokenFromEmailVerificationToken(
+ async verifyEmailAndGetWorkspaceAgnosticToken(
@Args()
getAuthTokenFromEmailVerificationTokenInput: GetAuthTokenFromEmailVerificationTokenInput,
@AuthProvider() authProvider: AuthProviderEnum,
@@ -287,31 +294,39 @@ export class AuthResolver {
getAuthTokenFromEmailVerificationTokenInput,
);
- await this.userService.markEmailAsVerified(appToken.user.id);
+ if (appToken.context && appToken.context.email !== appToken.user.email) {
+ await this.userService.updateEmailFromVerificationToken(
+ appToken.user.id,
+ appToken.context.email,
+ );
+ }
+
+ const user = await this.userService.markEmailAsVerified(appToken.user.id);
+
await this.appTokenRepository.remove(appToken);
const availableWorkspaces =
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
- appToken.user.email,
+ user.email,
);
return {
availableWorkspaces:
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
availableWorkspaces,
- appToken.user,
+ user,
authProvider,
),
tokens: {
accessOrWorkspaceAgnosticToken:
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
{
- userId: appToken.user.id,
+ userId: user.id,
authProvider: AuthProviderEnum.Password,
},
),
refreshToken: await this.refreshTokenService.generateRefreshToken({
- userId: appToken.user.id,
+ userId: user.id,
authProvider: AuthProviderEnum.Password,
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
@@ -377,13 +392,14 @@ export class AuthResolver {
user.email,
);
- await this.emailVerificationService.sendVerificationEmail(
- user.id,
- user.email,
- undefined,
- signUpInput.locale ?? SOURCE_LOCALE,
- signUpInput.verifyEmailRedirectPath,
- );
+ await this.emailVerificationService.sendVerificationEmail({
+ userId: user.id,
+ email: user.email,
+ workspace: undefined,
+ locale: signUpInput.locale ?? SOURCE_LOCALE,
+ verifyEmailRedirectPath: signUpInput.verifyEmailRedirectPath,
+ verificationTrigger: EmailVerificationTrigger.SIGN_UP,
+ });
return {
availableWorkspaces:
@@ -459,13 +475,14 @@ export class AuthResolver {
},
});
- await this.emailVerificationService.sendVerificationEmail(
- user.id,
- user.email,
+ await this.emailVerificationService.sendVerificationEmail({
+ userId: user.id,
+ email: user.email,
workspace,
- signUpInput.locale ?? SOURCE_LOCALE,
- signUpInput.verifyEmailRedirectPath,
- );
+ locale: signUpInput.locale ?? SOURCE_LOCALE,
+ verifyEmailRedirectPath: signUpInput.verifyEmailRedirectPath,
+ verificationTrigger: EmailVerificationTrigger.SIGN_UP,
+ });
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts
similarity index 72%
rename from packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts
rename to packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts
index 8a0c1258602..bd35bd589a0 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/dto/verify-email-and-get-login-token.output.ts
@@ -4,8 +4,8 @@ import { WorkspaceUrlsDTO } from 'src/engine/core-modules/workspace/dtos/workspa
import { AuthToken } from './auth-token.dto';
-@ObjectType('GetLoginTokenFromEmailVerificationTokenOutput')
-export class GetLoginTokenFromEmailVerificationTokenOutput {
+@ObjectType('VerifyEmailAndGetLoginTokenOutput')
+export class VerifyEmailAndGetLoginTokenOutput {
@Field(() => AuthToken)
loginToken: AuthToken;
diff --git a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
index ceea764406a..bcad13bec79 100644
--- a/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service.ts
@@ -142,6 +142,7 @@ export class WorkspaceDomainsService {
return {
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
customDomain: null,
+ isCustomDomainEnabled: false,
};
}
@@ -149,6 +150,7 @@ export class WorkspaceDomainsService {
return {
subdomain: workspace.subdomain,
customDomain: null,
+ isCustomDomainEnabled: false,
};
}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts
new file mode 100644
index 00000000000..6906cd490f1
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.constants.ts
@@ -0,0 +1,4 @@
+export enum EmailVerificationTrigger {
+ SIGN_UP = 'SIGN_UP',
+ EMAIL_UPDATE = 'EMAIL_UPDATE',
+}
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts
index 4aa2e1a404c..86456b6fe0c 100644
--- a/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/email-verification.module.ts
@@ -11,14 +11,12 @@ import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
-import { UserModule } from 'src/engine/core-modules/user/user.module';
@Module({
imports: [
TypeOrmModule.forFeature([AppTokenEntity, UserEntity]),
EmailModule,
TwentyConfigModule,
- UserModule,
UserWorkspaceModule,
WorkspaceDomainsModule,
DomainServerConfigModule,
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
index d4381c97c43..09694fa6602 100644
--- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
@@ -8,7 +8,7 @@ import ms from 'ms';
import { SendEmailVerificationLinkEmail } from 'twenty-emails';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
+import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import {
@@ -19,6 +19,7 @@ import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/toke
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceDomainConfig } from 'src/engine/core-modules/domain/workspace-domains/types/workspace-domain-config.type';
+import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
import {
EmailVerificationException,
EmailVerificationExceptionCode,
@@ -26,29 +27,38 @@ import {
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
-import { UserService } from 'src/engine/core-modules/user/services/user.service';
+import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class EmailVerificationService {
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository,
+ @InjectRepository(UserEntity)
+ private readonly userRepository: Repository,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly domainsServerConfigService: DomainServerConfigService,
private readonly emailService: EmailService,
private readonly twentyConfigService: TwentyConfigService,
- private readonly userService: UserService,
private readonly emailVerificationTokenService: EmailVerificationTokenService,
private readonly i18nService: I18nService,
) {}
- async sendVerificationEmail(
- userId: string,
- email: string,
- workspace: WorkspaceDomainConfig | undefined,
- locale: keyof typeof APP_LOCALES,
- verifyEmailRedirectPath?: string,
- ) {
+ async sendVerificationEmail({
+ userId,
+ email,
+ workspace,
+ locale,
+ verifyEmailRedirectPath,
+ verificationTrigger = EmailVerificationTrigger.SIGN_UP,
+ }: {
+ userId: string;
+ email: string;
+ workspace: WorkspaceDomainConfig | undefined;
+ locale: keyof typeof APP_LOCALES;
+ verifyEmailRedirectPath?: string;
+ verificationTrigger?: EmailVerificationTrigger;
+ }) {
if (!this.twentyConfigService.get('IS_EMAIL_VERIFICATION_REQUIRED')) {
return { success: false };
}
@@ -78,6 +88,8 @@ export class EmailVerificationService {
const emailData = {
link: verificationLink.toString(),
locale,
+ isEmailUpdate:
+ verificationTrigger === EmailVerificationTrigger.EMAIL_UPDATE,
};
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
@@ -87,7 +99,10 @@ export class EmailVerificationService {
plainText: true,
});
- const emailVerificationMsg = msg`Welcome to Twenty: Please Confirm Your Email`;
+ const emailVerificationMsg =
+ verificationTrigger === EmailVerificationTrigger.EMAIL_UPDATE
+ ? msg`Please confirm your updated email`
+ : msg`Welcome to Twenty: Please Confirm Your Email`;
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(emailVerificationMsg);
@@ -116,7 +131,14 @@ export class EmailVerificationService {
);
}
- const user = await this.userService.findUserByEmailOrThrow(email);
+ // TODO: Remove the dependency on querying user altogether when the endpoint is authenticated.
+ const user = await this.userRepository.findOne({
+ where: {
+ email,
+ },
+ });
+
+ assertIsDefinedOrThrow(user);
if (user.isEmailVerified) {
throw new EmailVerificationException(
@@ -149,7 +171,13 @@ export class EmailVerificationService {
await this.appTokenRepository.delete(existingToken.id);
}
- await this.sendVerificationEmail(user.id, email, workspace, locale);
+ await this.sendVerificationEmail({
+ userId: user.id,
+ email,
+ workspace,
+ locale,
+ verificationTrigger: EmailVerificationTrigger.SIGN_UP,
+ });
return { success: true };
}
diff --git a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
index 82c8686b744..15a32320cf7 100644
--- a/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/message-queue/jobs.module.ts
@@ -12,6 +12,7 @@ import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.modu
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
+import { UpdateWorkspaceMemberEmailJob } from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookJobModule } from 'src/engine/core-modules/webhook/jobs/webhook-job.module';
@@ -74,6 +75,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
UpdateSubscriptionQuantityJob,
HandleWorkspaceMemberDeletedJob,
CleanWorkspaceDeletionWarningUserVarsJob,
+ UpdateWorkspaceMemberEmailJob,
],
})
export class JobsModule {
diff --git a/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts b/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts
new file mode 100644
index 00000000000..4a4db6a618d
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/user/dtos/update-user-email.input.ts
@@ -0,0 +1,16 @@
+import { ArgsType, Field } from '@nestjs/graphql';
+
+import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
+@ArgsType()
+export class UpdateUserEmailInput {
+ @Field(() => String)
+ @IsNotEmpty()
+ @IsEmail()
+ newEmail: string;
+
+ @Field({ nullable: true })
+ @IsOptional()
+ @IsString()
+ verifyEmailRedirectPath?: string;
+}
diff --git a/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts b/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts
new file mode 100644
index 00000000000..04dccfddbd0
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/user/jobs/update-workspace-member-email.job.ts
@@ -0,0 +1,44 @@
+import { Logger, Scope } from '@nestjs/common';
+
+import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
+import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
+
+export type UpdateWorkspaceMemberEmailJobData = {
+ userId: string;
+ email: string;
+};
+
+@Processor({
+ queueName: MessageQueue.workspaceQueue,
+ scope: Scope.REQUEST,
+})
+export class UpdateWorkspaceMemberEmailJob {
+ private readonly logger = new Logger(UpdateWorkspaceMemberEmailJob.name);
+
+ constructor(
+ private readonly userWorkspaceService: UserWorkspaceService,
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
+
+ @Process(UpdateWorkspaceMemberEmailJob.name)
+ async handle({
+ userId,
+ email,
+ }: UpdateWorkspaceMemberEmailJobData): Promise {
+ const workspace =
+ await this.userWorkspaceService.findFirstWorkspaceByUserId(userId);
+
+ const workspaceMemberRepository =
+ await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ workspace.id,
+ 'workspaceMember',
+ { shouldBypassPermissionChecks: true },
+ );
+
+ await workspaceMemberRepository.update({ userId }, { userEmail: email });
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts
index d2bb5326ae8..430f3f404b9 100644
--- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.spec.ts
@@ -6,6 +6,9 @@ import { type Repository, type UpdateResult } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
+import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@@ -51,6 +54,21 @@ describe('UserService', () => {
provide: WorkspaceService,
useValue: { deleteWorkspace: jest.fn() },
},
+ {
+ provide: WorkspaceDomainsService,
+ useValue: {
+ getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain:
+ jest.fn(),
+ },
+ },
+ {
+ provide: EmailVerificationService,
+ useValue: { sendVerificationEmail: jest.fn() },
+ },
+ {
+ provide: `MESSAGE_QUEUE_${MessageQueue.workspaceQueue}`,
+ useValue: { add: jest.fn() },
+ },
{
provide: TwentyORMGlobalManager,
useValue: {
diff --git a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
index 3937c53b55b..78128c2f1d4 100644
--- a/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
@@ -4,6 +4,7 @@ import assert from 'assert';
import { msg } from '@lingui/core/macro';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { type QueryRunner, IsNull, Not, Repository } from 'typeorm';
@@ -12,9 +13,21 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
+import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
+import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
+import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
+import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
+import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
+import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
+import {
+ UpdateWorkspaceMemberEmailJob,
+ UpdateWorkspaceMemberEmailJobData,
+} from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
+import { UserExceptionCode } from 'src/engine/core-modules/user/user.exception';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -32,10 +45,14 @@ export class UserService extends TypeOrmQueryService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository,
+ private readonly workspaceDomainsService: WorkspaceDomainsService,
+ private readonly emailVerificationService: EmailVerificationService,
private readonly workspaceService: WorkspaceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly userRoleService: UserRoleService,
private readonly userWorkspaceService: UserWorkspaceService,
+ @InjectMessageQueue(MessageQueue.workspaceQueue)
+ private readonly workspaceQueueService: MessageQueueService,
) {
super(userRepository);
}
@@ -284,4 +301,94 @@ export class UserService extends TypeOrmQueryService {
? await queryRunner.manager.save(UserEntity, user)
: await this.userRepository.save(user);
}
+
+ async updateEmailFromVerificationToken(userId: string, email: string) {
+ const user = await this.findUserByIdOrThrow(userId);
+
+ user.email = email;
+
+ const updatedUser = await this.userRepository.save(user);
+
+ await this.enqueueWorkspaceMemberEmailUpdate({
+ userId: user.id,
+ email,
+ });
+
+ return updatedUser;
+ }
+
+ async updateUserEmail({
+ user,
+ workspace,
+ newEmail,
+ verifyEmailRedirectPath,
+ }: {
+ user: UserEntity;
+ workspace: WorkspaceEntity;
+ newEmail: string;
+ verifyEmailRedirectPath?: string;
+ }): Promise {
+ const normalizedEmail = newEmail.trim().toLowerCase();
+
+ if (normalizedEmail === user.email) {
+ throw new UserInputError(
+ 'New email must be different from current email',
+ {
+ subCode: UserExceptionCode.EMAIL_UNCHANGED,
+ userFriendlyMessage: msg`New email must be different from current email`,
+ },
+ );
+ }
+
+ const userWorkspaceCount =
+ await this.userWorkspaceService.countUserWorkspaces(user.id);
+
+ if (userWorkspaceCount > 1) {
+ throw new UserInputError(
+ 'Email updates are available only for users with a single workspace',
+ {
+ subCode:
+ UserExceptionCode.EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE,
+ userFriendlyMessage: msg`Email can only be updated when you belong to a single workspace.`,
+ },
+ );
+ }
+
+ const existingUser = await this.userRepository.findOne({
+ where: { email: normalizedEmail },
+ });
+
+ if (existingUser && existingUser.id !== user.id) {
+ throw new UserInputError('Email already in use', {
+ subCode: UserExceptionCode.EMAIL_ALREADY_IN_USE,
+ userFriendlyMessage: msg`Email already in use`,
+ });
+ }
+
+ const workspaceDomainConfig =
+ this.workspaceDomainsService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
+ workspace,
+ );
+
+ await this.emailVerificationService.sendVerificationEmail({
+ userId: user.id,
+ email: normalizedEmail,
+ workspace: workspaceDomainConfig,
+ locale: user.locale || SOURCE_LOCALE,
+ verifyEmailRedirectPath,
+ verificationTrigger: EmailVerificationTrigger.EMAIL_UPDATE,
+ });
+ }
+
+ async enqueueWorkspaceMemberEmailUpdate(
+ data: UpdateWorkspaceMemberEmailJobData,
+ ) {
+ await this.workspaceQueueService.add(
+ UpdateWorkspaceMemberEmailJob.name,
+ data,
+ {
+ retryLimit: 2,
+ },
+ );
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
index 1f0d01ea48a..d4870b646ad 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.entity.ts
@@ -1,7 +1,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
-import { SOURCE_LOCALE } from 'twenty-shared/translations';
+import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import {
BeforeInsert,
BeforeUpdate,
@@ -95,8 +95,8 @@ export class UserEntity {
deletedAt: Date;
@Field(() => String, { nullable: false })
- @Column({ nullable: false, default: SOURCE_LOCALE })
- locale: string;
+ @Column({ nullable: false, default: SOURCE_LOCALE, type: 'varchar' })
+ locale: keyof typeof APP_LOCALES;
@OneToMany(() => AppTokenEntity, (appToken) => appToken.user, {
cascade: true,
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.exception.ts b/packages/twenty-server/src/engine/core-modules/user/user.exception.ts
index 39363339c29..f88873020ee 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.exception.ts
@@ -4,4 +4,7 @@ export class UserException extends CustomException {}
export enum UserExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
+ EMAIL_ALREADY_IN_USE = 'EMAIL_ALREADY_IN_USE',
+ EMAIL_UNCHANGED = 'EMAIL_UNCHANGED',
+ EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE = 'EMAIL_UPDATE_RESTRICTED_TO_SINGLE_WORKSPACE',
}
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.module.ts b/packages/twenty-server/src/engine/core-modules/user/user.module.ts
index e5fa0c42db0..87ca3b25247 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.module.ts
@@ -22,6 +22,8 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
+import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
+import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { userAutoResolverOpts } from './user.auto-resolver-opts';
@@ -49,6 +51,8 @@ import { UserService } from './services/user.service';
UserRoleModule,
FeatureFlagModule,
PermissionsModule,
+ EmailVerificationModule,
+ WorkspaceDomainsModule,
],
exports: [UserService, WorkspaceMemberTranspiler],
providers: [UserService, UserResolver, WorkspaceMemberTranspiler],
diff --git a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
index e33e789cfcc..0bac4d1f24a 100644
--- a/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
+++ b/packages/twenty-server/src/engine/core-modules/user/user.resolver.ts
@@ -38,6 +38,7 @@ import { buildTwoFactorAuthenticationMethodSummary } from 'src/engine/core-modul
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { DeletedWorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
+import { UpdateUserEmailInput } from 'src/engine/core-modules/user/dtos/update-user-email.input';
import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import {
@@ -56,6 +57,7 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
+import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@@ -97,7 +99,6 @@ export class UserResolver {
private readonly userWorkspaceRepository: Repository,
private readonly userRoleService: UserRoleService,
private readonly permissionsService: PermissionsService,
-
private readonly workspaceMemberTranspiler: WorkspaceMemberTranspiler,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
@@ -512,4 +513,34 @@ export class UserResolver {
authProvider,
);
}
+
+ @Mutation(() => Boolean)
+ @UseGuards(
+ UserAuthGuard,
+ WorkspaceAuthGuard,
+ SettingsPermissionGuard(PermissionFlagType.PROFILE_INFORMATION),
+ )
+ async updateUserEmail(
+ @Args() { newEmail, verifyEmailRedirectPath }: UpdateUserEmailInput,
+ @AuthUser() user: UserEntity,
+ @AuthWorkspace() workspace: WorkspaceEntity,
+ ) {
+ const editableFields = workspace.editableProfileFields || [];
+
+ if (!editableFields.includes('email')) {
+ throw new PermissionsException(
+ PermissionsExceptionMessage.PERMISSION_DENIED,
+ PermissionsExceptionCode.PERMISSION_DENIED,
+ );
+ }
+
+ await this.userService.updateUserEmail({
+ user,
+ workspace,
+ newEmail,
+ verifyEmailRedirectPath,
+ });
+
+ return true;
+ }
}
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts
index eef8295e9d5..3d2d1244cc7 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts
@@ -1,6 +1,7 @@
import { Field, InputType } from '@nestjs/graphql';
import {
+ IsArray,
IsBoolean,
IsInt,
IsOptional,
@@ -102,4 +103,10 @@ export class UpdateWorkspaceInput {
@IsString()
@IsOptional()
routerModel?: string;
+
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ editableProfileFields?: string[];
}
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
index ac5f3076bbc..e84b9402c80 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts
@@ -69,6 +69,7 @@ export class WorkspaceService extends TypeOrmQueryService {
isGoogleAuthEnabled: PermissionFlagType.SECURITY,
isMicrosoftAuthEnabled: PermissionFlagType.SECURITY,
isPasswordAuthEnabled: PermissionFlagType.SECURITY,
+ editableProfileFields: PermissionFlagType.SECURITY,
isTwoFactorAuthenticationEnforced: PermissionFlagType.SECURITY,
defaultRoleId: PermissionFlagType.ROLES,
routerModel: PermissionFlagType.WORKSPACE,
diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts
index d9edd1c7055..b06800e9501 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts
@@ -262,6 +262,15 @@ export class WorkspaceEntity {
@Column({ default: false })
isCustomDomainEnabled: boolean;
+ @Field(() => [String], { nullable: true })
+ @Column({
+ type: 'varchar',
+ array: true,
+ nullable: true,
+ default: '{email,profilePicture,firstName,lastName}',
+ })
+ editableProfileFields: string[] | null;
+
// TODO: set as non nullable
@Column({ nullable: true, type: 'uuid' })
defaultRoleId: string | null;
diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
index aaa47ae5feb..cc15821028c 100644
--- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/permission-flag-type.constants.ts
@@ -23,4 +23,5 @@ export enum PermissionFlagType {
IMPORT_CSV = 'IMPORT_CSV',
EXPORT_CSV = 'EXPORT_CSV',
CONNECTED_ACCOUNTS = 'CONNECTED_ACCOUNTS',
+ PROFILE_INFORMATION = 'PROFILE_INFORMATION',
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts
index b42ee0252a7..4fb9521b5b4 100644
--- a/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/permissions/constants/tool-permission-flags.ts
@@ -7,4 +7,5 @@ export const TOOL_PERMISSION_FLAGS = [
'IMPORT_CSV',
'EXPORT_CSV',
'CONNECTED_ACCOUNTS',
+ 'PROFILE_INFORMATION',
];
diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
index 751cd2ba16d..1ddd0d26436 100644
--- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts
@@ -114,6 +114,7 @@ export class PermissionsService {
[PermissionFlagType.CONNECTED_ACCOUNTS]: false,
[PermissionFlagType.IMPERSONATE]: false,
[PermissionFlagType.SSO_BYPASS]: false,
+ [PermissionFlagType.PROFILE_INFORMATION]: false,
},
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;