diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 8f4e6cd71e3..585128dd8ef 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -550,6 +550,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS videoEditionCreate(input: VideoEditionCreateInput!) : VideoEdition! @join__field(graph: API_MEDIA) videoEditionUpdate(input: VideoEditionUpdateInput!) : VideoEdition! @join__field(graph: API_MEDIA) videoEditionDelete(id: ID!) : VideoEdition! @join__field(graph: API_MEDIA) + mergeGuest(input: MergeGuestInput!) : User! @join__field(graph: API_USERS) userImpersonate(email: String!) : String @join__field(graph: API_USERS) createVerificationRequest(input: CreateVerificationRequestInput) : Boolean @join__field(graph: API_USERS) validateEmail(email: String!, token: String!) : User @join__field(graph: API_USERS) @@ -2185,9 +2186,10 @@ type User @join__type(graph: API_JOURNEYS, key: "id", extension: true) @join__t id: ID! languageUserRoles: [LanguageRole!]! @join__field(graph: API_LANGUAGES) mediaUserRoles: [MediaRole!]! @join__field(graph: API_MEDIA) + userId: String! @join__field(graph: API_USERS) firstName: String! @join__field(graph: API_USERS) lastName: String @join__field(graph: API_USERS) - email: String! @join__field(graph: API_USERS) + email: String @join__field(graph: API_USERS) imageUrl: String @join__field(graph: API_USERS) superAdmin: Boolean @join__field(graph: API_USERS) emailVerified: Boolean! @join__field(graph: API_USERS) @@ -5454,5 +5456,12 @@ input CreateVerificationRequestInput @join__type(graph: API_USERS) { input MeInput @join__type(graph: API_USERS) { redirect: String + createGuestIfAnonymous: Boolean +} + +input MergeGuestInput @join__type(graph: API_USERS) { + firstName: String! + lastName: String! + email: String! } \ No newline at end of file diff --git a/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.spec.ts b/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.spec.ts index 90079280a26..a9c054733a2 100644 --- a/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.spec.ts +++ b/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.spec.ts @@ -92,5 +92,16 @@ describe('MailChimpService', () => { 'Mailchimp Audience ID is undefined' ) }) + + it('should no-op when user has no email (e.g. anonymous/guest)', async () => { + const userWithoutEmail: User = { + ...user, + email: null + } + await expect( + mailChimpService.syncUser(userWithoutEmail) + ).resolves.toBeUndefined() + expect(mailchimp.lists.setListMember).not.toHaveBeenCalled() + }) }) }) diff --git a/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.ts b/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.ts index 047037ef187..18ab143065a 100644 --- a/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.ts +++ b/apis/api-journeys/src/app/modules/mailChimp/mailChimp.service.ts @@ -8,14 +8,13 @@ import { User } from '../../lib/firebaseClient' export class MailChimpService { async syncUser(user: User): Promise { try { + if (user.email == null) return mailchimp.setConfig({ apiKey: process.env.MAILCHIMP_MARKETING_API_KEY, server: process.env.MAILCHIMP_MARKETING_API_SERVER_PREFIX }) if (process.env.MAILCHIMP_AUDIENCE_ID == null) throw new Error('Mailchimp Audience ID is undefined') - if (user.email == null) - throw new Error('User must have an email to receive marketing emails') // upsert operation await mailchimp.lists.setListMember( process.env.MAILCHIMP_AUDIENCE_ID, diff --git a/apis/api-users/schema.graphql b/apis/api-users/schema.graphql index e8efcd93300..42358945e37 100644 --- a/apis/api-users/schema.graphql +++ b/apis/api-users/schema.graphql @@ -7,9 +7,17 @@ input CreateVerificationRequestInput { input MeInput { redirect: String + createGuestIfAnonymous: Boolean +} + +input MergeGuestInput { + firstName: String! + lastName: String! + email: String! } type Mutation { + mergeGuest(input: MergeGuestInput!): User! userImpersonate(email: String!): String createVerificationRequest(input: CreateVerificationRequestInput): Boolean validateEmail(email: String!, token: String!): User @@ -25,9 +33,10 @@ type User @key(fields: "id") { id: ID! + userId: String! firstName: String! lastName: String - email: String! + email: String imageUrl: String superAdmin: Boolean emailVerified: Boolean! diff --git a/apis/api-users/src/schema/user/findOrFetchUser.spec.ts b/apis/api-users/src/schema/user/findOrFetchUser.spec.ts index 578ac30459e..07af84fc05f 100644 --- a/apis/api-users/src/schema/user/findOrFetchUser.spec.ts +++ b/apis/api-users/src/schema/user/findOrFetchUser.spec.ts @@ -6,14 +6,15 @@ import { verifyUser } from './verifyUser' jest.mock('@core/yoga/firebaseClient', () => ({ auth: { - getUser: jest.fn().mockReturnValue({ + getUser: jest.fn().mockResolvedValue({ id: '1', userId: '1', createdAt: new Date('2021-01-01T00:00:00.000Z'), displayName: 'Amin One', email: 'amin@email.com', photoURL: 'https://bit.ly/3Gth4', - emailVerified: false + emailVerified: false, + providerData: [{ providerId: 'google.com' }] }) } })) @@ -41,7 +42,7 @@ describe('findOrFetchUser', () => { const data = await findOrFetchUser({}, 'userId', undefined) expect(data).toEqual(user) expect(prismaMock.user.update).toHaveBeenCalledWith({ - where: { id: 'userId' }, + where: { userId: 'userId' }, data: { emailVerified: false } }) }) @@ -67,4 +68,71 @@ describe('findOrFetchUser', () => { undefined ) }) + + it('should return null and not create database user for anonymous Firebase user', async () => { + const { auth } = await import('@core/yoga/firebaseClient') + ;(auth.getUser as jest.Mock).mockResolvedValueOnce({ + id: 'anon', + userId: 'anonymousUserId', + displayName: null, + email: null, + photoURL: null, + emailVerified: false, + providerData: [] + }) + prismaMock.user.findUnique.mockResolvedValueOnce(null) + const data = await findOrFetchUser({}, 'anonymousUserId', undefined) + expect(data).toBeNull() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it('should return existing user when create fails (e.g. concurrent create)', async () => { + prismaMock.user.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(user) + prismaMock.user.create.mockRejectedValueOnce(new Error('Unique constraint')) + const data = await findOrFetchUser({}, 'userId', undefined) + expect(data).toEqual(user) + expect(prismaMock.user.create).toHaveBeenCalledTimes(1) + expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(2) + }) + + it('should create database user for anonymous Firebase user when forceCreateForAnonymous is true', async () => { + const { auth } = await import('@core/yoga/firebaseClient') + ;(auth.getUser as jest.Mock).mockResolvedValueOnce({ + id: 'anon', + userId: 'anonymousUserId', + displayName: null, + email: null, + photoURL: null, + emailVerified: false, + providerData: [] + }) + const createdUser = { + ...user, + userId: 'anonymousUserId', + firstName: 'Unknown User', + lastName: '', + email: null, + emailVerified: false + } + prismaMock.user.findUnique.mockResolvedValueOnce(null) + prismaMock.user.create.mockResolvedValueOnce( + createdUser as unknown as typeof user + ) + const data = await findOrFetchUser({}, 'anonymousUserId', undefined, { + forceCreateForAnonymous: true + }) + expect(data).toEqual(createdUser) + expect(prismaMock.user.create).toHaveBeenCalledWith({ + data: { + userId: 'anonymousUserId', + firstName: 'Unknown User', + lastName: '', + email: null, + imageUrl: null, + emailVerified: false + } + }) + }) }) diff --git a/apis/api-users/src/schema/user/findOrFetchUser.ts b/apis/api-users/src/schema/user/findOrFetchUser.ts index 4547c686b55..f900cd24d2f 100644 --- a/apis/api-users/src/schema/user/findOrFetchUser.ts +++ b/apis/api-users/src/schema/user/findOrFetchUser.ts @@ -3,11 +3,19 @@ import { auth } from '@core/yoga/firebaseClient' import { verifyUser } from './verifyUser' +export type FindOrFetchUserOptions = { + /** When true, create a database user even for anonymous Firebase users (e.g. "Create Guest"). */ + forceCreateForAnonymous?: boolean +} + export async function findOrFetchUser( query: { select?: Prisma.UserSelect; include?: undefined }, userId: string, - redirect: string | undefined = undefined + redirect: string | undefined = undefined, + options: FindOrFetchUserOptions = {} ): Promise { + const { forceCreateForAnonymous = false } = options + const existingUser = await prisma.user.findUnique({ ...query, where: { @@ -17,7 +25,7 @@ export async function findOrFetchUser( if (existingUser != null && existingUser.emailVerified == null) { const user = await prisma.user.update({ where: { - id: userId + userId }, data: { emailVerified: false @@ -29,12 +37,17 @@ export async function findOrFetchUser( if (existingUser != null && existingUser.emailVerified != null) return existingUser - const { - displayName, - email, - emailVerified, - photoURL: imageUrl - } = await auth.getUser(userId) + const firebaseUser = await auth.getUser(userId) + + const isAnonymous = + firebaseUser.providerData == null || firebaseUser.providerData.length === 0 + + // Do not create a database user for anonymous Firebase users unless explicitly requested. + if (isAnonymous && !forceCreateForAnonymous) { + return null + } + + const { displayName, email, emailVerified, photoURL: imageUrl } = firebaseUser // Extract firstName and lastName from displayName with better fallbacks let firstName = '' @@ -60,36 +73,31 @@ export async function findOrFetchUser( firstName = 'Unknown User' } - const data = { + // Schema has email String?; use assertion so nullable email compiles with any Prisma client version + const createData = { userId, firstName, lastName, - email: email ?? '', - imageUrl, + email: email ?? null, + imageUrl: imageUrl ?? null, emailVerified - } + } as Prisma.UserUncheckedCreateInput let user: User | null = null - let retry = 0 let userCreated = false - // this function can run in parallel as such it is possible for multiple - // calls to reach this point and try to create the same user - // due to the earlier firebase async call. + // This can run in parallel; multiple calls may try to create the same user. try { user = await prisma.user.create({ - data + data: createData }) userCreated = true - } catch (e) { - do { - user = await prisma.user.update({ - where: { - id: userId - }, - data - }) - retry++ - } while (user == null && retry < 3) + } catch (createErr) { + // Create failed - often unique constraint (concurrent request already created). Fetch existing. + user = await prisma.user.findUnique({ + ...query, + where: { userId } + }) + if (user == null) throw createErr } // after user create so it is only sent once if (email != null && userCreated && !emailVerified) diff --git a/apis/api-users/src/schema/user/inputs/index.ts b/apis/api-users/src/schema/user/inputs/index.ts index 01a0068bf1b..cb9740480d4 100644 --- a/apis/api-users/src/schema/user/inputs/index.ts +++ b/apis/api-users/src/schema/user/inputs/index.ts @@ -1,5 +1,7 @@ import './createVerificationRequestInput' import './meInput' +import './mergeGuestInput' export { CreateVerificationRequestInput } from './createVerificationRequestInput' export { MeInput } from './meInput' +export { MergeGuestInput } from './mergeGuestInput' diff --git a/apis/api-users/src/schema/user/inputs/meInput.ts b/apis/api-users/src/schema/user/inputs/meInput.ts index 1974a9f710f..3b015784a08 100644 --- a/apis/api-users/src/schema/user/inputs/meInput.ts +++ b/apis/api-users/src/schema/user/inputs/meInput.ts @@ -2,6 +2,7 @@ import { builder } from '../../builder' export const MeInput = builder.inputType('MeInput', { fields: (t) => ({ - redirect: t.string({ required: false }) + redirect: t.string({ required: false }), + createGuestIfAnonymous: t.boolean({ required: false }) }) }) diff --git a/apis/api-users/src/schema/user/objects/user.ts b/apis/api-users/src/schema/user/objects/user.ts index adc67c1c1ed..a3b5fc78afc 100644 --- a/apis/api-users/src/schema/user/objects/user.ts +++ b/apis/api-users/src/schema/user/objects/user.ts @@ -3,6 +3,7 @@ import { builder } from '../../builder' export const User = builder.prismaObject('User', { fields: (t) => ({ id: t.exposeID('id', { nullable: false }), + userId: t.exposeString('userId', { nullable: false }), firstName: t.field({ type: 'String', nullable: false, @@ -18,7 +19,7 @@ export const User = builder.prismaObject('User', { } }), lastName: t.exposeString('lastName'), - email: t.exposeString('email', { nullable: false }), + email: t.exposeString('email'), imageUrl: t.exposeString('imageUrl'), superAdmin: t.exposeBoolean('superAdmin'), emailVerified: t.exposeBoolean('emailVerified', { nullable: false }) diff --git a/apis/api-users/src/schema/user/user.spec.ts b/apis/api-users/src/schema/user/user.spec.ts index 681b82aeca0..39f07f690d6 100644 --- a/apis/api-users/src/schema/user/user.spec.ts +++ b/apis/api-users/src/schema/user/user.spec.ts @@ -83,7 +83,12 @@ describe('api-users', () => { const data = await authClient({ document: ME_QUERY }) - expect(findOrFetchUser).toHaveBeenCalledWith({}, 'testUserId', undefined) + expect(findOrFetchUser).toHaveBeenCalledWith( + {}, + 'testUserId', + undefined, + { forceCreateForAnonymous: false } + ) expect(data).toHaveProperty( 'data.me', omit(user, ['createdAt', 'userId']) diff --git a/apis/api-users/src/schema/user/user.ts b/apis/api-users/src/schema/user/user.ts index 3b04e525fc0..7811eb2a3aa 100644 --- a/apis/api-users/src/schema/user/user.ts +++ b/apis/api-users/src/schema/user/user.ts @@ -6,7 +6,11 @@ import { impersonateUser } from '@core/yoga/firebaseClient' import { builder } from '../builder' import { findOrFetchUser } from './findOrFetchUser' -import { CreateVerificationRequestInput, MeInput } from './inputs' +import { + CreateVerificationRequestInput, + MeInput, + MergeGuestInput +} from './inputs' import { User } from './objects' import { validateEmail } from './validateEmail' import { verifyUser } from './verifyUser' @@ -57,11 +61,19 @@ builder.queryFields((t) => ({ }) }, resolve: async (query, _parent, { input }, ctx) => { - return await findOrFetchUser( - query, - ctx.currentUser.id, - input?.redirect ?? undefined - ) + try { + return await findOrFetchUser( + query, + ctx.currentUser.id, + input?.redirect ?? undefined, + { forceCreateForAnonymous: input?.createGuestIfAnonymous ?? false } + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new GraphQLError(message, { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + }) + } } }), user: t.withAuth({ isValidInterop: true }).prismaField({ @@ -92,6 +104,27 @@ builder.queryFields((t) => ({ })) builder.mutationFields((t) => ({ + mergeGuest: t.withAuth({ isAuthenticated: true }).prismaField({ + type: 'User', + nullable: false, + args: { + input: t.arg({ type: MergeGuestInput, required: true }) + }, + resolve: async (query, _parent, { input }, ctx) => { + const userId = ctx.currentUser.id + const updated = await prisma.user.update({ + ...query, + where: { userId }, + data: { + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + emailVerified: true + } + }) + return updated + } + }), userImpersonate: t.withAuth({ isSuperAdmin: true }).field({ type: 'String', args: { @@ -151,6 +184,10 @@ builder.mutationFields((t) => ({ throw new GraphQLError('User not found', { extensions: { code: 'NOT_FOUND' } }) + if (user.email == null) + throw new GraphQLError('User not found', { + extensions: { code: 'NOT_FOUND' } + }) const validatedEmail = await validateEmail(user.userId, user.email, token) if (!validatedEmail) diff --git a/apps/journeys-admin/__generated__/GetCurrentUser.ts b/apps/journeys-admin/__generated__/GetCurrentUser.ts index a5dc3ae8fb3..ca89840ceed 100644 --- a/apps/journeys-admin/__generated__/GetCurrentUser.ts +++ b/apps/journeys-admin/__generated__/GetCurrentUser.ts @@ -10,7 +10,11 @@ export interface GetCurrentUser_me { __typename: "User"; id: string; - email: string; + userId: string; + email: string | null; + firstName: string; + lastName: string | null; + emailVerified: boolean; } export interface GetCurrentUser { diff --git a/apps/journeys-admin/__generated__/GetMe.ts b/apps/journeys-admin/__generated__/GetMe.ts index e13fe28cfec..86395c650f2 100644 --- a/apps/journeys-admin/__generated__/GetMe.ts +++ b/apps/journeys-admin/__generated__/GetMe.ts @@ -14,7 +14,7 @@ export interface GetMe_me { id: string; firstName: string; lastName: string | null; - email: string; + email: string | null; imageUrl: string | null; superAdmin: boolean | null; emailVerified: boolean; diff --git a/apps/journeys-admin/pages/api/login.tsx b/apps/journeys-admin/pages/api/login.tsx index 46c5c3e943a..fe9e0dfb06d 100644 --- a/apps/journeys-admin/pages/api/login.tsx +++ b/apps/journeys-admin/pages/api/login.tsx @@ -13,6 +13,11 @@ export default async function handler( await setAuthCookies(req, res, {}) res.status(200).json({ success: true }) } catch (e) { - res.status(500).json({ error: 'Unexpected error.' }) + console.error('Login API error:', e) + const message = + process.env.NODE_ENV !== 'production' && e instanceof Error + ? e.message + : 'Unexpected error.' + res.status(500).json({ error: message }) } } diff --git a/apps/journeys-admin/pages/templates/index.tsx b/apps/journeys-admin/pages/templates/index.tsx index 00ae2733d9b..543769d2374 100644 --- a/apps/journeys-admin/pages/templates/index.tsx +++ b/apps/journeys-admin/pages/templates/index.tsx @@ -27,6 +27,7 @@ import { HelpScoutBeacon } from '../../src/components/HelpScoutBeacon' import { PageWrapper } from '../../src/components/PageWrapper' import { GET_ME } from '../../src/components/PageWrapper/NavigationDrawer/UserNavigation' import { initAndAuthApp } from '../../src/libs/initAndAuthApp' +import { GuestUserTest } from '../../src/components/GuestUserTest/GuestUserTest' function TemplateIndexPage(): ReactElement { const { t } = useTranslation('apps-journeys-admin') @@ -43,17 +44,19 @@ function TemplateIndexPage(): ReactElement { }, [user.id, query]) const userSignedIn = user?.id != null + const isAnonymous = user.firebaseUser?.isAnonymous ?? false + const signedInNonAnonymous = userSignedIn && !isAnonymous return ( <> @@ -78,6 +81,7 @@ function TemplateIndexPage(): ReactElement { px: { xs: 6, sm: 8, md: 10 } }} > + diff --git a/apps/journeys-admin/src/components/GuestUserTest/GuestUserTest.tsx b/apps/journeys-admin/src/components/GuestUserTest/GuestUserTest.tsx new file mode 100644 index 00000000000..5e6814907de --- /dev/null +++ b/apps/journeys-admin/src/components/GuestUserTest/GuestUserTest.tsx @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState } from 'react' + +import { gql, useMutation } from '@apollo/client' +import Button from '@mui/material/Button' +import Card from '@mui/material/Card' +import CardContent from '@mui/material/CardContent' +import CardHeader from '@mui/material/CardHeader' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { getApp } from 'firebase/app' +import { + EmailAuthProvider, + getAuth, + linkWithCredential, + signInAnonymously, + updateProfile +} from 'firebase/auth' +import { useUser } from 'next-firebase-auth' + +import { JourneyProfileCreate } from '../../../__generated__/JourneyProfileCreate' +import { useCurrentUserLazyQuery } from '../../libs/useCurrentUserLazyQuery' + +const JOURNEY_PROFILE_CREATE = gql` + mutation JourneyProfileCreate { + journeyProfileCreate { + id + userId + acceptedTermsAt + } + } +` + +const MERGE_GUEST = gql` + mutation MergeGuest($input: MergeGuestInput!) { + mergeGuest(input: $input) { + id + userId + email + firstName + lastName + emailVerified + } + } +` +const count = 2 + +const MERGE_GUEST_DISPLAY_NAME = 'John Doe' +const MERGE_GUEST_EMAIL = `testemail${count}@example.com` +const MERGE_GUEST_PASSWORD = 'MergeGuestTest1' + +export function GuestUserTest() { + const user = useUser() + const { loadUser, data: dbUser } = useCurrentUserLazyQuery() + const hasAttemptedAnonymousSignIn = useRef(false) + const [journeyProfileCreate] = useMutation( + JOURNEY_PROFILE_CREATE + ) + const [mergeGuest] = useMutation(MERGE_GUEST) + const [isCreatingGuest, setIsCreatingGuest] = useState(false) + const [createGuestError, setCreateGuestError] = useState(null) + const [isMergingGuest, setIsMergingGuest] = useState(false) + const [mergeGuestError, setMergeGuestError] = useState(null) + + // Current user: useUser() gives id, email, displayName, etc. When not signed in, user.id is null. + const currentUserId = user.id ?? null + const isSignedIn = currentUserId != null + + // Anonymous check: firebaseUser is the Firebase JS SDK user (only set after client init). + // Firebase User has isAnonymous: https://firebase.google.com/docs/reference/js/auth.user#userisanonymous + const isAnonymous = user.firebaseUser?.isAnonymous ?? false + + // When signed out and Firebase client is ready, sign in anonymously so we can show the anonymous ID. + useEffect(() => { + if ( + !isSignedIn && + user.clientInitialized && + !hasAttemptedAnonymousSignIn.current + ) { + hasAttemptedAnonymousSignIn.current = true + signInAnonymously(getAuth(getApp())).catch(() => { + hasAttemptedAnonymousSignIn.current = false + }) + } + }, [isSignedIn, user.clientInitialized]) + + useEffect(() => { + if (isSignedIn) { + loadUser() + } + }, [isSignedIn, loadUser]) + + function getErrorMessage(err: unknown, prefix: string): string { + if (err == null) return `${prefix}: Unknown error` + const apolloErr = err as { + message?: string + graphQLErrors?: Array<{ message?: string }> + networkError?: { message?: string; result?: { error?: string } } + } + const gqlMessage = apolloErr.graphQLErrors?.[0]?.message + const networkMessage = + apolloErr.networkError?.result?.error ?? apolloErr.networkError?.message + const message = + gqlMessage ?? networkMessage ?? apolloErr.message ?? String(err) + return `${prefix}: ${message}` + } + + // Create guest: use me query with createGuestIfAnonymous (creates DB user via findOrFetchUser), then journey profile + const handleCreateGuest = async (): Promise => { + setCreateGuestError(null) + setIsCreatingGuest(true) + try { + try { + await loadUser({ + variables: { input: { createGuestIfAnonymous: true } } + }) + } catch (err) { + setCreateGuestError(getErrorMessage(err, 'Create guest user')) + return + } + try { + await journeyProfileCreate() + } catch (err) { + setCreateGuestError(getErrorMessage(err, 'Create journey profile')) + return + } + try { + await loadUser() + } catch (err) { + setCreateGuestError(getErrorMessage(err, 'Load user')) + } + } finally { + setIsCreatingGuest(false) + } + } + + const handleMergeGuest = async (): Promise => { + setMergeGuestError(null) + setIsMergingGuest(true) + const auth = getAuth(getApp()) + const firebaseUser = auth.currentUser + if (firebaseUser == null) { + setMergeGuestError('Merge guest: Not signed in') + setIsMergingGuest(false) + return + } + try { + await updateProfile(firebaseUser, { + displayName: MERGE_GUEST_DISPLAY_NAME + }) + } catch (err) { + setMergeGuestError(getErrorMessage(err, 'Merge guest (Firebase profile)')) + setIsMergingGuest(false) + return + } + try { + const credential = EmailAuthProvider.credential( + MERGE_GUEST_EMAIL, + MERGE_GUEST_PASSWORD + ) + await linkWithCredential(firebaseUser, credential) + } catch (err) { + setMergeGuestError( + getErrorMessage(err, 'Merge guest (Firebase email link)') + ) + setIsMergingGuest(false) + return + } + try { + await mergeGuest({ + variables: { + input: { + firstName: 'John', + lastName: 'Doe', + email: MERGE_GUEST_EMAIL + } + } + }) + } catch (err) { + setMergeGuestError(getErrorMessage(err, 'Merge guest (DB)')) + setIsMergingGuest(false) + return + } + try { + await loadUser() + } catch (err) { + setMergeGuestError(getErrorMessage(err, 'Merge guest (reload)')) + } finally { + setIsMergingGuest(false) + } + } + + const displayName = + dbUser != null && (dbUser.firstName || dbUser.lastName) + ? [dbUser.firstName, dbUser.lastName].filter(Boolean).join(' ') + : null + + return ( + + Guest User Test + + + + + + {currentUserId != null && ( + + {isAnonymous ? 'Anonymous ID' : 'UID'}: {currentUserId} + + )} + {user.email != null && user.email !== '' && ( + + Email: {user.email} + + )} + + Signed in: {isSignedIn ? 'Yes' : 'No'} + + + Anonymous: {isAnonymous ? 'Yes' : 'No'} + + + + + + + + + {dbUser != null && dbUser.userId !== '' ? ( + <> + + User ID: {dbUser.userId} + + {dbUser.email != null && dbUser.email !== '' && ( + + Email: {dbUser.email} + + )} + {displayName != null && ( + + Name: {displayName} + + )} + + Email verified: {dbUser.emailVerified ? 'Yes' : 'No'} + + + ) : ( + + {!isSignedIn + ? 'Sign in to see database user' + : isAnonymous + ? 'No database user (anonymous only)' + : 'Loading…'} + + )} + + + + + + + + + + {createGuestError != null && ( + + {createGuestError} + + )} + {mergeGuestError != null && ( + + {mergeGuestError} + + )} + + ) +} diff --git a/apps/journeys-admin/src/components/SignIn/RegisterPage/RegisterPage.tsx b/apps/journeys-admin/src/components/SignIn/RegisterPage/RegisterPage.tsx index e51ac5d10e5..ef203f14751 100644 --- a/apps/journeys-admin/src/components/SignIn/RegisterPage/RegisterPage.tsx +++ b/apps/journeys-admin/src/components/SignIn/RegisterPage/RegisterPage.tsx @@ -6,28 +6,83 @@ import InputAdornment from '@mui/material/InputAdornment' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' +import { gql, useMutation } from '@apollo/client' +import { getApp } from 'firebase/app' import { createUserWithEmailAndPassword, + EmailAuthProvider, getAuth, + linkWithCredential, signInWithEmailAndPassword, updateProfile } from 'firebase/auth' import { Form, Formik } from 'formik' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -import React, { ReactElement } from 'react' +import { useUser } from 'next-firebase-auth' +import React, { ReactElement, useState } from 'react' import { InferType, object, string } from 'yup' +import { useCurrentUserLazyQuery } from '../../../libs/useCurrentUserLazyQuery' import { useHandleNewAccountRedirect } from '../../../libs/useRedirectNewAccount' import { PageProps } from '../types' +const MERGE_GUEST = gql` + mutation MergeGuest($input: MergeGuestInput!) { + mergeGuest(input: $input) { + id + userId + email + firstName + lastName + emailVerified + } + } +` + +function parseNameToFirstAndLast(name: string): { + firstName: string + lastName: string +} { + const trimmed = name.trim() + if (trimmed === '') return { firstName: '', lastName: '' } + const parts = trimmed.split(/\s+/).filter(Boolean) + if (parts.length === 0) return { firstName: '', lastName: '' } + if (parts.length === 1) return { firstName: parts[0], lastName: '' } + return { + firstName: parts[0], + lastName: parts.slice(1).join(' ') + } +} + +function getErrorMessage(err: unknown, prefix: string): string { + if (err == null) return `${prefix}: Unknown error` + const apolloErr = err as { + message?: string + graphQLErrors?: Array<{ message?: string }> + networkError?: { message?: string; result?: { error?: string } } + } + const gqlMessage = apolloErr.graphQLErrors?.[0]?.message + const networkMessage = + apolloErr.networkError?.result?.error ?? apolloErr.networkError?.message + const message = + gqlMessage ?? networkMessage ?? apolloErr.message ?? String(err) + return `${prefix}: ${message}` +} + export function RegisterPage({ setActivePage, userEmail }: PageProps): ReactElement { const { t } = useTranslation('apps-journeys-admin') const [showPassword, setShowPassword] = React.useState(false) + const [submitError, setSubmitError] = useState(null) const router = useRouter() + const user = useUser() + const [mergeGuest] = useMutation(MERGE_GUEST) + const { loadUser } = useCurrentUserLazyQuery() + + const isAnonymous = user.firebaseUser?.isAnonymous ?? false useHandleNewAccountRedirect() @@ -72,21 +127,95 @@ export function RegisterPage({ await signInWithEmailAndPassword(auth, email, password) } + async function mergeGuestAndSignIn( + email: string, + name: string, + password: string, + setFieldError: (field: string, message: string) => void, + setSubmitting: (isSubmitting: boolean) => void + ): Promise { + const auth = getAuth(getApp()) + const firebaseUser = auth.currentUser + if (firebaseUser == null) { + setSubmitError(t('Not signed in. Please try again.')) + setSubmitting(false) + return + } + try { + await updateProfile(firebaseUser, { displayName: name }) + } catch (err) { + setSubmitError(getErrorMessage(err, t('Update profile'))) + setSubmitting(false) + return + } + try { + const credential = EmailAuthProvider.credential(email, password) + await linkWithCredential(firebaseUser, credential) + } catch (err) { + const errCode = (err as { code?: string })?.code + if (errCode === 'auth/email-already-in-use') { + setFieldError( + 'email', + t('The email address is already used by another account') + ) + } else { + setSubmitError(getErrorMessage(err, t('Link account'))) + } + setSubmitting(false) + return + } + const { firstName, lastName } = parseNameToFirstAndLast(name) + try { + await mergeGuest({ + variables: { + input: { firstName, lastName, email } + } + }) + } catch (err) { + setSubmitError(getErrorMessage(err, t('Save account'))) + setSubmitting(false) + return + } + try { + await loadUser() + } catch (err) { + setSubmitError(getErrorMessage(err, t('Reload user'))) + } finally { + setSubmitting(false) + } + } + async function handleCreateAccount( values: InferType, - { setFieldError } + { setFieldError, setSubmitting } ): Promise { + setSubmitError(null) try { - await createAccountAndSignIn(values.email, values.name, values.password) + if (isAnonymous) { + await mergeGuestAndSignIn( + values.email, + values.name, + values.password, + setFieldError, + setSubmitting + ) + } else { + await createAccountAndSignIn(values.email, values.name, values.password) + } } catch (error) { - if (error.code === 'auth/email-already-in-use') { + const errCode = (error as { code?: string })?.code + if (errCode === 'auth/email-already-in-use') { setFieldError( 'email', t('The email address is already used by another account') ) } else { + setSubmitError( + error instanceof Error ? error.message : t('Something went wrong') + ) console.error(error) } + setSubmitting(false) } } return ( @@ -110,6 +239,11 @@ export function RegisterPage({ {t('Create account')} + {submitError != null && ( + + {submitError} + + )} { result: { data: { me: { + __typename: 'User', id: 'user.id', - email: 'test@email.com' + userId: 'firebase-uid-123', + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true } } } @@ -38,8 +43,13 @@ describe('useCurrentUserLazyQuery', () => { await waitFor(() => expect(result.current.data).toEqual({ + __typename: 'User', id: 'user.id', - email: 'test@email.com' + userId: 'firebase-uid-123', + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true }) ) }) @@ -56,8 +66,13 @@ describe('useCurrentUserLazyQuery', () => { result: { data: { me: { + __typename: 'User', id: 'user.id', - email: 'test@email.com' + userId: 'firebase-uid-123', + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true } } } @@ -72,7 +87,11 @@ describe('useCurrentUserLazyQuery', () => { expect(result.current.data).toEqual({ __typename: 'User', id: '', - email: '' + userId: '', + email: null, + firstName: '', + lastName: null, + emailVerified: false }) }) }) diff --git a/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.mock.ts b/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.mock.ts index dd8bfad7514..21a35edd73f 100644 --- a/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.mock.ts +++ b/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.mock.ts @@ -13,7 +13,11 @@ export const mockUseCurrentUserLazyQuery: MockedResponse = { me: { __typename: 'User', id: 'user.id', - email: 'test@email.com' + userId: 'firebase-uid-123', + email: 'test@email.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true } } } diff --git a/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.ts b/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.ts index f78251e6b17..a16954a5469 100644 --- a/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.ts +++ b/apps/journeys-admin/src/libs/useCurrentUserLazyQuery/useCurrentUserLazyQuery.ts @@ -11,10 +11,14 @@ import { } from '../../../__generated__/GetCurrentUser' export const GET_CURRENT_USER = gql` - query GetCurrentUser { - me { + query GetCurrentUser($input: MeInput) { + me(input: $input) { id + userId email + firstName + lastName + emailVerified } } ` @@ -29,5 +33,16 @@ export function useCurrentUserLazyQuery(): { return { loadUser, data: data.me } } - return { loadUser, data: { __typename: 'User', id: '', email: '' } } + return { + loadUser, + data: { + __typename: 'User', + id: '', + userId: '', + email: null, + firstName: '', + lastName: null, + emailVerified: false + } + } } diff --git a/libs/prisma/users/db/migrations/20250201120000_make_user_email_nullable/migration.sql b/libs/prisma/users/db/migrations/20250201120000_make_user_email_nullable/migration.sql new file mode 100644 index 00000000000..f5908591047 --- /dev/null +++ b/libs/prisma/users/db/migrations/20250201120000_make_user_email_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "email" DROP NOT NULL; diff --git a/libs/prisma/users/db/schema.prisma b/libs/prisma/users/db/schema.prisma index 7647ef9a255..44820f4e454 100644 --- a/libs/prisma/users/db/schema.prisma +++ b/libs/prisma/users/db/schema.prisma @@ -29,7 +29,7 @@ model User { userId String @unique firstName String lastName String? - email String @unique + email String? @unique imageUrl String? createdAt DateTime @default(now()) superAdmin Boolean @default(false)