Merge pull request #2831 from omnivore-app/feature/convert-to-email-api
Update Email Feature
This commit is contained in:
@ -1291,6 +1291,7 @@ export type Mutation = {
|
||||
setWebhook: SetWebhookResult;
|
||||
subscribe: SubscribeResult;
|
||||
unsubscribe: UnsubscribeResult;
|
||||
updateEmail: UpdateEmailResult;
|
||||
updateFilter: UpdateFilterResult;
|
||||
updateHighlight: UpdateHighlightResult;
|
||||
updateHighlightReply: UpdateHighlightReplyResult;
|
||||
@ -1592,6 +1593,11 @@ export type MutationUnsubscribeArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateEmailArgs = {
|
||||
input: UpdateEmailInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateFilterArgs = {
|
||||
input: UpdateFilterInput;
|
||||
};
|
||||
@ -2845,6 +2851,29 @@ export type UnsubscribeSuccess = {
|
||||
subscription: Subscription;
|
||||
};
|
||||
|
||||
export type UpdateEmailError = {
|
||||
__typename?: 'UpdateEmailError';
|
||||
errorCodes: Array<UpdateEmailErrorCode>;
|
||||
};
|
||||
|
||||
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<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type UpdateFilterError = {
|
||||
__typename?: 'UpdateFilterError';
|
||||
errorCodes: Array<UpdateFilterErrorCode>;
|
||||
@ -3211,6 +3240,7 @@ export enum UploadImportFileType {
|
||||
|
||||
export type User = {
|
||||
__typename?: 'User';
|
||||
email?: Maybe<Scalars['String']>;
|
||||
followersCount?: Maybe<Scalars['Int']>;
|
||||
friendsCount?: Maybe<Scalars['Int']>;
|
||||
id: Scalars['ID'];
|
||||
@ -3224,6 +3254,7 @@ export type User = {
|
||||
sharedArticlesCount?: Maybe<Scalars['Int']>;
|
||||
sharedHighlightsCount?: Maybe<Scalars['Int']>;
|
||||
sharedNotesCount?: Maybe<Scalars['Int']>;
|
||||
source?: Maybe<Scalars['String']>;
|
||||
viewerIsFollowing?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
@ -3813,6 +3844,11 @@ export type ResolversTypes = {
|
||||
UnsubscribeErrorCode: UnsubscribeErrorCode;
|
||||
UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess'];
|
||||
UnsubscribeSuccess: ResolverTypeWrapper<UnsubscribeSuccess>;
|
||||
UpdateEmailError: ResolverTypeWrapper<UpdateEmailError>;
|
||||
UpdateEmailErrorCode: UpdateEmailErrorCode;
|
||||
UpdateEmailInput: UpdateEmailInput;
|
||||
UpdateEmailResult: ResolversTypes['UpdateEmailError'] | ResolversTypes['UpdateEmailSuccess'];
|
||||
UpdateEmailSuccess: ResolverTypeWrapper<UpdateEmailSuccess>;
|
||||
UpdateFilterError: ResolverTypeWrapper<UpdateFilterError>;
|
||||
UpdateFilterErrorCode: UpdateFilterErrorCode;
|
||||
UpdateFilterInput: UpdateFilterInput;
|
||||
@ -4226,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'];
|
||||
@ -5247,6 +5287,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
||||
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
|
||||
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
|
||||
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
|
||||
updateEmail?: Resolver<ResolversTypes['UpdateEmailResult'], ParentType, ContextType, RequireFields<MutationUpdateEmailArgs, 'input'>>;
|
||||
updateFilter?: Resolver<ResolversTypes['UpdateFilterResult'], ParentType, ContextType, RequireFields<MutationUpdateFilterArgs, 'input'>>;
|
||||
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
|
||||
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
|
||||
@ -5955,6 +5996,21 @@ export type UnsubscribeSuccessResolvers<ContextType = ResolverContext, ParentTyp
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type UpdateEmailErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailError'] = ResolversParentTypes['UpdateEmailError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['UpdateEmailErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type UpdateEmailResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailResult'] = ResolversParentTypes['UpdateEmailResult']> = {
|
||||
__resolveType: TypeResolveFn<'UpdateEmailError' | 'UpdateEmailSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type UpdateEmailSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailSuccess'] = ResolversParentTypes['UpdateEmailSuccess']> = {
|
||||
email?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
verificationEmailSent?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type UpdateFilterErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateFilterError'] = ResolversParentTypes['UpdateFilterError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['UpdateFilterErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@ -6157,6 +6213,7 @@ export type UploadImportFileSuccessResolvers<ContextType = ResolverContext, Pare
|
||||
};
|
||||
|
||||
export type UserResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User']> = {
|
||||
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
followersCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
friendsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
@ -6169,6 +6226,7 @@ export type UserResolvers<ContextType = ResolverContext, ParentType extends Reso
|
||||
sharedArticlesCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
sharedHighlightsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
sharedNotesCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
|
||||
source?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
viewerIsFollowing?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
@ -6528,6 +6586,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
UnsubscribeError?: UnsubscribeErrorResolvers<ContextType>;
|
||||
UnsubscribeResult?: UnsubscribeResultResolvers<ContextType>;
|
||||
UnsubscribeSuccess?: UnsubscribeSuccessResolvers<ContextType>;
|
||||
UpdateEmailError?: UpdateEmailErrorResolvers<ContextType>;
|
||||
UpdateEmailResult?: UpdateEmailResultResolvers<ContextType>;
|
||||
UpdateEmailSuccess?: UpdateEmailSuccessResolvers<ContextType>;
|
||||
UpdateFilterError?: UpdateFilterErrorResolvers<ContextType>;
|
||||
UpdateFilterResult?: UpdateFilterResultResolvers<ContextType>;
|
||||
UpdateFilterSuccess?: UpdateFilterSuccessResolvers<ContextType>;
|
||||
|
||||
@ -1159,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!
|
||||
@ -2177,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!]!
|
||||
}
|
||||
@ -2514,6 +2536,7 @@ enum UploadImportFileType {
|
||||
}
|
||||
|
||||
type User {
|
||||
email: String
|
||||
followersCount: Int
|
||||
friendsCount: Int
|
||||
id: ID!
|
||||
@ -2526,6 +2549,7 @@ type User {
|
||||
sharedArticlesCount: Int
|
||||
sharedHighlightsCount: Int
|
||||
sharedNotesCount: Int
|
||||
source: String
|
||||
viewerIsFollowing: Boolean
|
||||
}
|
||||
|
||||
|
||||
@ -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 { updateEmailResolver } from './user'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
@ -212,6 +213,7 @@ export const functionResolvers = {
|
||||
setFavoriteArticle: setFavoriteArticleResolver,
|
||||
updateSubscription: updateSubscriptionResolver,
|
||||
updateFilter: updateFilterResolver,
|
||||
updateEmail: updateEmailResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
@ -678,4 +680,5 @@ export const functionResolvers = {
|
||||
...resultResolveTypeResolver('ImportFromIntegration'),
|
||||
...resultResolveTypeResolver('SetFavoriteArticle'),
|
||||
...resultResolveTypeResolver('UpdateSubscription'),
|
||||
...resultResolveTypeResolver('UpdateEmail'),
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { RegistrationType } from '../../datalayer/user/model'
|
||||
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 {
|
||||
DeleteAccountError,
|
||||
@ -15,12 +16,16 @@ import {
|
||||
MutationDeleteAccountArgs,
|
||||
MutationGoogleLoginArgs,
|
||||
MutationGoogleSignupArgs,
|
||||
MutationUpdateEmailArgs,
|
||||
MutationUpdateUserArgs,
|
||||
MutationUpdateUserProfileArgs,
|
||||
QueryUserArgs,
|
||||
QueryValidateUsernameArgs,
|
||||
ResolverFn,
|
||||
SignupErrorCode,
|
||||
UpdateEmailError,
|
||||
UpdateEmailErrorCode,
|
||||
UpdateEmailSuccess,
|
||||
UpdateUserError,
|
||||
UpdateUserErrorCode,
|
||||
UpdateUserProfileError,
|
||||
@ -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,50 @@ export const deleteAccountResolver = authorized<
|
||||
|
||||
return { userID }
|
||||
})
|
||||
|
||||
export const updateEmailResolver = authorized<
|
||||
UpdateEmailSuccess,
|
||||
UpdateEmailError,
|
||||
MutationUpdateEmailArgs
|
||||
>(async (_, { input: { email } }, { uid, log }) => {
|
||||
try {
|
||||
const user = await getRepository(UserEntity).findOneBy({
|
||||
id: uid,
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
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 { email, verificationEmailSent: true }
|
||||
} catch (error) {
|
||||
log.error('Error updating email', error)
|
||||
return {
|
||||
errorCodes: [UpdateEmailErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,9 @@ export function authRouter() {
|
||||
)
|
||||
}
|
||||
|
||||
const user = await getRepository(User).findOneBy({ id: claims.uid })
|
||||
const user = await getRepository(User).findOneBy({
|
||||
id: claims.uid,
|
||||
})
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`${env.client.url}/auth/reset-password/${token}?errorCodes=USER_NOT_FOUND`
|
||||
@ -683,13 +685,18 @@ 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({ id: user.id }, { password: hashedPassword })
|
||||
return entityManager.getRepository(User).update(user.id, {
|
||||
password: hashedPassword,
|
||||
email: updateEmail ? claims.email : undefined,
|
||||
source: updateEmail ? RegistrationType.Email : undefined,
|
||||
})
|
||||
}
|
||||
)
|
||||
if (!updated.affected) {
|
||||
|
||||
@ -84,6 +84,8 @@ const schema = gql`
|
||||
sharedNotesCount: Int
|
||||
friendsCount: Int
|
||||
followersCount: Int
|
||||
email: String
|
||||
source: String
|
||||
}
|
||||
|
||||
type Profile {
|
||||
@ -2567,6 +2569,27 @@ const schema = gql`
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
union UpdateEmailResult = UpdateEmailSuccess | UpdateEmailError
|
||||
|
||||
type UpdateEmailSuccess {
|
||||
email: String!
|
||||
verificationEmailSent: Boolean
|
||||
}
|
||||
|
||||
type UpdateEmailError {
|
||||
errorCodes: [UpdateEmailErrorCode!]!
|
||||
}
|
||||
|
||||
enum UpdateEmailErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
EMAIL_ALREADY_EXISTS
|
||||
}
|
||||
|
||||
input UpdateEmailInput {
|
||||
email: String!
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
@ -2575,6 +2598,7 @@ const schema = gql`
|
||||
deleteAccount(userID: ID!): DeleteAccountResult!
|
||||
updateUser(input: UpdateUserInput!): UpdateUserResult!
|
||||
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult!
|
||||
updateEmail(input: UpdateEmailInput!): UpdateEmailResult!
|
||||
createArticle(input: CreateArticleInput!): CreateArticleResult!
|
||||
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
|
||||
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
|
||||
|
||||
@ -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/reset-password/${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 = {
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
48
packages/web/lib/networking/mutations/updateEmailMutation.ts
Normal file
48
packages/web/lib/networking/mutations/updateEmailMutation.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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<UpdateEmailSuccess | undefined> {
|
||||
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
|
||||
if ('errorCodes' in output.updateEmail) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
email: output.updateEmail.email,
|
||||
verificationEmailSent: output.updateEmail.verificationEmailSent,
|
||||
}
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@ -1,23 +1,23 @@
|
||||
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'
|
||||
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
|
||||
|
||||
const StyledLabel = styled('label', {
|
||||
fontWeight: 600,
|
||||
@ -49,6 +49,11 @@ 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 [showUpdateEmailConfirmation, setShowUpdateEmailConfirmation] =
|
||||
useState(false)
|
||||
|
||||
const [debouncedUsername, setDebouncedUsername] = useState('')
|
||||
const { usernameErrorMessage, isLoading: isUsernameValidationLoading } =
|
||||
@ -96,6 +101,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<HTMLInputElement>): void => {
|
||||
setUsername(event.target.value)
|
||||
@ -154,6 +171,29 @@ export default function Account(): JSX.Element {
|
||||
viewerData?.me,
|
||||
])
|
||||
|
||||
const updateEmail = useCallback(() => {
|
||||
setEmailUpdating(true)
|
||||
setShowUpdateEmailConfirmation(false)
|
||||
;(async () => {
|
||||
const response = await updateEmailMutation({ email })
|
||||
if (response) {
|
||||
setEmail(response.email)
|
||||
if (response.verificationEmailSent) {
|
||||
showSuccessToast('Verification email sent')
|
||||
} else {
|
||||
showSuccessToast('Email updated')
|
||||
}
|
||||
} else {
|
||||
// Reset if possible
|
||||
if (viewerData?.me?.email) {
|
||||
setEmail(viewerData?.me?.email)
|
||||
}
|
||||
showErrorToast('Error updating email')
|
||||
}
|
||||
setEmailUpdating(false)
|
||||
})()
|
||||
}, [email])
|
||||
|
||||
applyStoredTheme(false)
|
||||
|
||||
return (
|
||||
@ -281,6 +321,55 @@ export default function Account(): JSX.Element {
|
||||
</form>
|
||||
</VStack>
|
||||
|
||||
<VStack
|
||||
css={{
|
||||
padding: '24px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
bg: '$grayBg',
|
||||
gap: '5px',
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
// Show a confirmation dialog if switching from social login
|
||||
if (source == 'EMAIL') {
|
||||
updateEmail()
|
||||
} else {
|
||||
setShowUpdateEmailConfirmation(true)
|
||||
}
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<StyledLabel>Email</StyledLabel>
|
||||
<FormInput
|
||||
type={'text'}
|
||||
placeholder={'Email'}
|
||||
value={email}
|
||||
disabled={emailUpdating}
|
||||
onChange={(event) => {
|
||||
setEmail(event.target.value)
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
|
||||
Your email is used for account recovery and notifications.
|
||||
</StyledText>
|
||||
{source == 'EMAIL' ? (
|
||||
<Button style="ctaDarkYellow">Update Email</Button>
|
||||
) : (
|
||||
<VStack>
|
||||
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
|
||||
{`You are currently logged in with a ${source} account. To
|
||||
convert to an email login, please click the button below.`}
|
||||
</StyledText>
|
||||
<Button style="ctaDarkYellow">Convert to email login</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</form>
|
||||
</VStack>
|
||||
|
||||
{/* <VStack
|
||||
css={{
|
||||
padding: '24px',
|
||||
@ -309,6 +398,16 @@ export default function Account(): JSX.Element {
|
||||
</VStack> */}
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{showUpdateEmailConfirmation ? (
|
||||
<ConfirmationModal
|
||||
message={
|
||||
'You are converting from social to email based login. This can not be undone.'
|
||||
}
|
||||
onAccept={updateEmail}
|
||||
onOpenChange={() => setShowUpdateEmailConfirmation(false)}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user