Merge pull request #989 from omnivore-app/feature/email-login-and-registration

email-login, email-signup, confirm-email and reset-password router
This commit is contained in:
Jackson Harper 2022-07-25 15:47:51 -07:00 committed by GitHub
commit efc62600f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1185 additions and 557 deletions

View file

@ -24,4 +24,7 @@ GCS_UPLOAD_SA_KEY_FILE_PATH=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
REMINDER_TASK_HANDLER_URL=
ELASTIC_URL=http://localhost:9200
ELASTIC_URL=http://localhost:9200
SENDER_MESSAGE=msgs@sender.domain
SENDER_FEEDBACK=feedback@sender.domain
SENDER_GENERAL=no-reply@sender.domain

View file

@ -8,7 +8,7 @@
"reporter": [
"text-summary"
],
"branches": 0,
"branches": 40,
"lines": 0,
"functions": 0,
"statements": 60

View file

@ -107,6 +107,8 @@
"@types/oauth": "^0.9.1",
"@types/private-ip": "^1.0.0",
"@types/sanitize-html": "^1.27.1",
"@types/sinon": "^10.0.13",
"@types/sinon-chai": "^3.2.8",
"@types/supertest": "^2.0.11",
"@types/urlsafe-base64": "^1.0.28",
"@types/uuid": "^8.3.0",
@ -120,6 +122,8 @@
"nock": "^13.2.4",
"nyc": "^15.1.0",
"postgrator": "^4.2.0",
"sinon": "^14.0.0",
"sinon-chai": "^3.7.0",
"ts-node-dev": "^1.1.8"
},
"engines": {

View file

@ -24,7 +24,7 @@ import ScalarResolvers from './scalars'
import * as Sentry from '@sentry/node'
import { createPubSubClient } from './datalayer/pubsub'
import { initModels } from './server'
import { getClaimsByToken } from './utils/auth'
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
const signToken = promisify(jwt.sign)
const logger = buildLogger('app.dispatch')
@ -76,14 +76,7 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
setAuth: async (
claims: ClaimsToSet,
secret: string = env.server.jwtSecret
) => {
const token = await signToken(claims, secret)
res.cookie('auth', token, {
httpOnly: true,
expires: new Date(new Date().getTime() + 365 * 24 * 60 * 60 * 1000),
})
},
) => await setAuthInCookie(claims, res, secret),
setClaims,
authTrx: <TResult>(
cb: (tx: Knex.Transaction) => TResult,

View file

@ -12,7 +12,6 @@ import { exclude, Partialize, PickTuple } from '../../util'
// source_user_id | text | | not null |
// created_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
// updated_at | timestamp with time zone | | not null | CURRENT_TIMESTAMP
// membership | omnivore.membership_tier | | not null | 'WAIT_LIST'::omnivore.membership_tier
// Table "omnivore.user_profile"
// Column | Type | Collation | Nullable | Default
@ -30,7 +29,6 @@ export interface UserData {
id: string
name: string
source: string
membership: string
email?: string | null
phone?: string | null
sourceUserId: string
@ -44,11 +42,7 @@ export interface UserData {
private: boolean
}
password?: string | null
}
export enum MembershipTier {
WaitList = 'WAIT_LIST',
Beta = 'BETA',
status?: StatusType
}
export enum RegistrationType {
@ -57,16 +51,21 @@ export enum RegistrationType {
Email = 'EMAIL',
}
export enum StatusType {
Active = 'ACTIVE',
Pending = 'PENDING',
}
export const keys = [
'id',
'name',
'source',
'membership',
'email',
'phone',
'sourceUserId',
'createdAt',
'password',
'status',
] as const
export const defaultedKeys = ['id', 'createdAt'] as const

View file

@ -7,7 +7,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { MembershipTier, RegistrationType } from '../datalayer/user/model'
import { RegistrationType, StatusType } from '../datalayer/user/model'
import { NewsletterEmail } from './newsletter_email'
import { Profile } from './profile'
import { Label } from './label'
@ -30,9 +30,6 @@ export class User {
@Column('text')
sourceUserId!: string
@Column({ type: 'enum', enum: MembershipTier })
membership!: string
@CreateDateColumn()
createdAt!: Date
@ -53,4 +50,7 @@ export class User {
@OneToMany(() => Subscription, (subscription) => subscription.user)
subscriptions?: Subscription[]
@Column({ type: 'enum', enum: StatusType })
status!: StatusType
}

View file

@ -28,10 +28,10 @@ export class ContentDisplayReportSubscriber
if (!env.dev.isLocal) {
// If we are in the local environment, just log a message, otherwise email the report
await sendEmail({
to: 'feedback@omnivore.app',
to: env.sender.feedback,
subject: 'New content display report',
text: message,
from: 'msgs@omnivore.app',
from: env.sender.message,
})
}
}

View file

@ -86,7 +86,6 @@ export class IdentifySegmentUser implements EntitySubscriberInterface<Profile> {
traits: {
name: profile.user.name,
email: profile.user.email,
plan: profile.user.membership,
source: profile.user.source,
env: env.server.apiEnv,
},

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,37 +1808,17 @@ export type SharedArticleSuccess = {
article: Article;
};
export type SignupError = {
__typename?: 'SignupError';
errorCodes: Array<Maybe<SignupErrorCode>>;
};
export enum SignupErrorCode {
AccessDenied = 'ACCESS_DENIED',
ExpiredToken = 'EXPIRED_TOKEN',
GoogleAuthError = 'GOOGLE_AUTH_ERROR',
InvalidEmail = 'INVALID_EMAIL',
InvalidPassword = 'INVALID_PASSWORD',
InvalidUsername = 'INVALID_USERNAME',
Unknown = 'UNKNOWN',
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',
@ -2572,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>;
@ -2676,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;
@ -2900,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;
@ -2984,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;
@ -3714,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'>>;
@ -3732,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'>>;
@ -4124,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>;
@ -4627,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,35 +1340,17 @@ type SharedArticleSuccess {
article: Article!
}
type SignupError {
errorCodes: [SignupErrorCode]!
}
enum SignupErrorCode {
ACCESS_DENIED
EXPIRED_TOKEN
GOOGLE_AUTH_ERROR
INVALID_EMAIL
INVALID_PASSWORD
INVALID_USERNAME
UNKNOWN
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

@ -50,7 +50,6 @@ import {
googleLoginResolver,
googleSignupResolver,
labelsResolver,
loginResolver,
logOutResolver,
mergeHighlightResolver,
newsletterEmailsResolver,
@ -73,7 +72,6 @@ import {
setShareHighlightResolver,
setUserPersonalizationResolver,
setWebhookResolver,
signupResolver,
subscribeResolver,
subscriptionsResolver,
typeaheadSearchResolver,
@ -154,8 +152,6 @@ export const functionResolvers = {
createLabel: createLabelResolver,
updateLabel: updateLabelResolver,
deleteLabel: deleteLabelResolver,
login: loginResolver,
signup: signupResolver,
setLabels: setLabelsResolver,
generateApiKey: generateApiKeyResolver,
unsubscribe: unsubscribeResolver,
@ -572,8 +568,6 @@ export const functionResolvers = {
...resultResolveTypeResolver('Labels'),
...resultResolveTypeResolver('CreateLabel'),
...resultResolveTypeResolver('DeleteLabel'),
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('Signup'),
...resultResolveTypeResolver('SetLabels'),
...resultResolveTypeResolver('GenerateApiKey'),
...resultResolveTypeResolver('Search'),

View file

@ -7,6 +7,7 @@ import { authorized } from '../../utils/helpers'
import { sendEmail } from '../../utils/sendEmail'
import { AppDataSource } from '../../server'
import { User } from '../../entity/user'
import { env } from '../../env'
const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID =
'd-c576bdc3b9a849dab250655ba14c7794'
@ -25,8 +26,10 @@ export const sendInstallInstructionsResolver = authorized<
}
const sendInstallInstructions = await sendEmail({
from: 'msgs@omnivore.app',
templateId: INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID,
from: env.sender.message,
templateId:
env.sendgrid.installationTemplateId ||
INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID,
to: user?.email,
})

View file

@ -10,15 +10,12 @@ import {
MutationDeleteAccountArgs,
MutationGoogleLoginArgs,
MutationGoogleSignupArgs,
MutationLoginArgs,
MutationSignupArgs,
MutationUpdateUserArgs,
MutationUpdateUserProfileArgs,
QueryUserArgs,
QueryValidateUsernameArgs,
ResolverFn,
SignupErrorCode,
SignupResult,
UpdateUserError,
UpdateUserErrorCode,
UpdateUserProfileError,
@ -37,7 +34,6 @@ import { env } from '../../env'
import { validateUsername } from '../../utils/usernamePolicy'
import * as jwt from 'jsonwebtoken'
import { createUser } from '../../services/create_user'
import { comparePassword, hashPassword } from '../../utils/auth'
import { deletePagesByParam } from '../../elastic/pages'
import { setClaims } from '../../entity/utils'
import { User as UserEntity } from '../../entity/user'
@ -297,73 +293,6 @@ export function isErrorWithCode(error: unknown): error is ErrorWithCode {
)
}
export const loginResolver: ResolverFn<
LoginResult,
unknown,
WithDataSourcesContext,
MutationLoginArgs
> = async (_obj, { input }, { models, setAuth }) => {
const { email, password } = input
const user = await models.user.getWhere({
email,
})
if (!user?.id) {
return { errorCodes: [LoginErrorCode.UserNotFound] }
}
if (!user?.password) {
// user has no password, so they need to set one
return { errorCodes: [LoginErrorCode.WrongSource] }
}
// check if password is correct
const validPassword = await comparePassword(password, user.password)
if (!validPassword) {
return { errorCodes: [LoginErrorCode.InvalidCredentials] }
}
// set auth cookie in response header
await setAuth({ uid: user.id })
return { me: userDataToUser(user) }
}
export const signupResolver: ResolverFn<
SignupResult,
Record<string, unknown>,
WithDataSourcesContext,
MutationSignupArgs
> = async (_obj, { input }) => {
const { email, username, name, bio, password, pictureUrl } = input
const lowerCasedUsername = username.toLowerCase()
try {
// hash password
const hashedPassword = await hashPassword(password)
const [user, profile] = await createUser({
email,
provider: 'EMAIL',
sourceUserId: email,
name,
username: lowerCasedUsername,
pictureUrl: pictureUrl || undefined,
bio: bio || undefined,
password: hashedPassword,
})
return {
me: userDataToUser({ ...user, profile: { ...profile, private: false } }),
}
} catch (err) {
console.log('error', err)
if (isErrorWithCode(err)) {
return { errorCodes: [err.errorCode as SignupErrorCode] }
}
return { errorCodes: [SignupErrorCode.Unknown] }
}
}
export const deleteAccountResolver = authorized<
DeleteAccountSuccess,
DeleteAccountError,

View file

@ -33,10 +33,25 @@ import { corsConfig } from '../../utils/corsConfig'
import cors from 'cors'
import {
MembershipTier,
RegistrationType,
StatusType,
UserData,
} from '../../datalayer/user/model'
import {
comparePassword,
getClaimsByToken,
hashPassword,
setAuthInCookie,
} from '../../utils/auth'
import { createUser } from '../../services/create_user'
import { isErrorWithCode } from '../../resolvers'
import { initModels } from '../../server'
import { getRepository } from '../../entity/utils'
import { User } from '../../entity/user'
import {
sendConfirmationEmail,
sendPasswordResetEmail,
} from '../../services/send_emails'
const logger = buildLogger('app.dispatch')
const signToken = promisify(jwt.sign)
@ -286,13 +301,13 @@ export function authRouter() {
res.setHeader('set-cookie', result.headers['set-cookie'])
handleSuccessfulLogin(req, res, user, data.googleLogin.newUser)
await handleSuccessfulLogin(req, res, user, data.googleLogin.newUser)
})
async function handleSuccessfulLogin(
req: express.Request,
res: express.Response,
user: UserData,
user: UserData | User,
newUser: boolean
): Promise<void> {
try {
@ -311,6 +326,13 @@ export function authRouter() {
}
}
const message = res.get('Message')
if (message) {
return res.redirect(
`${env.client.url}/home?message=${encodeURIComponent(message)}`
)
}
if (newUser) {
if (redirectUri && redirectUri !== '/') {
return res.redirect(
@ -322,10 +344,6 @@ export function authRouter() {
)
}
if (user.membership === MembershipTier.WaitList) {
return res.redirect(`${env.client.url}/waitlist`)
}
return res.redirect(
url.resolve(env.client.url, decodeURIComponent(redirectUri || 'home'))
)
@ -356,56 +374,53 @@ export function authRouter() {
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const { email, password } = req.body
if (!email || !password) {
res.redirect(`${env.client.url}/email-login?errorCodes=AUTH_FAILED`)
return
return res.redirect(
`${env.client.url}/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
)
}
const query = `
mutation login{
login(input: {
email: "${email}",
password: "${password}"
}) {
__typename
... on LoginError { errorCodes }
... on LoginSuccess {
me {
id
name
profile {
username
}
}
}
}
}`
try {
const result = await axios.post(env.server.gateway_url + '/graphql', {
query,
const models = initModels(kx, false)
const user = await models.user.getWhere({
email,
})
const { data } = result.data
if (data.login.__typename === 'LoginError') {
const errorCodes = data.login.errorCodes.join(',')
if (!user?.id) {
return res.redirect(
`${env.client.url}/email-login?errorCodes=${errorCodes}`
`${env.client.url}/email-login?errorCodes=${LoginErrorCode.UserNotFound}`
)
}
if (!result.headers['set-cookie']) {
if (user.status === StatusType.Pending && user.email) {
await sendConfirmationEmail({
id: user.id,
email: user.email,
name: user.name,
})
return res.redirect(
`${env.client.url}/${
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req.params as any)?.action
}?errorCodes=unknown`
`${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION`
)
}
res.setHeader('set-cookie', result.headers['set-cookie'])
if (!user?.password) {
// user has no password, so they need to set one
return res.redirect(
`${env.client.url}/email-login?errorCodes=${LoginErrorCode.WrongSource}`
)
}
await handleSuccessfulLogin(req, res, data.login.me, false)
// check if password is correct
const validPassword = await comparePassword(password, user.password)
if (!validPassword) {
return res.redirect(
`${env.client.url}/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
)
}
// set auth cookie in response header
await setAuthInCookie({ uid: user.id }, res)
await handleSuccessfulLogin(req, res, user, false)
} catch (e) {
logger.info('email-login exception:', e)
res.redirect(`${env.client.url}/email-login?errorCodes=AUTH_FAILED`)
@ -422,57 +437,206 @@ export function authRouter() {
'/email-signup',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const { email, password, name, username, bio } = req.body
if (!email || !password || !name || !username) {
res.redirect(`${env.client.url}/email-signup?errorCodes=BAD_DATA`)
return
}
const { email, password, name, username, bio, pictureUrl } = req.body
const query = `
mutation signup {
signup(input: {
email: "${email}",
password: "${password}",
name: "${name}",
username: "${username}",
bio: "${bio ?? ''}"
}) {
__typename
... on SignupSuccess {
me {
id
name
profile {
username
}
}
}
... on SignupError {
errorCodes
}
}
}`
if (!email || !password || !name || !username) {
return res.redirect(
`${env.client.url}/email-signup?errorCodes=INVALID_CREDENTIALS`
)
}
const lowerCasedUsername = username.toLowerCase()
try {
const result = await axios.post(env.server.gateway_url + '/graphql', {
query,
})
const { data } = result.data
// hash password
const hashedPassword = await hashPassword(password)
if (data.signup.__typename === 'SignupError') {
const errorCodes = data.signup.errorCodes.join(',')
return res.redirect(
`${env.client.url}/email-signup?errorCodes=${errorCodes}`
)
}
await createUser({
email,
provider: 'EMAIL',
sourceUserId: email,
name,
username: lowerCasedUsername,
pictureUrl,
bio,
password: hashedPassword,
pendingConfirmation: true,
})
res.redirect(`${env.client.url}/email-login?message=SIGNUP_SUCCESS`)
} catch (e) {
logger.info('email-signup exception:', e)
if (isErrorWithCode(e)) {
return res.redirect(
`${env.client.url}/email-signup?errorCodes=${e.errorCode}`
)
}
res.redirect(`${env.client.url}/email-signup?errorCodes=UNKNOWN`)
}
}
)
router.options(
'/confirm-email',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/confirm-email',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const token = req.body.token
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/confirm-email?errorCodes=INVALID_TOKEN`
)
}
const user = await getRepository(User).findOneBy({ id: claims.uid })
if (!user) {
return res.redirect(
`${env.client.url}/confirm-email?errorCodes=USER_NOT_FOUND`
)
}
if (user.status === StatusType.Pending) {
await getRepository(User).update(
{ id: user.id },
{ status: StatusType.Active }
)
}
res.set('Message', 'EMAIL_CONFIRMED')
await setAuthInCookie({ uid: user.id }, res)
await handleSuccessfulLogin(req, res, user, false)
} catch (e) {
logger.info('confirm-email exception:', e)
if (e instanceof jwt.TokenExpiredError) {
return res.redirect(
`${env.client.url}/confirm-email?errorCodes=TOKEN_EXPIRED`
)
}
res.redirect(`${env.client.url}/confirm-email?errorCodes=INVALID_TOKEN`)
}
}
)
router.options(
'/forgot-password',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/forgot-password',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const email = req.body.email
if (!email) {
return res.redirect(
`${env.client.url}/forgot-password?errorCodes=INVALID_EMAIL`
)
}
try {
const user = await getRepository(User).findOneBy({
email,
})
if (!user) {
return res.redirect(
`${env.client.url}/forgot-password?errorCodes=USER_NOT_FOUND`
)
}
if (user.status === StatusType.Pending) {
return res.redirect(
`${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION`
)
}
if (!(await sendPasswordResetEmail(user))) {
return res.redirect(
`${env.client.url}/forgot-password?errorCodes=INVALID_EMAIL`
)
}
res.redirect(`${env.client.url}/forgot-password?message=SUCCESS`)
} catch (e) {
logger.info('forgot-password exception:', e)
res.redirect(`${env.client.url}/forgot-password?errorCodes=UNKNOWN`)
}
}
)
router.options(
'/reset-password',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/reset-password',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const { token, password } = req.body
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/reset-password?errorCodes=INVALID_TOKEN`
)
}
if (!password) {
return res.redirect(
`${env.client.url}/reset-password?errorCodes=INVALID_PASSWORD`
)
}
const user = await getRepository(User).findOneBy({ id: claims.uid })
if (!user) {
return res.redirect(
`${env.client.url}/reset-password?errorCodes=USER_NOT_FOUND`
)
}
if (user.status === StatusType.Pending) {
return res.redirect(
`${env.client.url}/email-login?errorCodes=PENDING_VERIFICATION`
)
}
const hashedPassword = await hashPassword(password)
const updated = await getRepository(User).update(
{ id: user.id },
{ password: hashedPassword }
)
if (!updated.affected) {
return res.redirect(
`${env.client.url}/reset-password?errorCodes=UNKNOWN`
)
}
res.redirect(`${env.client.url}/reset-password?message=SUCCESS`)
} catch (e) {
logger.info('reset-password exception:', e)
if (e instanceof jwt.TokenExpiredError) {
return res.redirect(
`${env.client.url}/reset-password?errorCodes=TOKEN_EXPIRED`
)
}
res.redirect(
`${env.client.url}/reset-password?errorCodes=INVALID_TOKEN`
)
}
}
)
return router
}

View file

@ -1,11 +1,10 @@
import { JsonResponsePayload, UserProfile } from '../auth_types'
import {
decodePendingUserToken,
createMobileAuthPayload,
decodePendingUserToken,
} from './../jwt_helpers'
import { createUser } from '../../../services/create_user'
import { SignupErrorCode } from '../../../generated/graphql'
import { MembershipTier } from '../../../datalayer/user/model'
export async function createMobileAccountCreationResponse(
pendingUserToken?: string,
@ -34,7 +33,6 @@ export async function createMobileAccountCreationResponse(
username: userProfile.username,
pictureUrl: undefined,
bio: userProfile.bio || undefined,
membershipTier: MembershipTier.Beta,
})
const mobileAuthPayload = await createMobileAuthPayload(user.id)

View file

@ -89,7 +89,7 @@ export function emailsServiceRouter() {
// forward non-newsletter emails to the registered email address
const result = await sendEmail({
from: 'msgs@omnivore.app',
from: env.sender.message,
to: newsletterEmail.user.email,
subject: `Fwd: ${data.subject}`,
html: data.html,

View file

@ -102,9 +102,9 @@ export function remindersServiceRouter() {
console.log('dynamic template data:', dynamicTemplateData)
await sendEmail({
from: 'msgs@omnivore.app',
from: env.sender.message,
dynamicTemplateData: dynamicTemplateData,
templateId: process.env.SENDGRID_REMINDER_TEMPLATE_ID,
templateId: env.sendgrid.reminderTemplateId,
to: user.email,
})

View file

@ -168,6 +168,7 @@ const schema = gql`
USER_EXISTS
EXPIRED_TOKEN
INVALID_PASSWORD
INVALID_EMAIL
}
type GoogleSignupError {
@ -1409,30 +1410,6 @@ const schema = gql`
union UpdateLabelResult = UpdateLabelSuccess | UpdateLabelError
input LoginInput {
password: String!
email: String!
}
input SignupInput {
email: String!
password: String! @sanitize(maxLength: 40)
username: String!
name: String!
pictureUrl: String
bio: String
}
type SignupSuccess {
me: User!
}
type SignupError {
errorCodes: [SignupErrorCode]!
}
union SignupResult = SignupSuccess | SignupError
input SetLabelsInput {
pageId: ID!
labelIds: [ID!]!
@ -1843,8 +1820,6 @@ const schema = gql`
createLabel(input: CreateLabelInput!): CreateLabelResult!
updateLabel(input: UpdateLabelInput!): UpdateLabelResult!
deleteLabel(id: ID!): DeleteLabelResult!
login(input: LoginInput!): LoginResult!
signup(input: SignupInput!): SignupResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
unsubscribe(name: String!): UnsubscribeResult!

View file

@ -1,5 +1,5 @@
import { AuthProvider } from '../routers/auth/auth_types'
import { MembershipTier } from '../datalayer/user/model'
import { StatusType } from '../datalayer/user/model'
import { EntityManager } from 'typeorm'
import { User } from '../entity/user'
import { Profile } from '../entity/profile'
@ -9,6 +9,7 @@ import { Invite } from '../entity/groups/invite'
import { GroupMembership } from '../entity/groups/group_membership'
import { AppDataSource } from '../server'
import { getRepository } from '../entity/utils'
import { sendConfirmationEmail } from './send_emails'
export const createUser = async (input: {
provider: AuthProvider
@ -19,9 +20,9 @@ export const createUser = async (input: {
pictureUrl?: string
bio?: string
groups?: [string]
membershipTier?: MembershipTier
inviteCode?: string
password?: string
pendingConfirmation?: boolean
}): Promise<[User, Profile]> => {
const existingUser = await getUser(input.email)
if (existingUser) {
@ -61,13 +62,13 @@ export const createUser = async (input: {
}
const user = await t.getRepository(User).save({
source: input.provider,
membership:
input.membershipTier ||
(hasInvite ? MembershipTier.Beta : MembershipTier.WaitList),
name: input.name,
email: input.email,
sourceUserId: input.sourceUserId,
password: input.password,
status: input.pendingConfirmation
? StatusType.Pending
: StatusType.Active,
})
const profile = await t.getRepository(Profile).save({
username: input.username,
@ -86,6 +87,12 @@ export const createUser = async (input: {
}
)
if (input.pendingConfirmation) {
if (!(await sendConfirmationEmail(user))) {
return Promise.reject({ errorCode: SignupErrorCode.InvalidEmail })
}
}
return [user, profile]
}

View file

@ -0,0 +1,47 @@
import { generateVerificationToken } from '../utils/auth'
import { env } from '../env'
import { sendEmail } from '../utils/sendEmail'
export const sendConfirmationEmail = async (user: {
id: string
name: string
email: string
}): Promise<boolean> => {
// generate confirmation link
const token = generateVerificationToken(user.id)
const link = `${env.client.url}/confirm-email/${token}`
// send email
const dynamicTemplateData = {
name: user.name,
link,
}
return sendEmail({
from: env.sender.message,
to: user.email,
templateId: env.sendgrid.confirmationTemplateId,
dynamicTemplateData,
})
}
export const sendPasswordResetEmail = async (user: {
id: string
name: string
email: string
}): Promise<boolean> => {
// generate link
const token = generateVerificationToken(user.id)
const link = `${env.client.url}/reset-password/${token}`
// send email
const dynamicTemplateData = {
name: user.name,
link,
}
return sendEmail({
from: env.sender.message,
to: user.email,
templateId: env.sendgrid.resetPasswordTemplateId,
dynamicTemplateData,
})
}

View file

@ -73,6 +73,17 @@ interface BackendEnv {
username: string
password: string
}
sender: {
message: string
feedback: string
general: string
}
sendgrid: {
confirmationTemplateId: string
reminderTemplateId: string
resetPasswordTemplateId: string
installationTemplateId: string
}
}
/***
@ -114,6 +125,13 @@ const nullableEnvVars = [
'ELASTIC_USERNAME',
'ELASTIC_PASSWORD',
'GCS_UPLOAD_PRIVATE_BUCKET',
'SENDER_MESSAGE',
'SENDER_FEEDBACK',
'SENDER_GENERAL',
'SENDGRID_CONFIRMATION_TEMPLATE_ID',
'SENDGRID_REMINDER_TEMPLATE_ID',
'SENDGRID_RESET_PASSWORD_TEMPLATE_ID',
'SENDGRID_INSTALLATION_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 */
@ -214,6 +232,18 @@ export function getEnv(): BackendEnv {
username: parse('ELASTIC_USERNAME'),
password: parse('ELASTIC_PASSWORD'),
}
const sender = {
message: parse('SENDER_MESSAGE'),
feedback: parse('SENDER_FEEDBACK'),
general: parse('SENDER_GENERAL'),
}
const sendgrid = {
confirmationTemplateId: parse('SENDGRID_CONFIRMATION_TEMPLATE_ID'),
reminderTemplateId: parse('SENDGRID_REMINDER_TEMPLATE_ID'),
resetPasswordTemplateId: parse('SENDGRID_RESET_PASSWORD_TEMPLATE_ID'),
installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'),
}
return {
pg,
@ -230,6 +260,8 @@ export function getEnv(): BackendEnv {
fileUpload,
queue,
elastic,
sender,
sendgrid,
}
}

View file

@ -1,11 +1,15 @@
import * as bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { Claims } from '../resolvers/types'
import { Claims, ClaimsToSet } from '../resolvers/types'
import { getRepository } from '../entity/utils'
import { ApiKey } from '../entity/api_key'
import crypto from 'crypto'
import * as jwt from 'jsonwebtoken'
import { env } from '../env'
import express from 'express'
import { promisify } from 'util'
const signToken = promisify(jwt.sign)
export const hashPassword = async (password: string, salt = 10) => {
return bcrypt.hash(password, salt)
@ -68,14 +72,43 @@ export const getClaimsByToken = async (
try {
jwt.verify(token, env.server.jwtSecret) &&
(claims = jwt.decode(token) as Claims)
} catch (e) {
if (e instanceof jwt.JsonWebTokenError) {
console.log(`not a jwt token, checking api key`, { token })
claims = await claimsFromApiKey(token)
} else {
throw e
}
}
return claims
return claims
} catch (e) {
if (
e instanceof jwt.JsonWebTokenError &&
!(e instanceof jwt.TokenExpiredError)
) {
console.log(`not a jwt token, checking api key`, { token })
return claimsFromApiKey(token)
}
throw e
}
}
export const generateVerificationToken = (
userId: string,
expireInDays = 1
): string => {
const iat = Math.floor(Date.now() / 1000)
const exp = Math.floor(
new Date(Date.now() + 1000 * 60 * 60 * 24 * expireInDays).getTime() / 1000
)
return jwt.sign({ uid: userId, iat, exp }, env.server.jwtSecret)
}
export const setAuthInCookie = async (
claims: ClaimsToSet,
res: express.Response,
secret: string = env.server.jwtSecret
) => {
// set auth cookie in response header
const token = await signToken(claims, secret)
res.cookie('auth', token, {
httpOnly: true,
expires: new Date(new Date().getTime() + 365 * 24 * 60 * 60 * 1000),
})
}

View file

@ -7,11 +7,7 @@ import {
ResolverFn,
} from '../generated/graphql'
import { Claims, WithDataSourcesContext } from '../resolvers/types'
import {
MembershipTier,
RegistrationType,
UserData,
} from '../datalayer/user/model'
import { RegistrationType, UserData } from '../datalayer/user/model'
import crypto from 'crypto'
import slugify from 'voca/slugify'
import { Merge } from '../util'
@ -128,7 +124,6 @@ export const userDataToUser = (
id: string
name: string
source: RegistrationType
membership: MembershipTier
email?: string | null
phone?: string | null
picture?: string | null
@ -149,11 +144,10 @@ export const userDataToUser = (
...user,
name: user.name,
source: user.source as RegistrationType,
membership: user.membership as MembershipTier,
createdAt: user.createdAt || new Date(),
friendsCount: user.friendsCount || 0,
followersCount: user.followersCount || 0,
isFullUser: isFullUser(user.membership as MembershipTier),
isFullUser: true,
viewerIsFollowing: user.viewerIsFollowing || user.isFriend || false,
picture: user.profile.picture_url,
sharedArticles: [],
@ -166,10 +160,6 @@ export const userDataToUser = (
},
})
export const isFullUser = (membership: MembershipTier): boolean => {
return membership != MembershipTier.WaitList
}
export const generateSlug = (title: string): string => {
return slugify(title).substring(0, 64) + '-' + Date.now().toString(16)
}

View file

@ -30,14 +30,18 @@ export const sendEmail = async (msg: MailDataRequired): Promise<boolean> => {
console.log('sending email', msg)
await client.send(msg).catch((error) => {
try {
const response = await client.send(msg)
console.log('email sent', response)
return true
} catch (error) {
console.log('error sending email', error)
const err = asSendGridError(error)
if (err) {
console.log('sendgrid error:', JSON.stringify(err.response?.body))
}
return false
})
return true
return false
}
}

View file

@ -71,7 +71,8 @@ export const deleteTestUser = async (name: string) => {
export const createTestUser = async (
name: string,
invite?: string | undefined,
password?: string
password?: string,
pendingConfirmation?: boolean
): Promise<User> => {
const [newUser] = await createUser({
provider: 'GOOGLE',
@ -82,6 +83,7 @@ export const createTestUser = async (
name: name,
inviteCode: invite,
password: password,
pendingConfirmation,
})
return newUser

View file

@ -7,12 +7,12 @@ import { createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
import { createPubSubClient } from '../../src/datalayer/pubsub'
import { expect } from 'chai'
import { describe } from 'mocha'
import { getPageById } from '../../src/elastic/pages'
import {
ArticleSavingRequestErrorCode,
CreateArticleSavingRequestErrorCode,
} from '../../src/generated/graphql'
import 'mocha'
const articleSavingRequestQuery = (id: string) => `
query {

View file

@ -2,8 +2,6 @@ import { createTestUser, deleteTestUser, getProfile, getUser } from '../db'
import { graphqlRequest, request } from '../util'
import { expect } from 'chai'
import {
LoginErrorCode,
SignupErrorCode,
UpdateUserErrorCode,
UpdateUserProfileErrorCode,
} from '../../src/generated/graphql'
@ -239,184 +237,4 @@ describe('User API', () => {
return graphqlRequest(query, invalidAuthToken).expect(500)
})
})
describe('login', () => {
let query: string
let email: string
let password: string
beforeEach(() => {
query = `
mutation {
login(
input: {
email: "${email}"
password: "${password}"
}
) {
... on LoginSuccess {
me {
id
name
profile {
username
}
}
}
... on LoginError {
errorCodes
}
}
}
`
})
context('when email and password are valid', () => {
before(() => {
email = user.email
password = correctPassword
})
it('responds with 200', async () => {
const res = await graphqlRequest(query).expect(200)
expect(res.body.data.login.me.id).to.eql(user.id)
})
})
context('when user not exists', () => {
before(() => {
email = 'Some email'
})
it('responds with error code UserNotFound', async () => {
const response = await graphqlRequest(query).expect(200)
expect(response.body.data.login.errorCodes).to.eql([
LoginErrorCode.UserNotFound,
])
})
})
context('when user has no password stored in db', () => {
before(() => {
email = anotherUser.email
password = 'Some password'
})
it('responds with error code WrongSource', async () => {
const response = await graphqlRequest(query).expect(200)
expect(response.body.data.login.errorCodes).to.eql([
LoginErrorCode.WrongSource,
])
})
})
context('when password is wrong', () => {
before(() => {
email = user.email
password = 'Some password'
})
it('responds with error code UserNotFound', async () => {
const response = await graphqlRequest(query).expect(200)
expect(response.body.data.login.errorCodes).to.eql([
LoginErrorCode.InvalidCredentials,
])
})
})
})
describe('signup', () => {
let query: string
let email: string
let password: string
let username: string
beforeEach(() => {
query = `
mutation {
signup(
input: {
email: "${email}"
password: "${password}"
name: "Some name"
username: "${username}"
}
) {
... on SignupSuccess {
me {
id
name
profile {
username
}
}
}
... on SignupError {
errorCodes
}
}
}
`
})
context('when inputs are valid and user not exists', () => {
before(() => {
password = correctPassword
username = 'Some_username'
email = `${username}@fake.com`
})
after(async () => {
await deleteTestUser(username)
})
it('responds with 200', async () => {
const res = await graphqlRequest(query).expect(200)
const user = await getUser(res.body.data.signup.me.id)
expect(user).to.exist
})
})
context('when password is too long', () => {
before(() => {
email = 'Some_email'
password = 'Some_password_that_is_too_long_for_database'
username = 'Some_username'
})
it('responds with status code 400', async () => {
return graphqlRequest(query).expect(400)
})
})
context('when user exists', () => {
before(() => {
email = user.email
password = 'Some password'
username = 'Some username'
})
it('responds with error code UserExists', async () => {
const response = await graphqlRequest(query).expect(200)
expect(response.body.data.signup.errorCodes).to.eql([
SignupErrorCode.UserExists,
])
})
})
context('when username is invalid', () => {
before(() => {
email = 'Some_email'
password = correctPassword
username = 'omnivore_admin'
})
it('responds with error code InvalidUsername', async () => {
const response = await graphqlRequest(query).expect(200)
expect(response.body.data.signup.errorCodes).to.eql([
SignupErrorCode.InvalidUsername,
])
})
})
})
})

View file

@ -0,0 +1,554 @@
import { createTestUser, deleteTestUser } from '../db'
import { generateFakeUuid, request } from '../util'
import { expect } from 'chai'
import { StatusType } from '../../src/datalayer/user/model'
import { getRepository } from '../../src/entity/utils'
import { User } from '../../src/entity/user'
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
import sinon from 'sinon'
import * as util from '../../src/utils/sendEmail'
import supertest from 'supertest'
import {
comparePassword,
generateVerificationToken,
hashPassword,
} from '../../src/utils/auth'
describe('auth router', () => {
const route = '/api/auth'
describe('email signup', () => {
const signupRequest = (
email: string,
password: string,
name: string,
username: string
): supertest.Test => {
return request.post(`${route}/email-signup`).send({
email,
password,
name,
username,
})
}
const validPassword = 'validPassword'
let email: string
let password: string
let username: string
let name: string
context('when inputs are valid and user not exists', () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
before(() => {
password = validPassword
username = 'Some_username'
email = `${username}@fake.com`
name = 'Some name'
})
afterEach(async () => {
await deleteTestUser(username)
})
context('when confirmation email sent', () => {
beforeEach(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
afterEach(() => {
sinon.restore()
})
it('redirects to login page', async () => {
const res = await signupRequest(
email,
password,
name,
username
).expect(302)
expect(res.header.location).to.endWith(
'/email-login?message=SIGNUP_SUCCESS'
)
})
it('creates the user with pending status and correct name', async () => {
await signupRequest(email, password, name, username).expect(302)
const user = await getRepository(User).findOneBy({ name })
expect(user?.status).to.eql(StatusType.Pending)
expect(user?.name).to.eql(name)
})
})
context('when confirmation email not sent', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false))
})
after(() => {
sinon.restore()
})
it('redirects to sign up page with error code INVALID_EMAIL', async () => {
const res = await signupRequest(
email,
password,
name,
username
).expect(302)
expect(res.header.location).to.endWith(
'/email-signup?errorCodes=INVALID_EMAIL'
)
})
})
})
context('when user exists', () => {
before(async () => {
username = 'Some_username'
const user = await createTestUser(username)
email = user.email
password = 'Some password'
})
after(async () => {
await deleteTestUser(username)
})
it('redirects to sign up page with error code USER_EXISTS', async () => {
const res = await signupRequest(email, password, name, username).expect(
302
)
expect(res.header.location).to.endWith(
'/email-signup?errorCodes=USER_EXISTS'
)
})
})
context('when username is invalid', () => {
before(() => {
email = 'Some_email'
password = validPassword
username = 'omnivore_admin'
})
it('redirects to sign up page with error code INVALID_USERNAME', async () => {
const res = await signupRequest(email, password, name, username).expect(
302
)
expect(res.header.location).to.endWith(
'/email-signup?errorCodes=INVALID_USERNAME'
)
})
})
})
describe('login', () => {
const loginRequest = (email: string, password: string): supertest.Test => {
return request.post(`${route}/email-login`).send({
email,
password,
})
}
const correctPassword = 'correctPassword'
let user: User
let email: string
let password: string
before(async () => {
const hashedPassword = await hashPassword(correctPassword)
user = await createTestUser('login_test_user', undefined, hashedPassword)
})
after(async () => {
await deleteTestUser(user.name)
})
context('when email and password are valid', () => {
before(() => {
email = user.email
password = correctPassword
})
it('redirects to home page', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header.location).to.endWith('/home')
})
it('set auth token in cookie', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header['set-cookie']).to.be.an('array')
expect(res.header['set-cookie'][0]).to.contain('auth')
})
})
context('when user is not confirmed', async () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
beforeEach(async () => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
await getRepository(User).update(user.id, {
status: StatusType.Pending,
})
email = user.email
password = correctPassword
})
afterEach(async () => {
await getRepository(User).update(user.id, {
status: StatusType.Active,
})
sinon.restore()
})
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 () => {
await loginRequest(email, password).expect(302)
expect(fake).to.have.been.calledOnce
})
})
context('when user not exists', () => {
before(() => {
email = 'Some email'
})
it('redirects with error code UserNotFound', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header.location).to.endWith(
'/email-login?errorCodes=USER_NOT_FOUND'
)
})
})
context('when user has no password stored in db', async () => {
before(async () => {
await getRepository(User).update(user.id, {
password: '',
})
email = user.email
password = user.password!
})
after(async () => {
await getRepository(User).update(user.id, {
password,
})
})
it('redirects with error code WrongSource', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header.location).to.endWith(
'/email-login?errorCodes=WRONG_SOURCE'
)
})
})
context('when password is wrong', () => {
before(() => {
email = user.email
password = 'Wrong password'
})
it('redirects with error code InvalidCredentials', async () => {
const res = await loginRequest(email, password).expect(302)
expect(res.header.location).to.endWith(
'/email-login?errorCodes=INVALID_CREDENTIALS'
)
})
})
})
describe('confirm-email', () => {
const confirmEmailRequest = (token: string): supertest.Test => {
return request.post(`${route}/confirm-email`).send({ token })
}
let user: User
let token: string
before(async () => {
sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
user = await createTestUser('pendingUser', undefined, 'password', true)
})
after(async () => {
sinon.restore()
await deleteTestUser(user.name)
})
context('when token is valid', () => {
before(() => {
token = generateVerificationToken(user.id)
})
it('set auth token in cookie', async () => {
const res = await confirmEmailRequest(token).expect(302)
expect(res.header['set-cookie']).to.be.an('array')
expect(res.header['set-cookie'][0]).to.contain('auth')
})
it('redirects to home page', async () => {
const res = await confirmEmailRequest(token).expect(302)
expect(res.header.location).to.endWith('/home?message=EMAIL_CONFIRMED')
})
it('sets user as active', async () => {
await confirmEmailRequest(token).expect(302)
const updatedUser = await getRepository(User).findOneBy({
name: user.name,
})
expect(updatedUser?.status).to.eql(StatusType.Active)
})
})
context('when token is invalid', () => {
it('redirects to confirm-email with error code InvalidToken', async () => {
const res = await confirmEmailRequest('invalid_token').expect(302)
expect(res.header.location).to.endWith(
'/confirm-email?errorCodes=INVALID_TOKEN'
)
})
})
context('when token is expired', () => {
before(() => {
token = generateVerificationToken(user.id, -1)
})
it('redirects to confirm-email page with error code TokenExpired', async () => {
const res = await confirmEmailRequest(token).expect(302)
expect(res.header.location).to.endWith(
'/confirm-email?errorCodes=TOKEN_EXPIRED'
)
})
})
context('when user is not found', () => {
before(() => {
const nonExistsUserId = generateFakeUuid()
token = generateVerificationToken(nonExistsUserId)
})
it('redirects to confirm-email page with error code UserNotFound', async () => {
const res = await confirmEmailRequest(token).expect(302)
expect(res.header.location).to.endWith(
'/confirm-email?errorCodes=USER_NOT_FOUND'
)
})
})
})
describe('forgot-password', () => {
const emailResetPasswordReq = (email: string): supertest.Test => {
return request.post(`${route}/forgot-password`).send({
email,
})
}
let email: string
context('when email is not empty', () => {
before(() => {
email = `some_email@domain.app`
})
context('when user exists', () => {
let user: User
before(async () => {
user = await createTestUser('test_user')
email = user.email
})
after(async () => {
await deleteTestUser(user.name)
})
context('when email is verified', () => {
let fake: (msg: MailDataRequired) => Promise<boolean>
before(async () => {
await getRepository(User).update(user.id, {
status: StatusType.Active,
})
})
context('when reset password email sent', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
after(() => {
sinon.restore()
})
it('redirects to forgot-password page with success message', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/forgot-password?message=SUCCESS'
)
})
})
context('when reset password email not sent', () => {
before(() => {
fake = sinon.replace(
util,
'sendEmail',
sinon.fake.resolves(false)
)
})
after(() => {
sinon.restore()
})
it('redirects to sign up page with error code INVALID_EMAIL', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/forgot-password?errorCodes=INVALID_EMAIL'
)
})
})
})
context('when email is not verified', () => {
before(async () => {
await getRepository(User).update(user.id, {
status: StatusType.Pending,
})
})
it('redirects to email-login page with error code PENDING_VERIFICATION', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/email-login?errorCodes=PENDING_VERIFICATION'
)
})
})
})
context('when user does not exist', () => {
before(() => {
email = 'non_exists_email@domain.app'
})
it('redirects to forgot-password page with error code USER_NOT_FOUND', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/forgot-password?errorCodes=USER_NOT_FOUND'
)
})
})
})
context('when email is empty', () => {
before(() => {
email = ''
})
it('redirects to forgot-password page with error code INVALID_EMAIL', async () => {
const res = await emailResetPasswordReq(email).expect(302)
expect(res.header.location).to.endWith(
'/forgot-password?errorCodes=INVALID_EMAIL'
)
})
})
})
describe('reset-password', () => {
const resetPasswordRequest = (
token: string,
password: string
): supertest.Test => {
return request.post(`${route}/reset-password`).send({
token,
password,
})
}
let user: User
let token: string
before(async () => {
user = await createTestUser('test_user', undefined, 'test_password')
})
after(async () => {
await deleteTestUser(user.name)
})
context('when token is valid', () => {
before(async () => {
token = generateVerificationToken(user.id)
})
context('when password is not empty', () => {
it('redirects to reset-password page with success message', async () => {
const res = await resetPasswordRequest(token, 'new_password').expect(
302
)
expect(res.header.location).to.endWith(
'/reset-password?message=SUCCESS'
)
})
it('resets password', async () => {
const password = 'test_reset_password'
await resetPasswordRequest(token, password).expect(302)
const updatedUser = await getRepository(User).findOneBy({
id: user?.id,
})
expect(await comparePassword(password, updatedUser?.password!)).to.be
.true
})
})
context('when password is empty', () => {
it('redirects to reset-password page with error code INVALID_PASSWORD', async () => {
const res = await resetPasswordRequest(token, '').expect(302)
expect(res.header.location).to.endWith(
'/reset-password?errorCodes=INVALID_PASSWORD'
)
})
})
})
context('when token is invalid', () => {
it('redirects to reset-password page with error code InvalidToken', async () => {
const res = await resetPasswordRequest(
'invalid_token',
'new_password'
).expect(302)
expect(res.header.location).to.endWith(
'/reset-password?errorCodes=INVALID_TOKEN'
)
})
context('when token is expired', () => {
before(() => {
token = generateVerificationToken(user.id, -1)
})
it('redirects to reset-password page with error code ExpiredToken', async () => {
const res = await resetPasswordRequest(token, 'new_password').expect(
302
)
expect(res.header.location).to.endWith(
'/reset-password?errorCodes=TOKEN_EXPIRED'
)
})
})
})
})
})

View file

@ -1,5 +1,5 @@
import 'mocha'
import { expect } from 'chai'
import chai, { expect } from 'chai'
import 'chai/register-should'
import {
createTestUser,
@ -12,42 +12,96 @@ import {
getUserFollowers,
getUserFollowing,
} from '../../src/services/followers'
import { StatusType } from '../../src/datalayer/user/model'
import sinonChai from 'sinon-chai'
import sinon from 'sinon'
import * as util from '../../src/utils/sendEmail'
import { MailDataRequired } from '@sendgrid/helpers/classes/mail'
describe('create a user with an invite', () => {
it('follows the other user in the group', async () => {
after(async () => {
await deleteTestUser(testOwner)
await deleteTestUser(testUser)
chai.use(sinonChai)
describe('create user', () => {
context('create a user with an invite', () => {
it('follows the other user in the group', async () => {
after(async () => {
await deleteTestUser(testOwner)
await deleteTestUser(testUser)
})
const testOwner = 'testowner'
const testUser = 'testuser'
const adminUser = await createTestUser(testOwner)
const [, invite] = await createGroup({
admin: adminUser,
name: 'testgroup',
})
const user = await createTestUser(testUser, invite.code)
expect(await getUserFollowers(user)).to.eql([adminUser])
expect(await getUserFollowing(user)).to.eql([adminUser])
expect(await getUserFollowers(adminUser)).to.eql([user])
expect(await getUserFollowing(adminUser)).to.eql([user])
})
const testOwner = 'testowner'
const testUser = 'testuser'
it('creates profile when user exists but profile not', async () => {
after(async () => {
await deleteTestUser(name)
})
const adminUser = await createTestUser(testOwner)
const [, invite] = await createGroup({
admin: adminUser,
name: 'testgroup',
const name = 'userWithoutProfile'
const user = await createUserWithoutProfile(name)
await createTestUser(user.name)
const profile = await getProfile(user)
expect(profile).to.exist
})
const user = await createTestUser(testUser, invite.code)
})
expect(await getUserFollowers(user)).to.eql([adminUser])
expect(await getUserFollowing(user)).to.eql([adminUser])
expect(await getUserFollowers(adminUser)).to.eql([user])
expect(await getUserFollowing(adminUser)).to.eql([user])
}).timeout(10000)
context('create a user with pending confirmation', () => {
const name = 'pendingUser'
let fake: (msg: MailDataRequired) => Promise<boolean>
it('creates profile when user exists but profile not', async () => {
after(async () => {
await deleteTestUser(name)
context('when email sends successfully', () => {
beforeEach(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(true))
})
afterEach(async () => {
sinon.restore()
await deleteTestUser(name)
})
it('creates the user with pending status and correct name', async () => {
const user = await createTestUser(name, undefined, undefined, true)
expect(user.status).to.eql(StatusType.Pending)
expect(user.name).to.eql(name)
})
it('sends an email to the user', async () => {
await createTestUser(name, undefined, undefined, true)
expect(fake).to.have.been.calledOnce
})
})
const name = 'userWithoutProfile'
const user = await createUserWithoutProfile(name)
context('when failed to send email', () => {
before(() => {
fake = sinon.replace(util, 'sendEmail', sinon.fake.resolves(false))
})
await createTestUser(user.name)
after(async () => {
sinon.restore()
await deleteTestUser(name)
})
const profile = await getProfile(user)
expect(profile).to.exist
it('rejects with error', async () => {
return expect(createTestUser(name, undefined, undefined, true)).to.be
.rejected
})
})
})
})

View file

@ -0,0 +1,11 @@
-- Type: DO
-- Name: add_status_to_user
-- Description: Add status to user table
BEGIN;
CREATE TYPE user_status_type AS ENUM ('ACTIVE', 'PENDING');
ALTER TABLE omnivore.user ADD COLUMN status user_status_type NOT NULL DEFAULT 'ACTIVE';
COMMIT;

View file

@ -0,0 +1,11 @@
-- Type: UNDO
-- Name: add_status_to_user
-- Description: Add status to user table
BEGIN;
DROP TYPE IF EXISTS user_status_type CASCADE;
ALTER TABLE omnivore.user DROP COLUMN IF EXISTS status;
COMMIT;

View file

@ -0,0 +1,12 @@
-- Type: DO
-- Name: drop_membership_from_user
-- Description: drop membership column from user table
BEGIN;
ALTER TABLE omnivore.user
DROP column membership;
DROP TYPE omnivore.membership_tier;
COMMIT;

View file

@ -0,0 +1,14 @@
-- Type: UNDO
-- Name: drop_membership_from_user
-- Description: drop membership column from user table
BEGIN;
CREATE TYPE omnivore.membership_tier AS ENUM ('WAIT_LIST', 'BETA');
ALTER TABLE omnivore.user
ADD column membership omnivore.membership_tier NOT NULL DEFAULT 'WAIT_LIST';
UPDATE omnivore.user SET membership = 'BETA';
COMMIT;

View file

@ -5415,13 +5415,20 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
"@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.7.0":
"@sinonjs/commons@^1", "@sinonjs/commons@^1.3.0", "@sinonjs/commons@^1.4.0", "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
version "1.8.3"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==
dependencies:
type-detect "4.0.8"
"@sinonjs/fake-timers@>=5", "@sinonjs/fake-timers@^9.1.2":
version "9.1.2"
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
dependencies:
"@sinonjs/commons" "^1.7.0"
"@sinonjs/fake-timers@^8.0.1":
version "8.0.1"
resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-8.0.1.tgz#1c1c9a91419f804e59ae8df316a07dd1c3a76b94"
@ -5446,6 +5453,15 @@
array-from "^2.1.1"
lodash "^4.17.15"
"@sinonjs/samsam@^6.1.1":
version "6.1.1"
resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.1.tgz#627f7f4cbdb56e6419fa2c1a3e4751ce4f6a00b1"
integrity sha512-cZ7rKJTLiE7u7Wi/v9Hc2fs3Ucc3jrWeMgPHbbTCeVAB2S0wOBbYlkJVeNSL04i7fdhT8wIbDq1zhC/PXTD2SA==
dependencies:
"@sinonjs/commons" "^1.6.0"
lodash.get "^4.4.2"
type-detect "^4.0.8"
"@sinonjs/text-encoding@^0.7.1":
version "0.7.1"
resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5"
@ -7994,6 +8010,26 @@
"@types/mime" "^1"
"@types/node" "*"
"@types/sinon-chai@^3.2.8":
version "3.2.8"
resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
dependencies:
"@types/chai" "*"
"@types/sinon" "*"
"@types/sinon@*", "@types/sinon@^10.0.13":
version "10.0.13"
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.13.tgz#60a7a87a70d9372d0b7b38cc03e825f46981fb83"
integrity sha512-UVjDqJblVNQYvVNUsj0PuYYw0ELRmgt1Nt5Vk0pT5f16ROGfcKJY8o1HVuMOJOpD727RrGB9EGvoaTQE5tgxZQ==
dependencies:
"@types/sinonjs__fake-timers" "*"
"@types/sinonjs__fake-timers@*":
version "8.1.2"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.2.tgz#bf2e02a3dbd4aecaf95942ecd99b7402e03fad5e"
integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
"@types/sinonjs__fake-timers@8.1.1":
version "8.1.1"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz#b49c2c70150141a15e0fa7e79cf1f92a72934ce3"
@ -12105,6 +12141,11 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diff@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
diff@~1.0.7:
version "1.0.8"
resolved "https://registry.yarnpkg.com/diff/-/diff-1.0.8.tgz#343276308ec991b7bc82267ed55bc1411f971666"
@ -18738,6 +18779,17 @@ nise@^1.5.2:
lolex "^5.0.1"
path-to-regexp "^1.7.0"
nise@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.1.tgz#ac4237e0d785ecfcb83e20f389185975da5c31f3"
integrity sha512-yr5kW2THW1AkxVmCnKEh4nbYkJdB3I7LUkiUgOvEkOp414mc2UMaHMA7pjq1nYowhdoJZGwEKGaQVbxfpWj10A==
dependencies:
"@sinonjs/commons" "^1.8.3"
"@sinonjs/fake-timers" ">=5"
"@sinonjs/text-encoding" "^0.7.1"
just-extend "^4.0.2"
path-to-regexp "^1.7.0"
no-case@^2.2.0, no-case@^2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
@ -22318,6 +22370,23 @@ simple-swizzle@^0.2.2:
dependencies:
is-arrayish "^0.3.1"
sinon-chai@^3.7.0:
version "3.7.0"
resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-3.7.0.tgz#cfb7dec1c50990ed18c153f1840721cf13139783"
integrity sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==
sinon@^14.0.0:
version "14.0.0"
resolved "https://registry.yarnpkg.com/sinon/-/sinon-14.0.0.tgz#203731c116d3a2d58dc4e3cbe1f443ba9382a031"
integrity sha512-ugA6BFmE+WrJdh0owRZHToLd32Uw3Lxq6E6LtNRU+xTVBefx632h03Q7apXWRsRdZAJ41LB8aUfn2+O4jsDNMw==
dependencies:
"@sinonjs/commons" "^1.8.3"
"@sinonjs/fake-timers" "^9.1.2"
"@sinonjs/samsam" "^6.1.1"
diff "^5.0.0"
nise "^5.1.1"
supports-color "^7.2.0"
sinon@^7.3.2:
version "7.5.0"
resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.5.0.tgz#e9488ea466070ea908fd44a3d6478fd4923c67ec"
@ -23144,7 +23213,7 @@ supports-color@^5.3.0, supports-color@^5.5.0:
dependencies:
has-flag "^3.0.0"
supports-color@^7.0.0, supports-color@^7.1.0:
supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
@ -23874,7 +23943,7 @@ type-detect@0.1.1:
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-0.1.1.tgz#0ba5ec2a885640e470ea4e8505971900dac58822"
integrity sha1-C6XsKohWQORw6k6FBZcZANrFiCI=
type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5:
type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==