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:
parent
baef4a7719
commit
adbed8e47d
3 changed files with 144 additions and 25 deletions
|
@ -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 = {
|
||||||
|
|
|
@ -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) });
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
Loading…
Add table
Reference in a new issue