From adbed8e47da729897a2fab4c2eeaeaaf66d1f929 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Sun, 21 May 2023 15:34:01 +0800 Subject: [PATCH] refactor(core): sync social email and phone for new registered user sync social verified email and phone for new registered user --- .../actions/submit-interaction.test.ts | 39 ++++++++++- .../interaction/actions/submit-interaction.ts | 63 ++++++++++------- .../interaction/social-interaction.test.ts | 67 +++++++++++++++++++ 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index f026a572d..d366c5430 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -73,7 +73,13 @@ describe('submit action', () => { connectorId: 'logto', }; - const userInfo = { id: 'foo', name: 'foo_social', avatar: 'avatar' }; + const userInfo = { + id: 'foo', + name: 'foo_social', + avatar: 'avatar', + email: 'email@socail.com', + phone: '123123', + }; const identifiers: Identifier[] = [ { @@ -127,6 +133,37 @@ describe('submit action', () => { }); }); + it('register new social user', async () => { + const interaction: VerifiedRegisterInteractionResult = { + event: InteractionEvent.Register, + profile: { connectorId: 'logto', username: 'username' }, + identifiers, + }; + + await submitInteraction(interaction, ctx, tenant); + + expect(generateUserId).toBeCalled(); + expect(hasActiveUsers).not.toBeCalled(); + expect(encryptUserPassword).not.toBeCalled(); + expect(getLogtoConnectorById).toBeCalledWith('logto'); + + expect(insertUser).toBeCalledWith( + { + id: 'uid', + username: 'username', + identities: { + logto: { userId: userInfo.id, details: userInfo }, + }, + name: userInfo.name, + avatar: userInfo.avatar, + primaryEmail: userInfo.email, + primaryPhone: userInfo.phone, + lastSignInAt: now, + }, + ['user'] + ); + }); + it('admin user register', async () => { hasActiveUsers.mockResolvedValueOnce(false); const adminConsoleCtx = { diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 6a305e579..c15897caa 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -1,6 +1,6 @@ import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event'; import { appInsights } from '@logto/app-insights/node'; -import type { User, Profile } from '@logto/schemas'; +import type { User, Profile, CreateUser } from '@logto/schemas'; import { AdminTenantRole, SignInMode, @@ -10,6 +10,7 @@ import { InteractionEvent, adminConsoleApplicationId, } from '@logto/schemas'; +import { type OmitAutoSetFields } from '@logto/shared'; import { conditional, conditionalArray } from '@silverhand/essentials'; import { EnvSet } from '#src/env-set/index.js'; @@ -46,7 +47,6 @@ const getNewSocialProfile = async ( } ) => { // TODO: @simeng refactor me. This step should be verified by the previous profile verification cycle Already. - // Should pickup the verified social user info result automatically const socialIdentifier = identifiers.find((identifier) => identifier.connectorId === connectorId); if (!socialIdentifier) { @@ -59,26 +59,43 @@ const getNewSocialProfile = async ( } = await getLogtoConnectorById(connectorId); const { userInfo } = socialIdentifier; - const { name, avatar, id } = userInfo; + const { name, avatar, id, email, phone } = userInfo; - // Update the user name and avatar if the connector has syncProfile enabled or is new registered user - const profileUpdate = conditional( - (syncProfile || !user) && { + const identities = { ...user?.identities, [target]: { userId: id, details: userInfo } }; + + // Sync the name, avatar, email and phone for new user + if (!user) { + return { + identities, ...conditional(name && { name }), ...conditional(avatar && { avatar }), - } - ); + ...conditional(email && { primaryEmail: email }), + ...conditional(phone && { primaryPhone: phone }), + }; + } + // Sync the user name and avatar if the connector has syncProfile enabled return { - identities: { ...user?.identities, [target]: { userId: id, details: userInfo } }, - ...profileUpdate, + identities, + ...conditional( + syncProfile && { + ...conditional(name && { name }), + ...conditional(avatar && { avatar }), + } + ), }; }; -const getSyncedSocialUserProfile = async ( +const getLatestUserProfileFromSocial = async ( { getLogtoConnectorById }: ConnectorLibrary, - socialIdentifier: SocialIdentifier + authIdentifiers: Identifier[] ) => { + const socialIdentifier = filterSocialIdentifiers(authIdentifiers).slice(-1)[0]; + + if (!socialIdentifier) { + return; + } + const { userInfo: { name, avatar }, connectorId, @@ -117,11 +134,11 @@ const parseNewUserProfile = async ( ]); return { + ...socialProfile, // SocialProfile should be applied first + ...passwordProfile, ...conditional(phone && { primaryPhone: phone }), ...conditional(username && { username }), ...conditional(email && { primaryEmail: email }), - ...passwordProfile, - ...socialProfile, }; }; @@ -129,17 +146,15 @@ const parseUserProfile = async ( connectorLibrary: ConnectorLibrary, { profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult, user?: User -) => { +): Promise, 'id'>> => { const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile); const newUserProfile = profile && (await parseNewUserProfile(connectorLibrary, profile, profileIdentifiers, user)); - // Sync the last social profile - const socialIdentifier = filterSocialIdentifiers(authIdentifiers).slice(-1)[0]; - + // Sync from the latest social identity profile for existing users const syncedSocialUserProfile = - socialIdentifier && (await getSyncedSocialUserProfile(connectorLibrary, socialIdentifier)); + user && (await getLatestUserProfileFromSocial(connectorLibrary, authIdentifiers)); return { ...syncedSocialUserProfile, @@ -151,7 +166,7 @@ const parseUserProfile = async ( export default async function submitInteraction( interaction: VerifiedInteractionResult, ctx: WithInteractionDetailsContext, - { provider, libraries, connectors, queries, id: tenantId }: TenantContext, + { provider, libraries, connectors, queries }: TenantContext, log?: LogEntry ) { const { hasActiveUsers, findUserById, updateUserById } = queries.users; @@ -164,7 +179,7 @@ export default async function submitInteraction( if (event === InteractionEvent.Register) { const id = await generateUserId(); - const upsertProfile = await parseUserProfile(connectors, interaction); + const userProfile = await parseUserProfile(connectors, interaction); const { client_id } = ctx.interactionDetails.params; @@ -178,7 +193,7 @@ export default async function submitInteraction( await insertUser( { id, - ...upsertProfile, + ...userProfile, }, conditionalArray( isInAdminTenant && AdminTenantRole.User, @@ -208,9 +223,9 @@ export default async function submitInteraction( if (event === InteractionEvent.SignIn) { const user = await findUserById(accountId); - const upsertProfile = await parseUserProfile(connectors, interaction, user); + const updateUserProfile = await parseUserProfile(connectors, interaction, user); - await updateUserById(accountId, upsertProfile); + await updateUserById(accountId, updateUserProfile); await assignInteractionResults(ctx, provider, { login: { accountId } }); appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) }); diff --git a/packages/integration-tests/src/tests/api/interaction/social-interaction.test.ts b/packages/integration-tests/src/tests/api/interaction/social-interaction.test.ts index eb2cc5616..bdc4b9579 100644 --- a/packages/integration-tests/src/tests/api/interaction/social-interaction.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/social-interaction.test.ts @@ -4,6 +4,7 @@ import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js'; import { createSocialAuthorizationUri, putInteraction, + getUser, deleteUser, putInteractionEvent, patchInteractionIdentifiers, @@ -97,6 +98,72 @@ describe('Social Identifier Interactions', () => { await logoutClient(client); await deleteUser(id); }); + + it('register with social and synced email', async () => { + const client = await initClient(); + const socialEmail = generateEmail(); + + const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? ''; + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId }); + + await client.successSend(patchInteractionIdentifiers, { + connectorId, + connectorData: { state, redirectUri, code, userId: socialUserId, email: socialEmail }, + }); + + await expectRejects(client.submitInteraction(), 'user.identity_not_exist'); + + await client.successSend(putInteractionEvent, { event: InteractionEvent.Register }); + await client.successSend(putInteractionProfile, { connectorId }); + + const { redirectTo } = await client.submitInteraction(); + + const uid = await processSession(client, redirectTo); + + const { primaryEmail } = await getUser(uid); + expect(primaryEmail).toBe(socialEmail); + + await logoutClient(client); + await deleteUser(uid); + }); + + it('register with social and synced phone', async () => { + const client = await initClient(); + const socialPhone = generatePhone(); + + const connectorId = connectorIdMap.get(mockSocialConnectorId) ?? ''; + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await client.successSend(createSocialAuthorizationUri, { state, redirectUri, connectorId }); + + await client.successSend(patchInteractionIdentifiers, { + connectorId, + connectorData: { state, redirectUri, code, userId: socialUserId, phone: socialPhone }, + }); + + await expectRejects(client.submitInteraction(), 'user.identity_not_exist'); + + await client.successSend(putInteractionEvent, { event: InteractionEvent.Register }); + await client.successSend(putInteractionProfile, { connectorId }); + + const { redirectTo } = await client.submitInteraction(); + + const uid = await processSession(client, redirectTo); + + const { primaryPhone } = await getUser(uid); + expect(primaryPhone).toBe(socialPhone); + + await logoutClient(client); + await deleteUser(uid); + }); }); describe('bind with existing email account', () => {