Merge pull request #2831 from omnivore-app/feature/convert-to-email-api

Update Email Feature
This commit is contained in:
Jackson Harper
2023-10-04 14:30:35 +08:00
committed by GitHub
15 changed files with 386 additions and 31 deletions

View File

@ -1291,6 +1291,7 @@ export type Mutation = {
setWebhook: SetWebhookResult;
subscribe: SubscribeResult;
unsubscribe: UnsubscribeResult;
updateEmail: UpdateEmailResult;
updateFilter: UpdateFilterResult;
updateHighlight: UpdateHighlightResult;
updateHighlightReply: UpdateHighlightReplyResult;
@ -1592,6 +1593,11 @@ export type MutationUnsubscribeArgs = {
};
export type MutationUpdateEmailArgs = {
input: UpdateEmailInput;
};
export type MutationUpdateFilterArgs = {
input: UpdateFilterInput;
};
@ -2845,6 +2851,29 @@ export type UnsubscribeSuccess = {
subscription: Subscription;
};
export type UpdateEmailError = {
__typename?: 'UpdateEmailError';
errorCodes: Array<UpdateEmailErrorCode>;
};
export enum UpdateEmailErrorCode {
BadRequest = 'BAD_REQUEST',
EmailAlreadyExists = 'EMAIL_ALREADY_EXISTS',
Unauthorized = 'UNAUTHORIZED'
}
export type UpdateEmailInput = {
email: Scalars['String'];
};
export type UpdateEmailResult = UpdateEmailError | UpdateEmailSuccess;
export type UpdateEmailSuccess = {
__typename?: 'UpdateEmailSuccess';
email: Scalars['String'];
verificationEmailSent?: Maybe<Scalars['Boolean']>;
};
export type UpdateFilterError = {
__typename?: 'UpdateFilterError';
errorCodes: Array<UpdateFilterErrorCode>;
@ -3211,6 +3240,7 @@ export enum UploadImportFileType {
export type User = {
__typename?: 'User';
email?: Maybe<Scalars['String']>;
followersCount?: Maybe<Scalars['Int']>;
friendsCount?: Maybe<Scalars['Int']>;
id: Scalars['ID'];
@ -3224,6 +3254,7 @@ export type User = {
sharedArticlesCount?: Maybe<Scalars['Int']>;
sharedHighlightsCount?: Maybe<Scalars['Int']>;
sharedNotesCount?: Maybe<Scalars['Int']>;
source?: Maybe<Scalars['String']>;
viewerIsFollowing?: Maybe<Scalars['Boolean']>;
};
@ -3813,6 +3844,11 @@ export type ResolversTypes = {
UnsubscribeErrorCode: UnsubscribeErrorCode;
UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess'];
UnsubscribeSuccess: ResolverTypeWrapper<UnsubscribeSuccess>;
UpdateEmailError: ResolverTypeWrapper<UpdateEmailError>;
UpdateEmailErrorCode: UpdateEmailErrorCode;
UpdateEmailInput: UpdateEmailInput;
UpdateEmailResult: ResolversTypes['UpdateEmailError'] | ResolversTypes['UpdateEmailSuccess'];
UpdateEmailSuccess: ResolverTypeWrapper<UpdateEmailSuccess>;
UpdateFilterError: ResolverTypeWrapper<UpdateFilterError>;
UpdateFilterErrorCode: UpdateFilterErrorCode;
UpdateFilterInput: UpdateFilterInput;
@ -4226,6 +4262,10 @@ export type ResolversParentTypes = {
UnsubscribeError: UnsubscribeError;
UnsubscribeResult: ResolversParentTypes['UnsubscribeError'] | ResolversParentTypes['UnsubscribeSuccess'];
UnsubscribeSuccess: UnsubscribeSuccess;
UpdateEmailError: UpdateEmailError;
UpdateEmailInput: UpdateEmailInput;
UpdateEmailResult: ResolversParentTypes['UpdateEmailError'] | ResolversParentTypes['UpdateEmailSuccess'];
UpdateEmailSuccess: UpdateEmailSuccess;
UpdateFilterError: UpdateFilterError;
UpdateFilterInput: UpdateFilterInput;
UpdateFilterResult: ResolversParentTypes['UpdateFilterError'] | ResolversParentTypes['UpdateFilterSuccess'];
@ -5247,6 +5287,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
setWebhook?: Resolver<ResolversTypes['SetWebhookResult'], ParentType, ContextType, RequireFields<MutationSetWebhookArgs, 'input'>>;
subscribe?: Resolver<ResolversTypes['SubscribeResult'], ParentType, ContextType, RequireFields<MutationSubscribeArgs, 'input'>>;
unsubscribe?: Resolver<ResolversTypes['UnsubscribeResult'], ParentType, ContextType, RequireFields<MutationUnsubscribeArgs, 'name'>>;
updateEmail?: Resolver<ResolversTypes['UpdateEmailResult'], ParentType, ContextType, RequireFields<MutationUpdateEmailArgs, 'input'>>;
updateFilter?: Resolver<ResolversTypes['UpdateFilterResult'], ParentType, ContextType, RequireFields<MutationUpdateFilterArgs, 'input'>>;
updateHighlight?: Resolver<ResolversTypes['UpdateHighlightResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightArgs, 'input'>>;
updateHighlightReply?: Resolver<ResolversTypes['UpdateHighlightReplyResult'], ParentType, ContextType, RequireFields<MutationUpdateHighlightReplyArgs, 'input'>>;
@ -5955,6 +5996,21 @@ export type UnsubscribeSuccessResolvers<ContextType = ResolverContext, ParentTyp
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateEmailErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailError'] = ResolversParentTypes['UpdateEmailError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateEmailErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateEmailResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailResult'] = ResolversParentTypes['UpdateEmailResult']> = {
__resolveType: TypeResolveFn<'UpdateEmailError' | 'UpdateEmailSuccess', ParentType, ContextType>;
};
export type UpdateEmailSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateEmailSuccess'] = ResolversParentTypes['UpdateEmailSuccess']> = {
email?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
verificationEmailSent?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type UpdateFilterErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['UpdateFilterError'] = ResolversParentTypes['UpdateFilterError']> = {
errorCodes?: Resolver<Array<ResolversTypes['UpdateFilterErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -6157,6 +6213,7 @@ export type UploadImportFileSuccessResolvers<ContextType = ResolverContext, Pare
};
export type UserResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User']> = {
email?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
followersCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
friendsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
@ -6169,6 +6226,7 @@ export type UserResolvers<ContextType = ResolverContext, ParentType extends Reso
sharedArticlesCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
sharedHighlightsCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
sharedNotesCount?: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
source?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
viewerIsFollowing?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@ -6528,6 +6586,9 @@ export type Resolvers<ContextType = ResolverContext> = {
UnsubscribeError?: UnsubscribeErrorResolvers<ContextType>;
UnsubscribeResult?: UnsubscribeResultResolvers<ContextType>;
UnsubscribeSuccess?: UnsubscribeSuccessResolvers<ContextType>;
UpdateEmailError?: UpdateEmailErrorResolvers<ContextType>;
UpdateEmailResult?: UpdateEmailResultResolvers<ContextType>;
UpdateEmailSuccess?: UpdateEmailSuccessResolvers<ContextType>;
UpdateFilterError?: UpdateFilterErrorResolvers<ContextType>;
UpdateFilterResult?: UpdateFilterResultResolvers<ContextType>;
UpdateFilterSuccess?: UpdateFilterSuccessResolvers<ContextType>;

View File

@ -1159,6 +1159,7 @@ type Mutation {
setWebhook(input: SetWebhookInput!): SetWebhookResult!
subscribe(input: SubscribeInput!): SubscribeResult!
unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult!
updateEmail(input: UpdateEmailInput!): UpdateEmailResult!
updateFilter(input: UpdateFilterInput!): UpdateFilterResult!
updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult!
updateHighlightReply(input: UpdateHighlightReplyInput!): UpdateHighlightReplyResult!
@ -2177,6 +2178,27 @@ type UnsubscribeSuccess {
subscription: Subscription!
}
type UpdateEmailError {
errorCodes: [UpdateEmailErrorCode!]!
}
enum UpdateEmailErrorCode {
BAD_REQUEST
EMAIL_ALREADY_EXISTS
UNAUTHORIZED
}
input UpdateEmailInput {
email: String!
}
union UpdateEmailResult = UpdateEmailError | UpdateEmailSuccess
type UpdateEmailSuccess {
email: String!
verificationEmailSent: Boolean
}
type UpdateFilterError {
errorCodes: [UpdateFilterErrorCode!]!
}
@ -2514,6 +2536,7 @@ enum UploadImportFileType {
}
type User {
email: String
followersCount: Int
friendsCount: Int
id: ID!
@ -2526,6 +2549,7 @@ type User {
sharedArticlesCount: Int
sharedHighlightsCount: Int
sharedNotesCount: Int
source: String
viewerIsFollowing: Boolean
}

View File

@ -41,7 +41,6 @@ import {
createReminderResolver,
deleteAccountResolver,
deleteFilterResolver,
updateFilterResolver,
deleteHighlightResolver,
deleteIntegrationResolver,
deleteLabelResolver,
@ -105,6 +104,7 @@ import {
subscriptionsResolver,
typeaheadSearchResolver,
unsubscribeResolver,
updateFilterResolver,
updateHighlightResolver,
updateLabelResolver,
updateLinkShareInfoResolver,
@ -124,6 +124,7 @@ import { createReactionResolver, deleteReactionResolver } from './reaction'
import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
import { recentSearchesResolver } from './recent_searches'
import { Claims, WithDataSourcesContext } from './types'
import { updateEmailResolver } from './user'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
@ -212,6 +213,7 @@ export const functionResolvers = {
setFavoriteArticle: setFavoriteArticleResolver,
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
updateEmail: updateEmailResolver,
},
Query: {
me: getMeUserResolver,
@ -678,4 +680,5 @@ export const functionResolvers = {
...resultResolveTypeResolver('ImportFromIntegration'),
...resultResolveTypeResolver('SetFavoriteArticle'),
...resultResolveTypeResolver('UpdateSubscription'),
...resultResolveTypeResolver('UpdateEmail'),
}

View File

@ -22,6 +22,7 @@ export interface Claims {
userRole?: string
scope?: string // scope is used for api key like page:search
exp?: number
email?: string
}
export type ClaimsToSet = {

View File

@ -1,7 +1,8 @@
import * as jwt from 'jsonwebtoken'
import { RegistrationType } from '../../datalayer/user/model'
import { deletePagesByParam } from '../../elastic/pages'
import { User as UserEntity } from '../../entity/user'
import { setClaims } from '../../entity/utils'
import { getRepository, setClaims } from '../../entity/utils'
import { env } from '../../env'
import {
DeleteAccountError,
@ -15,12 +16,16 @@ import {
MutationDeleteAccountArgs,
MutationGoogleLoginArgs,
MutationGoogleSignupArgs,
MutationUpdateEmailArgs,
MutationUpdateUserArgs,
MutationUpdateUserProfileArgs,
QueryUserArgs,
QueryValidateUsernameArgs,
ResolverFn,
SignupErrorCode,
UpdateEmailError,
UpdateEmailErrorCode,
UpdateEmailSuccess,
UpdateUserError,
UpdateUserErrorCode,
UpdateUserProfileError,
@ -35,6 +40,7 @@ import {
} from '../../generated/graphql'
import { AppDataSource } from '../../server'
import { createUser } from '../../services/create_user'
import { sendVerificationEmail } from '../../services/send_emails'
import { authorized, userDataToUser } from '../../utils/helpers'
import { validateUsername } from '../../utils/usernamePolicy'
import { WithDataSourcesContext } from '../types'
@ -337,3 +343,50 @@ export const deleteAccountResolver = authorized<
return { userID }
})
export const updateEmailResolver = authorized<
UpdateEmailSuccess,
UpdateEmailError,
MutationUpdateEmailArgs
>(async (_, { input: { email } }, { uid, log }) => {
try {
const user = await getRepository(UserEntity).findOneBy({
id: uid,
})
if (!user) {
return {
errorCodes: [UpdateEmailErrorCode.Unauthorized],
}
}
if (user.source === RegistrationType.Email) {
await AppDataSource.transaction(async (entityManager) => {
await setClaims(entityManager, user.id)
return entityManager.getRepository(UserEntity).update(user.id, {
email,
})
})
return { email }
}
const result = await sendVerificationEmail({
id: user.id,
name: user.name,
email,
})
if (!result) {
return {
errorCodes: [UpdateEmailErrorCode.BadRequest],
}
}
return { email, verificationEmailSent: true }
} catch (error) {
log.error('Error updating email', error)
return {
errorCodes: [UpdateEmailErrorCode.BadRequest],
}
}
})

View File

@ -32,6 +32,7 @@ import {
sendConfirmationEmail,
sendPasswordResetEmail,
} from '../../services/send_emails'
import { analytics } from '../../utils/analytics'
import {
comparePassword,
getClaimsByToken,
@ -51,7 +52,6 @@ import {
} from './google_auth'
import { createWebAuthToken } from './jwt_helpers'
import { createMobileAccountCreationResponse } from './mobile/account_creation'
import { analytics } from '../../utils/analytics'
export interface SignupRequest {
email: string
@ -670,7 +670,9 @@ export function authRouter() {
)
}
const user = await getRepository(User).findOneBy({ id: claims.uid })
const user = await getRepository(User).findOneBy({
id: claims.uid,
})
if (!user) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=USER_NOT_FOUND`
@ -683,13 +685,18 @@ export function authRouter() {
)
}
// check if email needs to be updated
const updateEmail = claims.email && claims.email !== user.email
const hashedPassword = await hashPassword(password)
const updated = await AppDataSource.transaction(
async (entityManager) => {
await setClaims(entityManager, user.id)
return entityManager
.getRepository(User)
.update({ id: user.id }, { password: hashedPassword })
return entityManager.getRepository(User).update(user.id, {
password: hashedPassword,
email: updateEmail ? claims.email : undefined,
source: updateEmail ? RegistrationType.Email : undefined,
})
}
)
if (!updated.affected) {

View File

@ -84,6 +84,8 @@ const schema = gql`
sharedNotesCount: Int
friendsCount: Int
followersCount: Int
email: String
source: String
}
type Profile {
@ -2567,6 +2569,27 @@ const schema = gql`
NOT_FOUND
}
union UpdateEmailResult = UpdateEmailSuccess | UpdateEmailError
type UpdateEmailSuccess {
email: String!
verificationEmailSent: Boolean
}
type UpdateEmailError {
errorCodes: [UpdateEmailErrorCode!]!
}
enum UpdateEmailErrorCode {
UNAUTHORIZED
BAD_REQUEST
EMAIL_ALREADY_EXISTS
}
input UpdateEmailInput {
email: String!
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2575,6 +2598,7 @@ const schema = gql`
deleteAccount(userID: ID!): DeleteAccountResult!
updateUser(input: UpdateUserInput!): UpdateUserResult!
updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult!
updateEmail(input: UpdateEmailInput!): UpdateEmailResult!
createArticle(input: CreateArticleInput!): CreateArticleResult!
createHighlight(input: CreateHighlightInput!): CreateHighlightResult!
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!

View File

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

View File

@ -89,6 +89,7 @@ interface BackendEnv {
reminderTemplateId: string
resetPasswordTemplateId: string
installationTemplateId: string
verificationTemplateId: string
}
readwise: {
apiUrl: string
@ -163,6 +164,7 @@ const nullableEnvVars = [
'POCKET_CONSUMER_KEY',
'THUMBNAIL_TASK_HANDLER_URL',
'RSS_FEED_TASK_HANDLER_URL',
'SENDGRID_VERIFICATION_TEMPLATE_ID',
] // Allow some vars to be null/empty
/* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */
@ -280,6 +282,7 @@ export function getEnv(): BackendEnv {
reminderTemplateId: parse('SENDGRID_REMINDER_TEMPLATE_ID'),
resetPasswordTemplateId: parse('SENDGRID_RESET_PASSWORD_TEMPLATE_ID'),
installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'),
verificationTemplateId: parse('SENDGRID_VERIFICATION_TEMPLATE_ID'),
}
const readwise = {

View File

@ -91,15 +91,21 @@ export const getClaimsByToken = async (
}
export const generateVerificationToken = (
userId: string,
expireInDays = 1
user: {
id: string
email?: string
},
expireInSeconds = 60 * 60 * 24 // 1 day
): string => {
const iat = Math.floor(Date.now() / 1000)
const exp = Math.floor(
new Date(Date.now() + 1000 * 60 * 60 * 24 * expireInDays).getTime() / 1000
new Date(Date.now() + expireInSeconds * 1000).getTime() / 1000
)
return jwt.sign({ uid: userId, iat, exp }, env.server.jwtSecret)
return jwt.sign(
{ uid: user.id, iat, exp, email: user.email },
env.server.jwtSecret
)
}
export const setAuthInCookie = async (

View File

@ -546,7 +546,7 @@ export const enqueueThumbnailTask = async (
}
const headers = {
Cookie: `auth=${generateVerificationToken(userId)}`,
Cookie: `auth=${generateVerificationToken({ id: userId })}`,
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
@ -593,7 +593,7 @@ export const enqueueRssFeedFetch = async (
}
const headers = {
[OmnivoreAuthorizationHeader]: generateVerificationToken(userId),
[OmnivoreAuthorizationHeader]: generateVerificationToken({ id: userId }),
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment

View File

@ -306,7 +306,7 @@ describe('auth router', () => {
context('when token is valid', () => {
before(() => {
token = generateVerificationToken(user.id)
token = generateVerificationToken({ id: user.id })
})
it('set auth token in cookie', async () => {
@ -340,7 +340,7 @@ describe('auth router', () => {
context('when token is expired', () => {
before(() => {
token = generateVerificationToken(user.id, -1)
token = generateVerificationToken({ id: user.id }, -1)
})
it('redirects to confirm-email page with error code TokenExpired', async () => {
@ -354,7 +354,7 @@ describe('auth router', () => {
context('when user is not found', () => {
before(() => {
const nonExistsUserId = generateFakeUuid()
token = generateVerificationToken(nonExistsUserId)
token = generateVerificationToken({ id: nonExistsUserId })
})
it('redirects to confirm-email page with error code UserNotFound', async () => {
@ -498,7 +498,7 @@ describe('auth router', () => {
context('when token is valid', () => {
before(async () => {
token = generateVerificationToken(user.id)
token = generateVerificationToken({ id: user.id })
})
context('when password is not empty', () => {
@ -543,7 +543,7 @@ describe('auth router', () => {
context('when token is expired', () => {
before(() => {
token = generateVerificationToken(user.id, -1)
token = generateVerificationToken({ id: user.id }, -1)
})
it('redirects to reset-password page with error code ExpiredToken', async () => {

View File

@ -0,0 +1,48 @@
import { gql } from 'graphql-request'
import { gqlFetcher } from '../networkHelpers'
export interface UpdateEmailInput {
email: string
}
export interface UpdateEmailSuccess {
email: string
verificationEmailSent: boolean
}
interface Response {
updateEmail: UpdateEmailSuccess
}
export async function updateEmailMutation(
input: UpdateEmailInput
): Promise<UpdateEmailSuccess | undefined> {
const mutation = gql`
mutation UpdateEmail($input: UpdateEmailInput!) {
updateEmail(input: $input) {
... on UpdateEmailSuccess {
email
verificationEmailSent
}
... on UpdateEmailError {
errorCodes
}
}
}
`
try {
const data = await gqlFetcher(mutation, {
input,
})
const output = data as Response
if ('errorCodes' in output.updateEmail) {
return undefined
}
return {
email: output.updateEmail.email,
verificationEmailSent: output.updateEmail.verificationEmailSent,
}
} catch (err) {
return undefined
}
}

View File

@ -17,6 +17,8 @@ export type UserBasicData = {
name: string
isFullUser?: boolean
profile: UserProfile
email: string
source: string
}
export type UserProfile = {
@ -39,6 +41,8 @@ export function useGetViewerQuery(): ViewerQueryResponse {
pictureUrl
bio
}
email
source
}
}
`

View File

@ -1,23 +1,23 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { StyledText } from '../../components/elements/StyledText'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { Toaster } from 'react-hot-toast'
import { Button } from '../../components/elements/Button'
import {
Box,
SpanBox,
VStack,
} from '../../components/elements/LayoutPrimitives'
import { Button } from '../../components/elements/Button'
import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery'
import { StyledText } from '../../components/elements/StyledText'
import { SettingsLayout } from '../../components/templates/SettingsLayout'
import { styled } from '../../components/tokens/stitches.config'
import { updateEmailMutation } from '../../lib/networking/mutations/updateEmailMutation'
import { updateUserMutation } from '../../lib/networking/mutations/updateUserMutation'
import { updateUserProfileMutation } from '../../lib/networking/mutations/updateUserProfileMutation'
import { styled, theme } from '../../components/tokens/stitches.config'
import { ProgressBar } from '../../components/elements/ProgressBar'
import { useGetLibraryItemsQuery } from '../../lib/networking/queries/useGetLibraryItemsQuery'
import { useGetViewerQuery } from '../../lib/networking/queries/useGetViewerQuery'
import { useValidateUsernameQuery } from '../../lib/networking/queries/useValidateUsernameQuery'
import { applyStoredTheme } from '../../lib/themeUpdater'
import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers'
import { ConfirmationModal } from '../../components/patterns/ConfirmationModal'
const StyledLabel = styled('label', {
fontWeight: 600,
@ -49,6 +49,11 @@ export default function Account(): JSX.Element {
const [username, setUsername] = useState('')
const [nameUpdating, setNameUpdating] = useState(false)
const [usernameUpdating, setUsernameUpdating] = useState(false)
const [email, setEmail] = useState('')
const [emailUpdating, setEmailUpdating] = useState(false)
const [source, setSource] = useState('')
const [showUpdateEmailConfirmation, setShowUpdateEmailConfirmation] =
useState(false)
const [debouncedUsername, setDebouncedUsername] = useState('')
const { usernameErrorMessage, isLoading: isUsernameValidationLoading } =
@ -96,6 +101,18 @@ export default function Account(): JSX.Element {
}
}, [viewerData?.me?.name])
useEffect(() => {
if (viewerData?.me?.email) {
setEmail(viewerData?.me?.email)
}
}, [viewerData?.me?.email])
useEffect(() => {
if (viewerData?.me?.source) {
setSource(viewerData?.me?.source)
}
}, [viewerData?.me?.source])
const handleUsernameChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
setUsername(event.target.value)
@ -154,6 +171,29 @@ export default function Account(): JSX.Element {
viewerData?.me,
])
const updateEmail = useCallback(() => {
setEmailUpdating(true)
setShowUpdateEmailConfirmation(false)
;(async () => {
const response = await updateEmailMutation({ email })
if (response) {
setEmail(response.email)
if (response.verificationEmailSent) {
showSuccessToast('Verification email sent')
} else {
showSuccessToast('Email updated')
}
} else {
// Reset if possible
if (viewerData?.me?.email) {
setEmail(viewerData?.me?.email)
}
showErrorToast('Error updating email')
}
setEmailUpdating(false)
})()
}, [email])
applyStoredTheme(false)
return (
@ -281,6 +321,55 @@ export default function Account(): JSX.Element {
</form>
</VStack>
<VStack
css={{
padding: '24px',
width: '100%',
height: '100%',
bg: '$grayBg',
gap: '5px',
borderRadius: '5px',
}}
>
<form
onSubmit={(event) => {
// Show a confirmation dialog if switching from social login
if (source == 'EMAIL') {
updateEmail()
} else {
setShowUpdateEmailConfirmation(true)
}
event.preventDefault()
}}
>
<StyledLabel>Email</StyledLabel>
<FormInput
type={'text'}
placeholder={'Email'}
value={email}
disabled={emailUpdating}
onChange={(event) => {
setEmail(event.target.value)
event.preventDefault()
}}
/>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
Your email is used for account recovery and notifications.
</StyledText>
{source == 'EMAIL' ? (
<Button style="ctaDarkYellow">Update Email</Button>
) : (
<VStack>
<StyledText style="footnote" css={{ mt: '10px', mb: '20px' }}>
{`You are currently logged in with a ${source} account. To
convert to an email login, please click the button below.`}
</StyledText>
<Button style="ctaDarkYellow">Convert to email login</Button>
</VStack>
)}
</form>
</VStack>
{/* <VStack
css={{
padding: '24px',
@ -309,6 +398,16 @@ export default function Account(): JSX.Element {
</VStack> */}
</VStack>
</VStack>
{showUpdateEmailConfirmation ? (
<ConfirmationModal
message={
'You are converting from social to email based login. This can not be undone.'
}
onAccept={updateEmail}
onOpenChange={() => setShowUpdateEmailConfirmation(false)}
/>
) : null}
</SettingsLayout>
)
}