send confirmation email to pending user when login

This commit is contained in:
Hongbo Wu
2022-07-21 22:13:46 +08:00
committed by Jackson Harper
parent bd2327a3ae
commit 9ea9bd9ea1
8 changed files with 62 additions and 114 deletions

View File

@ -44,6 +44,7 @@ export interface UserData {
private: boolean
}
password?: string | null
status?: StatusType
}
export enum MembershipTier {
@ -72,6 +73,7 @@ export const keys = [
'sourceUserId',
'createdAt',
'password',
'status',
] as const
export const defaultedKeys = ['id', 'createdAt'] as const

View File

@ -59,5 +59,5 @@ export class User {
subscriptions?: Subscription[]
@Column({ type: 'enum', enum: StatusType })
status!: string
status!: StatusType
}

View File

@ -825,11 +825,6 @@ export enum LoginErrorCode {
WrongSource = 'WRONG_SOURCE'
}
export type LoginInput = {
email: Scalars['String'];
password: Scalars['String'];
};
export type LoginResult = LoginError | LoginSuccess;
export type LoginSuccess = {
@ -893,7 +888,6 @@ export type Mutation = {
googleLogin: LoginResult;
googleSignup: GoogleSignupResult;
logOut: LogOutResult;
login: LoginResult;
mergeHighlight: MergeHighlightResult;
reportItem: ReportItemResult;
revokeApiKey: RevokeApiKeyResult;
@ -911,7 +905,6 @@ export type Mutation = {
setShareHighlight: SetShareHighlightResult;
setUserPersonalization: SetUserPersonalizationResult;
setWebhook: SetWebhookResult;
signup: SignupResult;
subscribe: SubscribeResult;
unsubscribe: UnsubscribeResult;
updateHighlight: UpdateHighlightResult;
@ -1022,11 +1015,6 @@ export type MutationGoogleSignupArgs = {
};
export type MutationLoginArgs = {
input: LoginInput;
};
export type MutationMergeHighlightArgs = {
input: MergeHighlightInput;
};
@ -1112,11 +1100,6 @@ export type MutationSetWebhookArgs = {
};
export type MutationSignupArgs = {
input: SignupInput;
};
export type MutationSubscribeArgs = {
name: Scalars['String'];
};
@ -1825,11 +1808,6 @@ export type SharedArticleSuccess = {
article: Article;
};
export type SignupError = {
__typename?: 'SignupError';
errorCodes: Array<Maybe<SignupErrorCode>>;
};
export enum SignupErrorCode {
AccessDenied = 'ACCESS_DENIED',
ExpiredToken = 'EXPIRED_TOKEN',
@ -1841,22 +1819,6 @@ export enum SignupErrorCode {
UserExists = 'USER_EXISTS'
}
export type SignupInput = {
bio?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
name: Scalars['String'];
password: Scalars['String'];
pictureUrl?: InputMaybe<Scalars['String']>;
username: Scalars['String'];
};
export type SignupResult = SignupError | SignupSuccess;
export type SignupSuccess = {
__typename?: 'SignupSuccess';
me: User;
};
export enum SortBy {
PublishedAt = 'PUBLISHED_AT',
SavedAt = 'SAVED_AT',
@ -2573,7 +2535,6 @@ export type ResolversTypes = {
LogOutSuccess: ResolverTypeWrapper<LogOutSuccess>;
LoginError: ResolverTypeWrapper<LoginError>;
LoginErrorCode: LoginErrorCode;
LoginInput: LoginInput;
LoginResult: ResolversTypes['LoginError'] | ResolversTypes['LoginSuccess'];
LoginSuccess: ResolverTypeWrapper<LoginSuccess>;
MergeHighlightError: ResolverTypeWrapper<MergeHighlightError>;
@ -2677,11 +2638,7 @@ export type ResolversTypes = {
SharedArticleErrorCode: SharedArticleErrorCode;
SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess'];
SharedArticleSuccess: ResolverTypeWrapper<SharedArticleSuccess>;
SignupError: ResolverTypeWrapper<SignupError>;
SignupErrorCode: SignupErrorCode;
SignupInput: SignupInput;
SignupResult: ResolversTypes['SignupError'] | ResolversTypes['SignupSuccess'];
SignupSuccess: ResolverTypeWrapper<SignupSuccess>;
SortBy: SortBy;
SortOrder: SortOrder;
SortParams: SortParams;
@ -2901,7 +2858,6 @@ export type ResolversParentTypes = {
LogOutResult: ResolversParentTypes['LogOutError'] | ResolversParentTypes['LogOutSuccess'];
LogOutSuccess: LogOutSuccess;
LoginError: LoginError;
LoginInput: LoginInput;
LoginResult: ResolversParentTypes['LoginError'] | ResolversParentTypes['LoginSuccess'];
LoginSuccess: LoginSuccess;
MergeHighlightError: MergeHighlightError;
@ -2985,10 +2941,6 @@ export type ResolversParentTypes = {
SharedArticleError: SharedArticleError;
SharedArticleResult: ResolversParentTypes['SharedArticleError'] | ResolversParentTypes['SharedArticleSuccess'];
SharedArticleSuccess: SharedArticleSuccess;
SignupError: SignupError;
SignupInput: SignupInput;
SignupResult: ResolversParentTypes['SignupError'] | ResolversParentTypes['SignupSuccess'];
SignupSuccess: SignupSuccess;
SortParams: SortParams;
String: Scalars['String'];
SubscribeError: SubscribeError;
@ -3715,7 +3667,6 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
googleSignup?: Resolver<ResolversTypes['GoogleSignupResult'], ParentType, ContextType, RequireFields<MutationGoogleSignupArgs, 'input'>>;
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
login?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationLoginArgs, 'input'>>;
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
@ -3733,7 +3684,6 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setShareHighlight?: Resolver<ResolversTypes['SetShareHighlightResult'], ParentType, ContextType, RequireFields<MutationSetShareHighlightArgs, 'input'>>;
setUserPersonalization?: Resolver<ResolversTypes['SetUserPersonalizationResult'], ParentType, ContextType, RequireFields<MutationSetUserPersonalizationArgs, 'input'>>;
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
signup?: Resolver<ResolversTypes['SignupResult'], ParentType, ContextType, RequireFields<MutationSignupArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'name'>>;
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
@ -4125,20 +4075,6 @@ export type SharedArticleSuccessResolvers<ContextType = ResolverContext, ParentT
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SignupErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SignupError'] = ResolversParentTypes['SignupError']> = {
errorCodes?: Resolver<Array<Maybe<ResolversTypes['SignupErrorCode']>>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SignupResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SignupResult'] = ResolversParentTypes['SignupResult']> = {
__resolveType: TypeResolveFn<'SignupError' | 'SignupSuccess', ParentType, ContextType>;
};
export type SignupSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SignupSuccess'] = ResolversParentTypes['SignupSuccess']> = {
me?: Resolver<ResolversTypes['User'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SubscribeErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SubscribeError'] = ResolversParentTypes['SubscribeError']> = {
errorCodes?: Resolver<Array<ResolversTypes['SubscribeErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -4628,9 +4564,6 @@ export type Resolvers<ContextType = ResolverContext> = {
SharedArticleError?: SharedArticleErrorResolvers<ContextType>;
SharedArticleResult?: SharedArticleResultResolvers<ContextType>;
SharedArticleSuccess?: SharedArticleSuccessResolvers<ContextType>;
SignupError?: SignupErrorResolvers<ContextType>;
SignupResult?: SignupResultResolvers<ContextType>;
SignupSuccess?: SignupSuccessResolvers<ContextType>;
SubscribeError?: SubscribeErrorResolvers<ContextType>;
SubscribeResult?: SubscribeResultResolvers<ContextType>;
SubscribeSuccess?: SubscribeSuccessResolvers<ContextType>;

View File

@ -730,11 +730,6 @@ enum LoginErrorCode {
WRONG_SOURCE
}
input LoginInput {
email: String!
password: String!
}
union LoginResult = LoginError | LoginSuccess
type LoginSuccess {
@ -794,7 +789,6 @@ type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
logOut: LogOutResult!
login(input: LoginInput!): LoginResult!
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
reportItem(input: ReportItemInput!): ReportItemResult!
revokeApiKey(id: ID!): RevokeApiKeyResult!
@ -812,7 +806,6 @@ type Mutation {
setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult!
setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult!
setWebhook(input: SetWebhookInput!): SetWebhookResult!
signup(input: SignupInput!): SignupResult!
subscribe(name: String!): SubscribeResult!
unsubscribe(name: String!): UnsubscribeResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
@ -1347,10 +1340,6 @@ type SharedArticleSuccess {
article: Article!
}
type SignupError {
errorCodes: [SignupErrorCode]!
}
enum SignupErrorCode {
ACCESS_DENIED
EXPIRED_TOKEN
@ -1362,21 +1351,6 @@ enum SignupErrorCode {
USER_EXISTS
}
input SignupInput {
bio: String
email: String!
name: String!
password: String!
pictureUrl: String
username: String!
}
union SignupResult = SignupError | SignupSuccess
type SignupSuccess {
me: User!
}
enum SortBy {
PUBLISHED_AT
SAVED_AT

View File

@ -35,10 +35,11 @@ import cors from 'cors'
import {
MembershipTier,
RegistrationType,
StatusType,
UserData,
} from '../../datalayer/user/model'
import { comparePassword, hashPassword } from '../../utils/auth'
import { createUser } from '../../services/create_user'
import { createUser, sendConfirmationEmail } from '../../services/create_user'
import { isErrorWithCode } from '../../resolvers'
import { initModels } from '../../server'
@ -372,6 +373,17 @@ export function authRouter() {
)
}
if (user.status === StatusType.Pending && user.email) {
await sendConfirmationEmail({
id: user.id,
email: user.email,
name: user.name,
})
return res.redirect(
`${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION`
)
}
if (!user?.password) {
// user has no password, so they need to set one
return res.redirect(

View File

@ -8,7 +8,7 @@ import { validateUsername } from '../utils/usernamePolicy'
import { Invite } from '../entity/groups/invite'
import { GroupMembership } from '../entity/groups/group_membership'
import { AppDataSource } from '../server'
import { getRepository, setClaims } from '../entity/utils'
import { getRepository } from '../entity/utils'
import { generateVerificationToken } from '../utils/auth'
import { env } from '../env'
import { sendEmail } from '../utils/sendEmail'
@ -95,13 +95,6 @@ export const createUser = async (input: {
if (input.pendingConfirmation) {
if (!(await sendConfirmationEmail(user))) {
// delete user if email failed to send
await AppDataSource.transaction(async (e) => {
await setClaims(e, user.id)
return e.getRepository(User).delete(user.id)
})
return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail })
}
}
@ -138,7 +131,11 @@ const getUser = async (email: string): Promise<User | null> => {
})
}
export const sendConfirmationEmail = async (user: User): Promise<boolean> => {
export const sendConfirmationEmail = async (user: {
id: string
name: string
email: string
}): Promise<boolean> => {
// generate confirmation link
const confirmationToken = generateVerificationToken(user.id)
const confirmationLink = `${env.client.url}/confirm-email/${confirmationToken}`

View File

@ -181,6 +181,38 @@ describe('auth router', () => {
})
})
context('when user is not confirmed', async () => {
const pendingUser = await createTestUser(
'pending_user',
undefined,
correctPassword,
true
)
before(async () => {
email = pendingUser.email
password = correctPassword
})
after(async () => {
await deleteTestUser(pendingUser.name)
})
it('redirects with error code PendingVerification', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header.location).to.endWith(
'/email-login?errorCodes=PENDING_VERIFICATION'
)
})
it('sends a verification email', async () => {
const fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
await loginRequest(email, password).expect(302)
sinon.restore()
expect(fake).to.have.been.calledOnce
})
})
context('when user not exists', () => {
before(() => {
email = 'Some email'
@ -194,15 +226,16 @@ describe('auth router', () => {
})
})
context('when user has no password stored in db', () => {
before(async () => {
const anotherUser = await createTestUser('another_user')
email = anotherUser.email
context('when user has no password stored in db', async () => {
const socialAccountUser = await createTestUser('social_account_user')
before(() => {
email = socialAccountUser.email
password = 'Some password'
})
after(async () => {
await deleteTestUser('another_user')
await deleteTestUser(socialAccountUser.name)
})
it('redirects with error code WrongSource', async () => {

View File

@ -17,8 +17,6 @@ import sinonChai from 'sinon-chai'
import sinon from 'sinon'
import * as util from '../../src/utils/sendEmail'
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
import { getRepository } from '../../src/entity/utils'
import { User } from '../../src/entity/user'
chai.use(sinonChai)
@ -99,10 +97,9 @@ describe('create user', () => {
sinon.restore()
})
it('does not create the user', async () => {
await expect(createTestUser(name, undefined, undefined, true)).to.be
it('rejects with error', async () => {
return expect(createTestUser(name, undefined, undefined, true)).to.be
.rejected
expect(await getRepository(User).findOneBy({ name })).to.be.null
})
})
})