mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
commit
efc62600f4
36 changed files with 1185 additions and 557 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
"reporter": [
|
||||
"text-summary"
|
||||
],
|
||||
"branches": 0,
|
||||
"branches": 40,
|
||||
"lines": 0,
|
||||
"functions": 0,
|
||||
"statements": 60
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
|||
47
packages/api/src/services/send_emails.ts
Normal file
47
packages/api/src/services/send_emails.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
554
packages/api/test/routers/auth.test.ts
Normal file
554
packages/api/test/routers/auth.test.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
11
packages/db/migrations/0088.do.add_status_to_user.sql
Executable file
11
packages/db/migrations/0088.do.add_status_to_user.sql
Executable 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;
|
||||
11
packages/db/migrations/0088.undo.add_status_to_user.sql
Executable file
11
packages/db/migrations/0088.undo.add_status_to_user.sql
Executable 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;
|
||||
12
packages/db/migrations/0089.do.drop_membership_from_user.sql
Executable file
12
packages/db/migrations/0089.do.drop_membership_from_user.sql
Executable 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;
|
||||
14
packages/db/migrations/0089.undo.drop_membership_from_user.sql
Executable file
14
packages/db/migrations/0089.undo.drop_membership_from_user.sql
Executable 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;
|
||||
75
yarn.lock
75
yarn.lock
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue