diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 9f730a73b..a311b6b6d 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -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; +}; + +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; +}; + export type UpdateFilterError = { __typename?: 'UpdateFilterError'; errorCodes: Array; @@ -3211,6 +3240,7 @@ export enum UploadImportFileType { export type User = { __typename?: 'User'; + email?: Maybe; followersCount?: Maybe; friendsCount?: Maybe; id: Scalars['ID']; @@ -3224,6 +3254,7 @@ export type User = { sharedArticlesCount?: Maybe; sharedHighlightsCount?: Maybe; sharedNotesCount?: Maybe; + source?: Maybe; viewerIsFollowing?: Maybe; }; @@ -3813,6 +3844,11 @@ export type ResolversTypes = { UnsubscribeErrorCode: UnsubscribeErrorCode; UnsubscribeResult: ResolversTypes['UnsubscribeError'] | ResolversTypes['UnsubscribeSuccess']; UnsubscribeSuccess: ResolverTypeWrapper; + UpdateEmailError: ResolverTypeWrapper; + UpdateEmailErrorCode: UpdateEmailErrorCode; + UpdateEmailInput: UpdateEmailInput; + UpdateEmailResult: ResolversTypes['UpdateEmailError'] | ResolversTypes['UpdateEmailSuccess']; + UpdateEmailSuccess: ResolverTypeWrapper; UpdateFilterError: ResolverTypeWrapper; 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>; subscribe?: Resolver>; unsubscribe?: Resolver>; + updateEmail?: Resolver>; updateFilter?: Resolver>; updateHighlight?: Resolver>; updateHighlightReply?: Resolver>; @@ -5955,6 +5996,21 @@ export type UnsubscribeSuccessResolvers; }; +export type UpdateEmailErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type UpdateEmailResultResolvers = { + __resolveType: TypeResolveFn<'UpdateEmailError' | 'UpdateEmailSuccess', ParentType, ContextType>; +}; + +export type UpdateEmailSuccessResolvers = { + email?: Resolver; + verificationEmailSent?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type UpdateFilterErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -6157,6 +6213,7 @@ export type UploadImportFileSuccessResolvers = { + email?: Resolver, ParentType, ContextType>; followersCount?: Resolver, ParentType, ContextType>; friendsCount?: Resolver, ParentType, ContextType>; id?: Resolver; @@ -6169,6 +6226,7 @@ export type UserResolvers, ParentType, ContextType>; sharedHighlightsCount?: Resolver, ParentType, ContextType>; sharedNotesCount?: Resolver, ParentType, ContextType>; + source?: Resolver, ParentType, ContextType>; viewerIsFollowing?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; @@ -6528,6 +6586,9 @@ export type Resolvers = { UnsubscribeError?: UnsubscribeErrorResolvers; UnsubscribeResult?: UnsubscribeResultResolvers; UnsubscribeSuccess?: UnsubscribeSuccessResolvers; + UpdateEmailError?: UpdateEmailErrorResolvers; + UpdateEmailResult?: UpdateEmailResultResolvers; + UpdateEmailSuccess?: UpdateEmailSuccessResolvers; UpdateFilterError?: UpdateFilterErrorResolvers; UpdateFilterResult?: UpdateFilterResultResolvers; UpdateFilterSuccess?: UpdateFilterSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index a9347f774..1af5e9d49 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -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 } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index b10dae9cb..9c62aeccf 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -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'), } diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index 5e7aa2d00..2a26a3ff4 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -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 = { diff --git a/packages/api/src/resolvers/user/index.ts b/packages/api/src/resolvers/user/index.ts index f010a3dc8..62b24a794 100644 --- a/packages/api/src/resolvers/user/index.ts +++ b/packages/api/src/resolvers/user/index.ts @@ -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], + } + } +}) diff --git a/packages/api/src/routers/auth/auth_router.ts b/packages/api/src/routers/auth/auth_router.ts index 381d974f0..b0f6d821e 100644 --- a/packages/api/src/routers/auth/auth_router.ts +++ b/packages/api/src/routers/auth/auth_router.ts @@ -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) { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 305a75528..d0b161eb9 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -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! diff --git a/packages/api/src/services/send_emails.ts b/packages/api/src/services/send_emails.ts index 2ebefd3d1..279b193f9 100644 --- a/packages/api/src/services/send_emails.ts +++ b/packages/api/src/services/send_emails.ts @@ -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 => { // 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 => { + // 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 => { // 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 = { diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index 039785d9e..8f0601356 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -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 = { diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index dbb94c46d..951bf6300 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -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 ( diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 273eaee94..e473c17fe 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -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 diff --git a/packages/api/test/routers/auth.test.ts b/packages/api/test/routers/auth.test.ts index 79dfc2dcf..48dcd8df7 100644 --- a/packages/api/test/routers/auth.test.ts +++ b/packages/api/test/routers/auth.test.ts @@ -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 () => { diff --git a/packages/web/lib/networking/mutations/updateEmailMutation.ts b/packages/web/lib/networking/mutations/updateEmailMutation.ts new file mode 100644 index 000000000..aab9585d2 --- /dev/null +++ b/packages/web/lib/networking/mutations/updateEmailMutation.ts @@ -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 { + 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 + } +} diff --git a/packages/web/lib/networking/queries/useGetViewerQuery.tsx b/packages/web/lib/networking/queries/useGetViewerQuery.tsx index bddd23975..0cf7f9c2d 100644 --- a/packages/web/lib/networking/queries/useGetViewerQuery.tsx +++ b/packages/web/lib/networking/queries/useGetViewerQuery.tsx @@ -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 } } ` diff --git a/packages/web/pages/settings/account.tsx b/packages/web/pages/settings/account.tsx index 23b466758..88f67e075 100644 --- a/packages/web/pages/settings/account.tsx +++ b/packages/web/pages/settings/account.tsx @@ -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): 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 { + +
{ + // Show a confirmation dialog if switching from social login + if (source == 'EMAIL') { + updateEmail() + } else { + setShowUpdateEmailConfirmation(true) + } + event.preventDefault() + }} + > + Email + { + setEmail(event.target.value) + event.preventDefault() + }} + /> + + Your email is used for account recovery and notifications. + + {source == 'EMAIL' ? ( + + ) : ( + + + {`You are currently logged in with a ${source} account. To + convert to an email login, please click the button below.`} + + + + )} + +
+ {/* */} + + {showUpdateEmailConfirmation ? ( + setShowUpdateEmailConfirmation(false)} + /> + ) : null} ) }