convert to email api

This commit is contained in:
Hongbo Wu 2023-10-02 16:50:45 +08:00
parent 4953a4d356
commit c40e64773a
11 changed files with 190 additions and 16 deletions

View file

@ -252,6 +252,28 @@ export enum ContentReader {
Web = 'WEB'
}
export type ConvertToEmailError = {
__typename?: 'ConvertToEmailError';
errorCodes: Array<ConvertToEmailErrorCode>;
};
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<CreateArticleErrorCode>;
@ -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<BulkActionSuccess>;
BulkActionType: BulkActionType;
ContentReader: ContentReader;
ConvertToEmailError: ResolverTypeWrapper<ConvertToEmailError>;
ConvertToEmailErrorCode: ConvertToEmailErrorCode;
ConvertToEmailInput: ConvertToEmailInput;
ConvertToEmailResult: ResolversTypes['ConvertToEmailError'] | ResolversTypes['ConvertToEmailSuccess'];
ConvertToEmailSuccess: ResolverTypeWrapper<ConvertToEmailSuccess>;
CreateArticleError: ResolverTypeWrapper<CreateArticleError>;
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<ContextType = ResolverContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ConvertToEmailErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ConvertToEmailError'] = ResolversParentTypes['ConvertToEmailError']> = {
errorCodes?: Resolver<Array<ResolversTypes['ConvertToEmailErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type ConvertToEmailResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ConvertToEmailResult'] = ResolversParentTypes['ConvertToEmailResult']> = {
__resolveType: TypeResolveFn<'ConvertToEmailError' | 'ConvertToEmailSuccess', ParentType, ContextType>;
};
export type ConvertToEmailSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ConvertToEmailSuccess'] = ResolversParentTypes['ConvertToEmailSuccess']> = {
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CreateArticleErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['CreateArticleError'] = ResolversParentTypes['CreateArticleError']> = {
errorCodes?: Resolver<Array<ResolversTypes['CreateArticleErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -5191,6 +5242,7 @@ export type MoveLabelSuccessResolvers<ContextType = ResolverContext, ParentType
export type MutationResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
addPopularRead?: Resolver<ResolversTypes['AddPopularReadResult'], ParentType, ContextType, RequireFields<MutationAddPopularReadArgs, 'name'>>;
bulkAction?: Resolver<ResolversTypes['BulkActionResult'], ParentType, ContextType, RequireFields<MutationBulkActionArgs, 'action' | 'query'>>;
convertToEmail?: Resolver<ResolversTypes['ConvertToEmailResult'], ParentType, ContextType, RequireFields<MutationConvertToEmailArgs, 'input'>>;
createArticle?: Resolver<ResolversTypes['CreateArticleResult'], ParentType, ContextType, RequireFields<MutationCreateArticleArgs, 'input'>>;
createArticleSavingRequest?: Resolver<ResolversTypes['CreateArticleSavingRequestResult'], ParentType, ContextType, RequireFields<MutationCreateArticleSavingRequestArgs, 'input'>>;
createGroup?: Resolver<ResolversTypes['CreateGroupResult'], ParentType, ContextType, RequireFields<MutationCreateGroupArgs, 'input'>>;
@ -6282,6 +6334,9 @@ export type Resolvers<ContextType = ResolverContext> = {
BulkActionError?: BulkActionErrorResolvers<ContextType>;
BulkActionResult?: BulkActionResultResolvers<ContextType>;
BulkActionSuccess?: BulkActionSuccessResolvers<ContextType>;
ConvertToEmailError?: ConvertToEmailErrorResolvers<ContextType>;
ConvertToEmailResult?: ConvertToEmailResultResolvers<ContextType>;
ConvertToEmailSuccess?: ConvertToEmailSuccessResolvers<ContextType>;
CreateArticleError?: CreateArticleErrorResolvers<ContextType>;
CreateArticleResult?: CreateArticleResultResolvers<ContextType>;
CreateArticleSavingRequestError?: CreateArticleSavingRequestErrorResolvers<ContextType>;

View file

@ -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!

View file

@ -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'),
}

View file

@ -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 = {

View file

@ -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],
}
}
})

View file

@ -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) {

View file

@ -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!

View file

@ -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<boolean> => {
// 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<boolean> => {
// 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<boolean> => {
// 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 = {

View file

@ -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 = {

View file

@ -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 (

View file

@ -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