From c40e64773a1d563a502bc6178cf2353415160657 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 2 Oct 2023 16:50:45 +0800 Subject: [PATCH] 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