0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(core): sync social email and phone for new registered user

sync social verified email and phone for new registered user
This commit is contained in:
simeng-li 2023-05-21 15:34:01 +08:00 committed by Gao Sun
parent baef4a7719
commit adbed8e47d
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
3 changed files with 144 additions and 25 deletions

View file

@ -73,7 +73,13 @@ describe('submit action', () => {
connectorId: 'logto', 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[] = [ 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 () => { it('admin user register', async () => {
hasActiveUsers.mockResolvedValueOnce(false); hasActiveUsers.mockResolvedValueOnce(false);
const adminConsoleCtx = { const adminConsoleCtx = {

View file

@ -1,6 +1,6 @@
import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event'; import { Component, CoreEvent, getEventName } from '@logto/app-insights/custom-event';
import { appInsights } from '@logto/app-insights/node'; import { appInsights } from '@logto/app-insights/node';
import type { User, Profile } from '@logto/schemas'; import type { User, Profile, CreateUser } from '@logto/schemas';
import { import {
AdminTenantRole, AdminTenantRole,
SignInMode, SignInMode,
@ -10,6 +10,7 @@ import {
InteractionEvent, InteractionEvent,
adminConsoleApplicationId, adminConsoleApplicationId,
} from '@logto/schemas'; } from '@logto/schemas';
import { type OmitAutoSetFields } from '@logto/shared';
import { conditional, conditionalArray } from '@silverhand/essentials'; import { conditional, conditionalArray } from '@silverhand/essentials';
import { EnvSet } from '#src/env-set/index.js'; 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. // 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); const socialIdentifier = identifiers.find((identifier) => identifier.connectorId === connectorId);
if (!socialIdentifier) { if (!socialIdentifier) {
@ -59,26 +59,43 @@ const getNewSocialProfile = async (
} = await getLogtoConnectorById(connectorId); } = await getLogtoConnectorById(connectorId);
const { userInfo } = socialIdentifier; 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 identities = { ...user?.identities, [target]: { userId: id, details: userInfo } };
const profileUpdate = conditional(
(syncProfile || !user) && { // Sync the name, avatar, email and phone for new user
if (!user) {
return {
identities,
...conditional(name && { name }), ...conditional(name && { name }),
...conditional(avatar && { avatar }), ...conditional(avatar && { avatar }),
} ...conditional(email && { primaryEmail: email }),
); ...conditional(phone && { primaryPhone: phone }),
};
}
// Sync the user name and avatar if the connector has syncProfile enabled
return { return {
identities: { ...user?.identities, [target]: { userId: id, details: userInfo } }, identities,
...profileUpdate, ...conditional(
syncProfile && {
...conditional(name && { name }),
...conditional(avatar && { avatar }),
}
),
}; };
}; };
const getSyncedSocialUserProfile = async ( const getLatestUserProfileFromSocial = async (
{ getLogtoConnectorById }: ConnectorLibrary, { getLogtoConnectorById }: ConnectorLibrary,
socialIdentifier: SocialIdentifier authIdentifiers: Identifier[]
) => { ) => {
const socialIdentifier = filterSocialIdentifiers(authIdentifiers).slice(-1)[0];
if (!socialIdentifier) {
return;
}
const { const {
userInfo: { name, avatar }, userInfo: { name, avatar },
connectorId, connectorId,
@ -117,11 +134,11 @@ const parseNewUserProfile = async (
]); ]);
return { return {
...socialProfile, // SocialProfile should be applied first
...passwordProfile,
...conditional(phone && { primaryPhone: phone }), ...conditional(phone && { primaryPhone: phone }),
...conditional(username && { username }), ...conditional(username && { username }),
...conditional(email && { primaryEmail: email }), ...conditional(email && { primaryEmail: email }),
...passwordProfile,
...socialProfile,
}; };
}; };
@ -129,17 +146,15 @@ const parseUserProfile = async (
connectorLibrary: ConnectorLibrary, connectorLibrary: ConnectorLibrary,
{ profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult, { profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult,
user?: User user?: User
) => { ): Promise<Omit<OmitAutoSetFields<CreateUser>, 'id'>> => {
const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile); const { authIdentifiers, profileIdentifiers } = categorizeIdentifiers(identifiers ?? [], profile);
const newUserProfile = const newUserProfile =
profile && (await parseNewUserProfile(connectorLibrary, profile, profileIdentifiers, user)); profile && (await parseNewUserProfile(connectorLibrary, profile, profileIdentifiers, user));
// Sync the last social profile // Sync from the latest social identity profile for existing users
const socialIdentifier = filterSocialIdentifiers(authIdentifiers).slice(-1)[0];
const syncedSocialUserProfile = const syncedSocialUserProfile =
socialIdentifier && (await getSyncedSocialUserProfile(connectorLibrary, socialIdentifier)); user && (await getLatestUserProfileFromSocial(connectorLibrary, authIdentifiers));
return { return {
...syncedSocialUserProfile, ...syncedSocialUserProfile,
@ -151,7 +166,7 @@ const parseUserProfile = async (
export default async function submitInteraction( export default async function submitInteraction(
interaction: VerifiedInteractionResult, interaction: VerifiedInteractionResult,
ctx: WithInteractionDetailsContext, ctx: WithInteractionDetailsContext,
{ provider, libraries, connectors, queries, id: tenantId }: TenantContext, { provider, libraries, connectors, queries }: TenantContext,
log?: LogEntry log?: LogEntry
) { ) {
const { hasActiveUsers, findUserById, updateUserById } = queries.users; const { hasActiveUsers, findUserById, updateUserById } = queries.users;
@ -164,7 +179,7 @@ export default async function submitInteraction(
if (event === InteractionEvent.Register) { if (event === InteractionEvent.Register) {
const id = await generateUserId(); const id = await generateUserId();
const upsertProfile = await parseUserProfile(connectors, interaction); const userProfile = await parseUserProfile(connectors, interaction);
const { client_id } = ctx.interactionDetails.params; const { client_id } = ctx.interactionDetails.params;
@ -178,7 +193,7 @@ export default async function submitInteraction(
await insertUser( await insertUser(
{ {
id, id,
...upsertProfile, ...userProfile,
}, },
conditionalArray<string>( conditionalArray<string>(
isInAdminTenant && AdminTenantRole.User, isInAdminTenant && AdminTenantRole.User,
@ -208,9 +223,9 @@ export default async function submitInteraction(
if (event === InteractionEvent.SignIn) { if (event === InteractionEvent.SignIn) {
const user = await findUserById(accountId); 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 } }); await assignInteractionResults(ctx, provider, { login: { accountId } });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) }); appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) });

View file

@ -4,6 +4,7 @@ import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
import { import {
createSocialAuthorizationUri, createSocialAuthorizationUri,
putInteraction, putInteraction,
getUser,
deleteUser, deleteUser,
putInteractionEvent, putInteractionEvent,
patchInteractionIdentifiers, patchInteractionIdentifiers,
@ -97,6 +98,72 @@ describe('Social Identifier Interactions', () => {
await logoutClient(client); await logoutClient(client);
await deleteUser(id); 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', () => { describe('bind with existing email account', () => {