From c40e64773a1d563a502bc6178cf2353415160657 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 2 Oct 2023 16:50:45 +0800 Subject: [PATCH 1/5] convert to email api --- packages/api/src/generated/graphql.ts | 55 +++++++++++++++++++ packages/api/src/generated/schema.graphql | 21 +++++++ .../api/src/resolvers/function_resolvers.ts | 5 +- packages/api/src/resolvers/types.ts | 1 + packages/api/src/resolvers/user/index.ts | 40 +++++++++++++- packages/api/src/routers/auth/auth_router.ts | 14 +++-- packages/api/src/schema.ts | 21 +++++++ packages/api/src/services/send_emails.ts | 28 +++++++++- packages/api/src/util.ts | 3 + packages/api/src/utils/auth.ts | 14 +++-- packages/api/src/utils/createTask.ts | 4 +- 11 files changed, 190 insertions(+), 16 deletions(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 9f730a73b..c533150d5 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -252,6 +252,28 @@ export enum ContentReader { Web = 'WEB' } +export type ConvertToEmailError = { + __typename?: 'ConvertToEmailError'; + errorCodes: Array; +}; + +export enum ConvertToEmailErrorCode { + BadRequest = 'BAD_REQUEST', + EmailAlreadyExists = 'EMAIL_ALREADY_EXISTS', + Unauthorized = 'UNAUTHORIZED' +} + +export type ConvertToEmailInput = { + email: Scalars['String']; +}; + +export type ConvertToEmailResult = ConvertToEmailError | ConvertToEmailSuccess; + +export type ConvertToEmailSuccess = { + __typename?: 'ConvertToEmailSuccess'; + success: Scalars['Boolean']; +}; + export type CreateArticleError = { __typename?: 'CreateArticleError'; errorCodes: Array; @@ -1235,6 +1257,7 @@ export type Mutation = { __typename?: 'Mutation'; addPopularRead: AddPopularReadResult; bulkAction: BulkActionResult; + convertToEmail: ConvertToEmailResult; createArticle: CreateArticleResult; createArticleSavingRequest: CreateArticleSavingRequestResult; createGroup: CreateGroupResult; @@ -1321,6 +1344,11 @@ export type MutationBulkActionArgs = { }; +export type MutationConvertToEmailArgs = { + input: ConvertToEmailInput; +}; + + export type MutationCreateArticleArgs = { input: CreateArticleInput; }; @@ -3442,6 +3470,11 @@ export type ResolversTypes = { BulkActionSuccess: ResolverTypeWrapper; BulkActionType: BulkActionType; ContentReader: ContentReader; + ConvertToEmailError: ResolverTypeWrapper; + ConvertToEmailErrorCode: ConvertToEmailErrorCode; + ConvertToEmailInput: ConvertToEmailInput; + ConvertToEmailResult: ResolversTypes['ConvertToEmailError'] | ResolversTypes['ConvertToEmailSuccess']; + ConvertToEmailSuccess: ResolverTypeWrapper; CreateArticleError: ResolverTypeWrapper; CreateArticleErrorCode: CreateArticleErrorCode; CreateArticleInput: CreateArticleInput; @@ -3936,6 +3969,10 @@ export type ResolversParentTypes = { BulkActionError: BulkActionError; BulkActionResult: ResolversParentTypes['BulkActionError'] | ResolversParentTypes['BulkActionSuccess']; BulkActionSuccess: BulkActionSuccess; + ConvertToEmailError: ConvertToEmailError; + ConvertToEmailInput: ConvertToEmailInput; + ConvertToEmailResult: ResolversParentTypes['ConvertToEmailError'] | ResolversParentTypes['ConvertToEmailSuccess']; + ConvertToEmailSuccess: ConvertToEmailSuccess; CreateArticleError: CreateArticleError; CreateArticleInput: CreateArticleInput; CreateArticleResult: ResolversParentTypes['CreateArticleError'] | ResolversParentTypes['CreateArticleSuccess']; @@ -4482,6 +4519,20 @@ export type BulkActionSuccessResolvers; }; +export type ConvertToEmailErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type ConvertToEmailResultResolvers = { + __resolveType: TypeResolveFn<'ConvertToEmailError' | 'ConvertToEmailSuccess', ParentType, ContextType>; +}; + +export type ConvertToEmailSuccessResolvers = { + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type CreateArticleErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5191,6 +5242,7 @@ export type MoveLabelSuccessResolvers = { addPopularRead?: Resolver>; bulkAction?: Resolver>; + convertToEmail?: Resolver>; createArticle?: Resolver>; createArticleSavingRequest?: Resolver>; createGroup?: Resolver>; @@ -6282,6 +6334,9 @@ export type Resolvers = { BulkActionError?: BulkActionErrorResolvers; BulkActionResult?: BulkActionResultResolvers; BulkActionSuccess?: BulkActionSuccessResolvers; + ConvertToEmailError?: ConvertToEmailErrorResolvers; + ConvertToEmailResult?: ConvertToEmailResultResolvers; + ConvertToEmailSuccess?: ConvertToEmailSuccessResolvers; CreateArticleError?: CreateArticleErrorResolvers; CreateArticleResult?: CreateArticleResultResolvers; CreateArticleSavingRequestError?: CreateArticleSavingRequestErrorResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index a9347f774..1cb0af004 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -211,6 +211,26 @@ enum ContentReader { WEB } +type ConvertToEmailError { + errorCodes: [ConvertToEmailErrorCode!]! +} + +enum ConvertToEmailErrorCode { + BAD_REQUEST + EMAIL_ALREADY_EXISTS + UNAUTHORIZED +} + +input ConvertToEmailInput { + email: String! +} + +union ConvertToEmailResult = ConvertToEmailError | ConvertToEmailSuccess + +type ConvertToEmailSuccess { + success: Boolean! +} + type CreateArticleError { errorCodes: [CreateArticleErrorCode!]! } @@ -1103,6 +1123,7 @@ type MoveLabelSuccess { type Mutation { addPopularRead(name: String!): AddPopularReadResult! bulkAction(action: BulkActionType!, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult! + convertToEmail(input: ConvertToEmailInput!): ConvertToEmailResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! createGroup(input: CreateGroupInput!): CreateGroupResult! diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index b10dae9cb..a76642524 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -41,7 +41,6 @@ import { createReminderResolver, deleteAccountResolver, deleteFilterResolver, - updateFilterResolver, deleteHighlightResolver, deleteIntegrationResolver, deleteLabelResolver, @@ -105,6 +104,7 @@ import { subscriptionsResolver, typeaheadSearchResolver, unsubscribeResolver, + updateFilterResolver, updateHighlightResolver, updateLabelResolver, updateLinkShareInfoResolver, @@ -124,6 +124,7 @@ import { createReactionResolver, deleteReactionResolver } from './reaction' import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails' import { recentSearchesResolver } from './recent_searches' import { Claims, WithDataSourcesContext } from './types' +import { convertToEmailResolver } from './user' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -212,6 +213,7 @@ export const functionResolvers = { setFavoriteArticle: setFavoriteArticleResolver, updateSubscription: updateSubscriptionResolver, updateFilter: updateFilterResolver, + convertToEmail: convertToEmailResolver, }, Query: { me: getMeUserResolver, @@ -678,4 +680,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('ImportFromIntegration'), ...resultResolveTypeResolver('SetFavoriteArticle'), ...resultResolveTypeResolver('UpdateSubscription'), + ...resultResolveTypeResolver('ConvertToEmail'), } diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index 5e7aa2d00..2a26a3ff4 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -22,6 +22,7 @@ export interface Claims { userRole?: string scope?: string // scope is used for api key like page:search exp?: number + email?: string } export type ClaimsToSet = { diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index f010a3dc8..03336f9b5 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -1,9 +1,13 @@ import * as jwt from 'jsonwebtoken' +import { Not } from 'typeorm' import { deletePagesByParam } from '../../elastic/pages' import { User as UserEntity } from '../../entity/user' -import { setClaims } from '../../entity/utils' +import { getRepository, setClaims } from '../../entity/utils' import { env } from '../../env' import { + ConvertToEmailError, + ConvertToEmailErrorCode, + ConvertToEmailSuccess, DeleteAccountError, DeleteAccountErrorCode, DeleteAccountSuccess, @@ -12,6 +16,7 @@ import { LoginResult, LogOutErrorCode, LogOutResult, + MutationConvertToEmailArgs, MutationDeleteAccountArgs, MutationGoogleLoginArgs, MutationGoogleSignupArgs, @@ -35,6 +40,7 @@ import { } from '../../generated/graphql' import { AppDataSource } from '../../server' import { createUser } from '../../services/create_user' +import { sendVerificationEmail } from '../../services/send_emails' import { authorized, userDataToUser } from '../../utils/helpers' import { validateUsername } from '../../utils/usernamePolicy' import { WithDataSourcesContext } from '../types' @@ -337,3 +343,35 @@ export const deleteAccountResolver = authorized< return { userID } }) + +export const convertToEmailResolver = authorized< + ConvertToEmailSuccess, + ConvertToEmailError, + MutationConvertToEmailArgs +>(async (_, { input: { email } }, { uid, log }) => { + try { + const user = await getRepository(UserEntity).findOneBy({ + id: uid, + source: Not('EMAIL'), + }) + + if (!user) { + return { + errorCodes: [ConvertToEmailErrorCode.Unauthorized], + } + } + + const result = await sendVerificationEmail({ + id: user.id, + name: user.name, + email, + }) + + return { success: result } + } catch (error) { + log.error('Error converting user to email', error) + return { + errorCodes: [ConvertToEmailErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 381d974f0..7b95085d3 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -32,6 +32,7 @@ import { sendConfirmationEmail, sendPasswordResetEmail, } from '../../services/send_emails' +import { analytics } from '../../utils/analytics' import { comparePassword, getClaimsByToken, @@ -51,7 +52,6 @@ import { } from './google_auth' import { createWebAuthToken } from './jwt_helpers' import { createMobileAccountCreationResponse } from './mobile/account_creation' -import { analytics } from '../../utils/analytics' export interface SignupRequest { email: string @@ -670,7 +670,10 @@ export function authRouter() { ) } - const user = await getRepository(User).findOneBy({ id: claims.uid }) + const user = await getRepository(User).findOneBy({ + id: claims.uid, + source: 'EMAIL', + }) if (!user) { return res.redirect( `${env.client.url}/auth/reset-password/${token}?errorCodes=USER_NOT_FOUND` @@ -687,9 +690,10 @@ export function authRouter() { const updated = await AppDataSource.transaction( async (entityManager) => { await setClaims(entityManager, user.id) - return entityManager - .getRepository(User) - .update({ id: user.id }, { password: hashedPassword }) + return entityManager.getRepository(User).update(user.id, { + password: hashedPassword, + email: claims.email, + }) } ) if (!updated.affected) { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 305a75528..ba6bc424f 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2567,6 +2567,26 @@ const schema = gql` NOT_FOUND } + union ConvertToEmailResult = ConvertToEmailSuccess | ConvertToEmailError + + type ConvertToEmailSuccess { + success: Boolean! + } + + type ConvertToEmailError { + errorCodes: [ConvertToEmailErrorCode!]! + } + + enum ConvertToEmailErrorCode { + UNAUTHORIZED + BAD_REQUEST + EMAIL_ALREADY_EXISTS + } + + input ConvertToEmailInput { + email: String! + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -2575,6 +2595,7 @@ const schema = gql` deleteAccount(userID: ID!): DeleteAccountResult! updateUser(input: UpdateUserInput!): UpdateUserResult! updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! + convertToEmail(input: ConvertToEmailInput!): ConvertToEmailResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createHighlight(input: CreateHighlightInput!): CreateHighlightResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! diff --git a/packages/api/src/services/send_emails.ts b/packages/api/src/services/send_emails.ts index 2ebefd3d1..9a81dab55 100644 --- a/packages/api/src/services/send_emails.ts +++ b/packages/api/src/services/send_emails.ts @@ -1,5 +1,5 @@ -import { generateVerificationToken } from '../utils/auth' import { env } from '../env' +import { generateVerificationToken } from '../utils/auth' import { sendEmail } from '../utils/sendEmail' export const sendConfirmationEmail = async (user: { @@ -8,7 +8,7 @@ export const sendConfirmationEmail = async (user: { email: string }): Promise => { // generate confirmation link - const token = generateVerificationToken(user.id) + const token = generateVerificationToken({ id: user.id }) const link = `${env.client.url}/auth/confirm-email/${token}` // send email const dynamicTemplateData = { @@ -24,13 +24,35 @@ export const sendConfirmationEmail = async (user: { }) } +export const sendVerificationEmail = async (user: { + id: string + name: string + email: string +}): Promise => { + // generate verification link + const token = generateVerificationToken({ id: user.id, email: user.email }) + const link = `${env.client.url}/auth/convert-to-email/${token}` + // send email + const dynamicTemplateData = { + name: user.name, + link, + } + + return sendEmail({ + from: env.sender.message, + to: user.email, + templateId: env.sendgrid.verificationTemplateId, + dynamicTemplateData, + }) +} + export const sendPasswordResetEmail = async (user: { id: string name: string email: string }): Promise => { // generate link - const token = generateVerificationToken(user.id) + const token = generateVerificationToken({ id: user.id }) const link = `${env.client.url}/auth/reset-password/${token}` // send email const dynamicTemplateData = { diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 039785d9e..8f0601356 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -89,6 +89,7 @@ interface BackendEnv { reminderTemplateId: string resetPasswordTemplateId: string installationTemplateId: string + verificationTemplateId: string } readwise: { apiUrl: string @@ -163,6 +164,7 @@ const nullableEnvVars = [ 'POCKET_CONSUMER_KEY', 'THUMBNAIL_TASK_HANDLER_URL', 'RSS_FEED_TASK_HANDLER_URL', + 'SENDGRID_VERIFICATION_TEMPLATE_ID', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -280,6 +282,7 @@ export function getEnv(): BackendEnv { reminderTemplateId: parse('SENDGRID_REMINDER_TEMPLATE_ID'), resetPasswordTemplateId: parse('SENDGRID_RESET_PASSWORD_TEMPLATE_ID'), installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'), + verificationTemplateId: parse('SENDGRID_VERIFICATION_TEMPLATE_ID'), } const readwise = { diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index dbb94c46d..951bf6300 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -91,15 +91,21 @@ export const getClaimsByToken = async ( } export const generateVerificationToken = ( - userId: string, - expireInDays = 1 + user: { + id: string + email?: string + }, + expireInSeconds = 60 * 60 * 24 // 1 day ): string => { const iat = Math.floor(Date.now() / 1000) const exp = Math.floor( - new Date(Date.now() + 1000 * 60 * 60 * 24 * expireInDays).getTime() / 1000 + new Date(Date.now() + expireInSeconds * 1000).getTime() / 1000 ) - return jwt.sign({ uid: userId, iat, exp }, env.server.jwtSecret) + return jwt.sign( + { uid: user.id, iat, exp, email: user.email }, + env.server.jwtSecret + ) } export const setAuthInCookie = async ( diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 273eaee94..e473c17fe 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -546,7 +546,7 @@ export const enqueueThumbnailTask = async ( } const headers = { - Cookie: `auth=${generateVerificationToken(userId)}`, + Cookie: `auth=${generateVerificationToken({ id: userId })}`, } // If there is no Google Cloud Project Id exposed, it means that we are in local environment @@ -593,7 +593,7 @@ export const enqueueRssFeedFetch = async ( } const headers = { - [OmnivoreAuthorizationHeader]: generateVerificationToken(userId), + [OmnivoreAuthorizationHeader]: generateVerificationToken({ id: userId }), } // If there is no Google Cloud Project Id exposed, it means that we are in local environment From ee50529f958dd683403dc3d08a70707052ad9c9e Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 2 Oct 2023 22:21:50 +0800 Subject: [PATCH 2/5] add update email form on account page --- packages/api/src/generated/graphql.ts | 116 +++++++++--------- packages/api/src/generated/schema.graphql | 45 +++---- .../api/src/resolvers/function_resolvers.ts | 6 +- packages/api/src/resolvers/user/index.ts | 43 ++++--- packages/api/src/routers/auth/auth_router.ts | 7 +- packages/api/src/schema.ts | 19 +-- packages/api/src/services/send_emails.ts | 2 +- .../mutations/updateEmailMutation.ts | 45 +++++++ .../networking/queries/useGetViewerQuery.tsx | 4 + packages/web/pages/settings/account.tsx | 96 +++++++++++++-- 10 files changed, 269 insertions(+), 114 deletions(-) create mode 100644 packages/web/lib/networking/mutations/updateEmailMutation.ts diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index c533150d5..a311b6b6d 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -252,28 +252,6 @@ export enum ContentReader { Web = 'WEB' } -export type ConvertToEmailError = { - __typename?: 'ConvertToEmailError'; - errorCodes: Array; -}; - -export enum ConvertToEmailErrorCode { - BadRequest = 'BAD_REQUEST', - EmailAlreadyExists = 'EMAIL_ALREADY_EXISTS', - Unauthorized = 'UNAUTHORIZED' -} - -export type ConvertToEmailInput = { - email: Scalars['String']; -}; - -export type ConvertToEmailResult = ConvertToEmailError | ConvertToEmailSuccess; - -export type ConvertToEmailSuccess = { - __typename?: 'ConvertToEmailSuccess'; - success: Scalars['Boolean']; -}; - export type CreateArticleError = { __typename?: 'CreateArticleError'; errorCodes: Array; @@ -1257,7 +1235,6 @@ export type Mutation = { __typename?: 'Mutation'; addPopularRead: AddPopularReadResult; bulkAction: BulkActionResult; - convertToEmail: ConvertToEmailResult; createArticle: CreateArticleResult; createArticleSavingRequest: CreateArticleSavingRequestResult; createGroup: CreateGroupResult; @@ -1314,6 +1291,7 @@ export type Mutation = { setWebhook: SetWebhookResult; subscribe: SubscribeResult; unsubscribe: UnsubscribeResult; + updateEmail: UpdateEmailResult; updateFilter: UpdateFilterResult; updateHighlight: UpdateHighlightResult; updateHighlightReply: UpdateHighlightReplyResult; @@ -1344,11 +1322,6 @@ export type MutationBulkActionArgs = { }; -export type MutationConvertToEmailArgs = { - input: ConvertToEmailInput; -}; - - export type MutationCreateArticleArgs = { input: CreateArticleInput; }; @@ -1620,6 +1593,11 @@ export type MutationUnsubscribeArgs = { }; +export type MutationUpdateEmailArgs = { + input: UpdateEmailInput; +}; + + export type MutationUpdateFilterArgs = { input: UpdateFilterInput; }; @@ -2873,6 +2851,29 @@ export type UnsubscribeSuccess = { subscription: Subscription; }; +export type UpdateEmailError = { + __typename?: 'UpdateEmailError'; + errorCodes: Array; +}; + +export enum UpdateEmailErrorCode { + BadRequest = 'BAD_REQUEST', + EmailAlreadyExists = 'EMAIL_ALREADY_EXISTS', + Unauthorized = 'UNAUTHORIZED' +} + +export type UpdateEmailInput = { + email: Scalars['String']; +}; + +export type UpdateEmailResult = UpdateEmailError | UpdateEmailSuccess; + +export type UpdateEmailSuccess = { + __typename?: 'UpdateEmailSuccess'; + email: Scalars['String']; + verificationEmailSent?: Maybe; +}; + export type UpdateFilterError = { __typename?: 'UpdateFilterError'; errorCodes: Array; @@ -3239,6 +3240,7 @@ export enum UploadImportFileType { export type User = { __typename?: 'User'; + email?: Maybe; followersCount?: Maybe; friendsCount?: Maybe; id: Scalars['ID']; @@ -3252,6 +3254,7 @@ export type User = { sharedArticlesCount?: Maybe; sharedHighlightsCount?: Maybe; sharedNotesCount?: Maybe; + source?: Maybe; viewerIsFollowing?: Maybe; }; @@ -3470,11 +3473,6 @@ export type ResolversTypes = { BulkActionSuccess: ResolverTypeWrapper; BulkActionType: BulkActionType; ContentReader: ContentReader; - ConvertToEmailError: ResolverTypeWrapper; - ConvertToEmailErrorCode: ConvertToEmailErrorCode; - ConvertToEmailInput: ConvertToEmailInput; - ConvertToEmailResult: ResolversTypes['ConvertToEmailError'] | ResolversTypes['ConvertToEmailSuccess']; - ConvertToEmailSuccess: ResolverTypeWrapper; CreateArticleError: ResolverTypeWrapper; CreateArticleErrorCode: CreateArticleErrorCode; CreateArticleInput: CreateArticleInput; @@ -3846,6 +3844,11 @@ export type ResolversTypes = { UnsubscribeErrorCode: UnsubscribeErrorCode; UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess']; UnsubscribeSuccess: ResolverTypeWrapper; + UpdateEmailError: ResolverTypeWrapper; + UpdateEmailErrorCode: UpdateEmailErrorCode; + UpdateEmailInput: UpdateEmailInput; + UpdateEmailResult: ResolversTypes['UpdateEmailError'] | ResolversTypes['UpdateEmailSuccess']; + UpdateEmailSuccess: ResolverTypeWrapper; UpdateFilterError: ResolverTypeWrapper; UpdateFilterErrorCode: UpdateFilterErrorCode; UpdateFilterInput: UpdateFilterInput; @@ -3969,10 +3972,6 @@ export type ResolversParentTypes = { BulkActionError: BulkActionError; BulkActionResult: ResolversParentTypes['BulkActionError'] | ResolversParentTypes['BulkActionSuccess']; BulkActionSuccess: BulkActionSuccess; - ConvertToEmailError: ConvertToEmailError; - ConvertToEmailInput: ConvertToEmailInput; - ConvertToEmailResult: ResolversParentTypes['ConvertToEmailError'] | ResolversParentTypes['ConvertToEmailSuccess']; - ConvertToEmailSuccess: ConvertToEmailSuccess; CreateArticleError: CreateArticleError; CreateArticleInput: CreateArticleInput; CreateArticleResult: ResolversParentTypes['CreateArticleError'] | ResolversParentTypes['CreateArticleSuccess']; @@ -4263,6 +4262,10 @@ export type ResolversParentTypes = { UnsubscribeError: UnsubscribeError; UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess']; UnsubscribeSuccess: UnsubscribeSuccess; + UpdateEmailError: UpdateEmailError; + UpdateEmailInput: UpdateEmailInput; + UpdateEmailResult: ResolversParentTypes['UpdateEmailError'] | ResolversParentTypes['UpdateEmailSuccess']; + UpdateEmailSuccess: UpdateEmailSuccess; UpdateFilterError: UpdateFilterError; UpdateFilterInput: UpdateFilterInput; UpdateFilterResult: ResolversParentTypes['UpdateFilterError'] | ResolversParentTypes['UpdateFilterSuccess']; @@ -4519,20 +4522,6 @@ export type BulkActionSuccessResolvers; }; -export type ConvertToEmailErrorResolvers = { - errorCodes?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type ConvertToEmailResultResolvers = { - __resolveType: TypeResolveFn<'ConvertToEmailError' | 'ConvertToEmailSuccess', ParentType, ContextType>; -}; - -export type ConvertToEmailSuccessResolvers = { - success?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type CreateArticleErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -5242,7 +5231,6 @@ export type MoveLabelSuccessResolvers = { addPopularRead?: Resolver>; bulkAction?: Resolver>; - convertToEmail?: Resolver>; createArticle?: Resolver>; createArticleSavingRequest?: Resolver>; createGroup?: Resolver>; @@ -5299,6 +5287,7 @@ export type MutationResolvers>; subscribe?: Resolver>; unsubscribe?: Resolver>; + updateEmail?: Resolver>; updateFilter?: Resolver>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; @@ -6007,6 +5996,21 @@ export type UnsubscribeSuccessResolvers; }; +export type UpdateEmailErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdateEmailResultResolvers = { + __resolveType: TypeResolveFn<'UpdateEmailError' | 'UpdateEmailSuccess', ParentType, ContextType>; +}; + +export type UpdateEmailSuccessResolvers = { + email?: Resolver; + verificationEmailSent?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateFilterErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6209,6 +6213,7 @@ export type UploadImportFileSuccessResolvers = { + email?: Resolver, ParentType, ContextType>; followersCount?: Resolver, ParentType, ContextType>; friendsCount?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -6221,6 +6226,7 @@ export type UserResolvers, ParentType, ContextType>; sharedHighlightsCount?: Resolver, ParentType, ContextType>; sharedNotesCount?: Resolver, ParentType, ContextType>; + source?: Resolver, ParentType, ContextType>; viewerIsFollowing?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -6334,9 +6340,6 @@ export type Resolvers = { BulkActionError?: BulkActionErrorResolvers; BulkActionResult?: BulkActionResultResolvers; BulkActionSuccess?: BulkActionSuccessResolvers; - ConvertToEmailError?: ConvertToEmailErrorResolvers; - ConvertToEmailResult?: ConvertToEmailResultResolvers; - ConvertToEmailSuccess?: ConvertToEmailSuccessResolvers; CreateArticleError?: CreateArticleErrorResolvers; CreateArticleResult?: CreateArticleResultResolvers; CreateArticleSavingRequestError?: CreateArticleSavingRequestErrorResolvers; @@ -6583,6 +6586,9 @@ export type Resolvers = { UnsubscribeError?: UnsubscribeErrorResolvers; UnsubscribeResult?: UnsubscribeResultResolvers; UnsubscribeSuccess?: UnsubscribeSuccessResolvers; + UpdateEmailError?: UpdateEmailErrorResolvers; + UpdateEmailResult?: UpdateEmailResultResolvers; + UpdateEmailSuccess?: UpdateEmailSuccessResolvers; UpdateFilterError?: UpdateFilterErrorResolvers; UpdateFilterResult?: UpdateFilterResultResolvers; UpdateFilterSuccess?: UpdateFilterSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 1cb0af004..1af5e9d49 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -211,26 +211,6 @@ enum ContentReader { WEB } -type ConvertToEmailError { - errorCodes: [ConvertToEmailErrorCode!]! -} - -enum ConvertToEmailErrorCode { - BAD_REQUEST - EMAIL_ALREADY_EXISTS - UNAUTHORIZED -} - -input ConvertToEmailInput { - email: String! -} - -union ConvertToEmailResult = ConvertToEmailError | ConvertToEmailSuccess - -type ConvertToEmailSuccess { - success: Boolean! -} - type CreateArticleError { errorCodes: [CreateArticleErrorCode!]! } @@ -1123,7 +1103,6 @@ type MoveLabelSuccess { type Mutation { addPopularRead(name: String!): AddPopularReadResult! bulkAction(action: BulkActionType!, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult! - convertToEmail(input: ConvertToEmailInput!): ConvertToEmailResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! createGroup(input: CreateGroupInput!): CreateGroupResult! @@ -1180,6 +1159,7 @@ type Mutation { setWebhook(input: SetWebhookInput!): SetWebhookResult! subscribe(input: SubscribeInput!): SubscribeResult! unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult! + updateEmail(input: UpdateEmailInput!): UpdateEmailResult! updateFilter(input: UpdateFilterInput!): UpdateFilterResult! updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult! @@ -2198,6 +2178,27 @@ type UnsubscribeSuccess { subscription: Subscription! } +type UpdateEmailError { + errorCodes: [UpdateEmailErrorCode!]! +} + +enum UpdateEmailErrorCode { + BAD_REQUEST + EMAIL_ALREADY_EXISTS + UNAUTHORIZED +} + +input UpdateEmailInput { + email: String! +} + +union UpdateEmailResult = UpdateEmailError | UpdateEmailSuccess + +type UpdateEmailSuccess { + email: String! + verificationEmailSent: Boolean +} + type UpdateFilterError { errorCodes: [UpdateFilterErrorCode!]! } @@ -2535,6 +2536,7 @@ enum UploadImportFileType { } type User { + email: String followersCount: Int friendsCount: Int id: ID! @@ -2547,6 +2549,7 @@ type User { sharedArticlesCount: Int sharedHighlightsCount: Int sharedNotesCount: Int + source: String viewerIsFollowing: Boolean } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index a76642524..9c62aeccf 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -124,7 +124,7 @@ import { createReactionResolver, deleteReactionResolver } from './reaction' import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails' import { recentSearchesResolver } from './recent_searches' import { Claims, WithDataSourcesContext } from './types' -import { convertToEmailResolver } from './user' +import { updateEmailResolver } from './user' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -213,7 +213,7 @@ export const functionResolvers = { setFavoriteArticle: setFavoriteArticleResolver, updateSubscription: updateSubscriptionResolver, updateFilter: updateFilterResolver, - convertToEmail: convertToEmailResolver, + updateEmail: updateEmailResolver, }, Query: { me: getMeUserResolver, @@ -680,5 +680,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('ImportFromIntegration'), ...resultResolveTypeResolver('SetFavoriteArticle'), ...resultResolveTypeResolver('UpdateSubscription'), - ...resultResolveTypeResolver('ConvertToEmail'), + ...resultResolveTypeResolver('UpdateEmail'), } diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index 03336f9b5..62b24a794 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -1,13 +1,10 @@ import * as jwt from 'jsonwebtoken' -import { Not } from 'typeorm' +import { RegistrationType } from '../../datalayer/user/model' import { deletePagesByParam } from '../../elastic/pages' import { User as UserEntity } from '../../entity/user' import { getRepository, setClaims } from '../../entity/utils' import { env } from '../../env' import { - ConvertToEmailError, - ConvertToEmailErrorCode, - ConvertToEmailSuccess, DeleteAccountError, DeleteAccountErrorCode, DeleteAccountSuccess, @@ -16,16 +13,19 @@ import { LoginResult, LogOutErrorCode, LogOutResult, - MutationConvertToEmailArgs, MutationDeleteAccountArgs, MutationGoogleLoginArgs, MutationGoogleSignupArgs, + MutationUpdateEmailArgs, MutationUpdateUserArgs, MutationUpdateUserProfileArgs, QueryUserArgs, QueryValidateUsernameArgs, ResolverFn, SignupErrorCode, + UpdateEmailError, + UpdateEmailErrorCode, + UpdateEmailSuccess, UpdateUserError, UpdateUserErrorCode, UpdateUserProfileError, @@ -344,34 +344,49 @@ export const deleteAccountResolver = authorized< return { userID } }) -export const convertToEmailResolver = authorized< - ConvertToEmailSuccess, - ConvertToEmailError, - MutationConvertToEmailArgs +export const updateEmailResolver = authorized< + UpdateEmailSuccess, + UpdateEmailError, + MutationUpdateEmailArgs >(async (_, { input: { email } }, { uid, log }) => { try { const user = await getRepository(UserEntity).findOneBy({ id: uid, - source: Not('EMAIL'), }) if (!user) { return { - errorCodes: [ConvertToEmailErrorCode.Unauthorized], + errorCodes: [UpdateEmailErrorCode.Unauthorized], } } + if (user.source === RegistrationType.Email) { + await AppDataSource.transaction(async (entityManager) => { + await setClaims(entityManager, user.id) + return entityManager.getRepository(UserEntity).update(user.id, { + email, + }) + }) + + return { email } + } + const result = await sendVerificationEmail({ id: user.id, name: user.name, email, }) + if (!result) { + return { + errorCodes: [UpdateEmailErrorCode.BadRequest], + } + } - return { success: result } + return { email, verificationEmailSent: true } } catch (error) { - log.error('Error converting user to email', error) + log.error('Error updating email', error) return { - errorCodes: [ConvertToEmailErrorCode.BadRequest], + errorCodes: [UpdateEmailErrorCode.BadRequest], } } }) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 7b95085d3..b0f6d821e 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -672,7 +672,6 @@ export function authRouter() { const user = await getRepository(User).findOneBy({ id: claims.uid, - source: 'EMAIL', }) if (!user) { return res.redirect( @@ -686,13 +685,17 @@ export function authRouter() { ) } + // check if email needs to be updated + const updateEmail = claims.email && claims.email !== user.email + const hashedPassword = await hashPassword(password) const updated = await AppDataSource.transaction( async (entityManager) => { await setClaims(entityManager, user.id) return entityManager.getRepository(User).update(user.id, { password: hashedPassword, - email: claims.email, + email: updateEmail ? claims.email : undefined, + source: updateEmail ? RegistrationType.Email : undefined, }) } ) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ba6bc424f..d0b161eb9 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -84,6 +84,8 @@ const schema = gql` sharedNotesCount: Int friendsCount: Int followersCount: Int + email: String + source: String } type Profile { @@ -2567,23 +2569,24 @@ const schema = gql` NOT_FOUND } - union ConvertToEmailResult = ConvertToEmailSuccess | ConvertToEmailError + union UpdateEmailResult = UpdateEmailSuccess | UpdateEmailError - type ConvertToEmailSuccess { - success: Boolean! + type UpdateEmailSuccess { + email: String! + verificationEmailSent: Boolean } - type ConvertToEmailError { - errorCodes: [ConvertToEmailErrorCode!]! + type UpdateEmailError { + errorCodes: [UpdateEmailErrorCode!]! } - enum ConvertToEmailErrorCode { + enum UpdateEmailErrorCode { UNAUTHORIZED BAD_REQUEST EMAIL_ALREADY_EXISTS } - input ConvertToEmailInput { + input UpdateEmailInput { email: String! } @@ -2595,7 +2598,7 @@ const schema = gql` deleteAccount(userID: ID!): DeleteAccountResult! updateUser(input: UpdateUserInput!): UpdateUserResult! updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! - convertToEmail(input: ConvertToEmailInput!): ConvertToEmailResult! + updateEmail(input: UpdateEmailInput!): UpdateEmailResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createHighlight(input: CreateHighlightInput!): CreateHighlightResult! mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! diff --git a/packages/api/src/services/send_emails.ts b/packages/api/src/services/send_emails.ts index 9a81dab55..279b193f9 100644 --- a/packages/api/src/services/send_emails.ts +++ b/packages/api/src/services/send_emails.ts @@ -31,7 +31,7 @@ export const sendVerificationEmail = async (user: { }): Promise => { // generate verification link const token = generateVerificationToken({ id: user.id, email: user.email }) - const link = `${env.client.url}/auth/convert-to-email/${token}` + const link = `${env.client.url}/auth/reset-password/${token}` // send email const dynamicTemplateData = { name: user.name, diff --git a/packages/web/lib/networking/mutations/updateEmailMutation.ts b/packages/web/lib/networking/mutations/updateEmailMutation.ts new file mode 100644 index 000000000..7c6e13bb7 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateEmailMutation.ts @@ -0,0 +1,45 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' + +export interface UpdateEmailInput { + email: string +} + +export interface UpdateEmailSuccess { + email: string + verificationEmailSent: boolean +} + +interface Response { + updateEmail: UpdateEmailSuccess +} + +export async function updateEmailMutation( + input: UpdateEmailInput +): Promise { + const mutation = gql` + mutation UpdateEmail($input: UpdateEmailInput!) { + updateEmail(input: $input) { + ... on UpdateEmailSuccess { + email + verificationEmailSent + } + ... on UpdateEmailError { + errorCodes + } + } + } + ` + try { + const data = await gqlFetcher(mutation, { + input, + }) + const output = data as Response + return { + email: output.updateEmail.email, + verificationEmailSent: output.updateEmail.verificationEmailSent, + } + } catch (err) { + return undefined + } +} diff --git a/packages/web/lib/networking/queries/useGetViewerQuery.tsx b/packages/web/lib/networking/queries/useGetViewerQuery.tsx index bddd23975..0cf7f9c2d 100644 --- a/packages/web/lib/networking/queries/useGetViewerQuery.tsx +++ b/packages/web/lib/networking/queries/useGetViewerQuery.tsx @@ -17,6 +17,8 @@ export type UserBasicData = { name: string isFullUser?: boolean profile: UserProfile + email: string + source: string } export type UserProfile = { @@ -39,6 +41,8 @@ export function useGetViewerQuery(): ViewerQueryResponse { pictureUrl bio } + email + source } } ` diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 23b466758..f3cff4b35 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -1,23 +1,22 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' -import { applyStoredTheme } from '../../lib/themeUpdater' - -import { StyledText } from '../../components/elements/StyledText' -import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' -import { SettingsLayout } from '../../components/templates/SettingsLayout' import { Toaster } from 'react-hot-toast' +import { Button } from '../../components/elements/Button' import { Box, SpanBox, VStack, } from '../../components/elements/LayoutPrimitives' -import { Button } from '../../components/elements/Button' -import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' +import { StyledText } from '../../components/elements/StyledText' +import { SettingsLayout } from '../../components/templates/SettingsLayout' +import { styled } from '../../components/tokens/stitches.config' +import { updateEmailMutation } from '../../lib/networking/mutations/updateEmailMutation' import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation' import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation' -import { styled, theme } from '../../components/tokens/stitches.config' -import { ProgressBar } from '../../components/elements/ProgressBar' import { useGetLibraryItemsQuery } from '../../lib/networking/queries/useGetLibraryItemsQuery' +import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery' +import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' const StyledLabel = styled('label', { fontWeight: 600, @@ -49,6 +48,9 @@ export default function Account(): JSX.Element { const [username, setUsername] = useState('') const [nameUpdating, setNameUpdating] = useState(false) const [usernameUpdating, setUsernameUpdating] = useState(false) + const [email, setEmail] = useState('') + const [emailUpdating, setEmailUpdating] = useState(false) + const [source, setSource] = useState('') const [debouncedUsername, setDebouncedUsername] = useState('') const { usernameErrorMessage, isLoading: isUsernameValidationLoading } = @@ -96,6 +98,18 @@ export default function Account(): JSX.Element { } }, [viewerData?.me?.name]) + useEffect(() => { + if (viewerData?.me?.email) { + setEmail(viewerData?.me?.email) + } + }, [viewerData?.me?.email]) + + useEffect(() => { + if (viewerData?.me?.source) { + setSource(viewerData?.me?.source) + } + }, [viewerData?.me?.source]) + const handleUsernameChange = useCallback( (event: React.ChangeEvent): void => { setUsername(event.target.value) @@ -154,6 +168,24 @@ export default function Account(): JSX.Element { viewerData?.me, ]) + const updateEmail = useCallback(() => { + setEmailUpdating(true) + ;(async () => { + const response = await updateEmailMutation({ email }) + if (response) { + setEmail(response.email) + if (response.verificationEmailSent) { + showSuccessToast('Verification email sent') + } else { + showSuccessToast('Email updated') + } + } else { + showErrorToast('Error updating email') + } + setEmailUpdating(false) + })() + }, [email]) + applyStoredTheme(false) return ( @@ -281,6 +313,50 @@ export default function Account(): JSX.Element { + +
{ + updateEmail() + event.preventDefault() + }} + > + Email + { + setEmail(event.target.value) + event.preventDefault() + }} + /> + + Your email is used for account recovery and notifications. + + {source == 'EMAIL' ? ( + + ) : ( + + + You are currently logged in with a social account. To + convert to an email login, please click the button below. + + + + )} + +
+ {/* Date: Tue, 3 Oct 2023 09:45:59 +0800 Subject: [PATCH 3/5] fix tests --- packages/api/test/routers/auth.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 79dfc2dcf..48dcd8df7 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -306,7 +306,7 @@ describe('auth router', () => { context('when token is valid', () => { before(() => { - token = generateVerificationToken(user.id) + token = generateVerificationToken({ id: user.id }) }) it('set auth token in cookie', async () => { @@ -340,7 +340,7 @@ describe('auth router', () => { context('when token is expired', () => { before(() => { - token = generateVerificationToken(user.id, -1) + token = generateVerificationToken({ id: user.id }, -1) }) it('redirects to confirm-email page with error code TokenExpired', async () => { @@ -354,7 +354,7 @@ describe('auth router', () => { context('when user is not found', () => { before(() => { const nonExistsUserId = generateFakeUuid() - token = generateVerificationToken(nonExistsUserId) + token = generateVerificationToken({ id: nonExistsUserId }) }) it('redirects to confirm-email page with error code UserNotFound', async () => { @@ -498,7 +498,7 @@ describe('auth router', () => { context('when token is valid', () => { before(async () => { - token = generateVerificationToken(user.id) + token = generateVerificationToken({ id: user.id }) }) context('when password is not empty', () => { @@ -543,7 +543,7 @@ describe('auth router', () => { context('when token is expired', () => { before(() => { - token = generateVerificationToken(user.id, -1) + token = generateVerificationToken({ id: user.id }, -1) }) it('redirects to reset-password page with error code ExpiredToken', async () => { From 21507dd3343114a17987e9f2523062702bd0f6f2 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 4 Oct 2023 12:10:23 +0800 Subject: [PATCH 4/5] Handle errorCodes response when updating email --- .../lib/networking/mutations/updateEmailMutation.ts | 3 +++ packages/web/pages/settings/account.tsx | 10 +++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/web/lib/networking/mutations/updateEmailMutation.ts b/packages/web/lib/networking/mutations/updateEmailMutation.ts index 7c6e13bb7..aab9585d2 100644 --- a/packages/web/lib/networking/mutations/updateEmailMutation.ts +++ b/packages/web/lib/networking/mutations/updateEmailMutation.ts @@ -35,6 +35,9 @@ export async function updateEmailMutation( input, }) const output = data as Response + if ('errorCodes' in output.updateEmail) { + return undefined + } return { email: output.updateEmail.email, verificationEmailSent: output.updateEmail.verificationEmailSent, diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index f3cff4b35..18f66b19e 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -180,6 +180,10 @@ export default function Account(): JSX.Element { showSuccessToast('Email updated') } } else { + // Reset if possible + if (viewerData?.me?.email) { + setEmail(viewerData?.me?.email) + } showErrorToast('Error updating email') } setEmailUpdating(false) @@ -348,10 +352,10 @@ export default function Account(): JSX.Element { ) : ( - You are currently logged in with a social account. To - convert to an email login, please click the button below. + {`You are currently logged in with a ${source} account. To + convert to an email login, please click the button below.`} - + )} From b5b661316b69ab34bd4730a49e4045d1d8781f5b Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 4 Oct 2023 12:24:53 +0800 Subject: [PATCH 5/5] Add a confirmation when switching from social to email based login --- packages/web/pages/settings/account.tsx | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 18f66b19e..88f67e075 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -17,6 +17,7 @@ import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuer import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery' import { applyStoredTheme } from '../../lib/themeUpdater' import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' const StyledLabel = styled('label', { fontWeight: 600, @@ -51,6 +52,8 @@ export default function Account(): JSX.Element { const [email, setEmail] = useState('') const [emailUpdating, setEmailUpdating] = useState(false) const [source, setSource] = useState('') + const [showUpdateEmailConfirmation, setShowUpdateEmailConfirmation] = + useState(false) const [debouncedUsername, setDebouncedUsername] = useState('') const { usernameErrorMessage, isLoading: isUsernameValidationLoading } = @@ -170,6 +173,7 @@ export default function Account(): JSX.Element { const updateEmail = useCallback(() => { setEmailUpdating(true) + setShowUpdateEmailConfirmation(false) ;(async () => { const response = await updateEmailMutation({ email }) if (response) { @@ -329,7 +333,12 @@ export default function Account(): JSX.Element { >
{ - updateEmail() + // Show a confirmation dialog if switching from social login + if (source == 'EMAIL') { + updateEmail() + } else { + setShowUpdateEmailConfirmation(true) + } event.preventDefault() }} > @@ -389,6 +398,16 @@ export default function Account(): JSX.Element { */} + + {showUpdateEmailConfirmation ? ( + setShowUpdateEmailConfirmation(false)} + /> + ) : null} ) }