mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
convert to email api
This commit is contained in:
parent
4953a4d356
commit
c40e64773a
11 changed files with 190 additions and 16 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,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],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue