Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): trigger user create DataHook event on user registration #5837

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/libraries/hook/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type InteractionHookMetadata = {
* In the `koaInteractionHooks` middleware,
* if we get an interaction hook result after the interaction is processed, related hooks will be triggered.
*/
export type InteractionHookResult = {
type InteractionHookResult = {
userId: string;
};

Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/routes/interaction/actions/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defaults, parseAffiliateData } from '@logto/affiliate';
import { type CreateUser, type User, adminTenantId } from '@logto/schemas';
import { adminTenantId, type CreateUser, type User } from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials';
import { type IRouterContext } from 'koa-router';

Expand All @@ -15,8 +15,8 @@ import { type OmitAutoSetFields } from '#src/utils/sql.js';
import {
type Identifier,
type SocialIdentifier,
type VerifiedSignInInteractionResult,
type VerifiedRegisterInteractionResult,
type VerifiedSignInInteractionResult,
} from '../types/index.js';
import { categorizeIdentifiers } from '../utils/interaction.js';

Expand Down Expand Up @@ -149,3 +149,12 @@ export const postAffiliateLogs = async (
getConsoleLogFromContext(ctx).info('Affiliate logs posted', userId);
}
};

/* Verify if user has updated profile */
export const hasUpdatedProfile = ({
lastSignInAt,
...profile
}: Omit<OmitAutoSetFields<CreateUser>, 'id'>) => {
// Check if the lastSignInAt is the only field in the updated profile
return Object.keys(profile).length > 0;
};
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe('submit action', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: { params: {} } as Awaited<ReturnType<Provider['interactionDetails']>>,
assignInteractionHookResult: jest.fn(),
assignDataHookContext: jest.fn(),
};
const profile = {
username: 'username',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
/* eslint-disable max-lines */
import {
InteractionEvent,
adminConsoleApplicationId,
adminTenantId,
type CreateUser,
type User,
} from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';

Expand All @@ -8,9 +15,9 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

import type {
Identifier,
VerifiedForgotPasswordInteractionResult,
VerifiedRegisterInteractionResult,
VerifiedSignInInteractionResult,
VerifiedForgotPasswordInteractionResult,
} from '../types/index.js';
import { userMfaDataKey } from '../verifications/mfa-verification.js';

Expand Down Expand Up @@ -45,15 +52,18 @@ const userQueries = {
identities: { google: { userId: 'googleId', details: {} } },
mfaVerifications: [],
}),
updateUserById: jest.fn(),
updateUserById: jest.fn(async (id: string, user: Partial<User>) => user as User),
hasActiveUsers: jest.fn().mockResolvedValue(true),
hasUserWithEmail: jest.fn().mockResolvedValue(false),
hasUserWithPhone: jest.fn().mockResolvedValue(false),
};

const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = userQueries;

const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() };
const userLibraries = {
generateUserId: jest.fn().mockResolvedValue('uid'),
insertUser: jest.fn(async (user: CreateUser) => user as User),
};
const { generateUserId, insertUser } = userLibraries;

const submitInteraction = await pickDefault(import('./submit-interaction.js'));
Expand All @@ -74,6 +84,7 @@ describe('submit action', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
interactionDetails: { params: {} } as Awaited<ReturnType<Provider['interactionDetails']>>,
assignInteractionHookResult: jest.fn(),
assignDataHookContext: jest.fn(),
};
const profile = {
username: 'username',
Expand Down Expand Up @@ -141,6 +152,14 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'uid' },
});

expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Created',
user: {
id: 'uid',
...upsertProfile,
},
});
});

it('register and use pendingAccountId', async () => {
Expand Down Expand Up @@ -168,6 +187,14 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'pending-account-id' },
});

expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Created',
user: {
id: 'pending-account-id',
...upsertProfile,
},
});
});

it('register with mfaSkipped', async () => {
Expand Down Expand Up @@ -294,11 +321,30 @@ describe('submit action', () => {
});
});

it('sign-in without new profile', async () => {
const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
identifiers: [{ key: 'accountId', value: 'foo' }],
};

await submitInteraction(interaction, ctx, tenant);

expect(updateUserById).toBeCalledWith('foo', {
lastSignInAt: now,
});
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).not.toBeCalled();
});

it('sign-in with new profile', async () => {
getLogtoConnectorById.mockResolvedValueOnce({
metadata: { target: 'logto' },
dbEntry: { syncProfile: false },
});

const interaction: VerifiedSignInInteractionResult = {
event: InteractionEvent.SignIn,
accountId: 'foo',
Expand All @@ -311,18 +357,24 @@ describe('submit action', () => {
expect(encryptUserPassword).toBeCalledWith('password');
expect(getLogtoConnectorById).toBeCalledWith('logto');

expect(updateUserById).toBeCalledWith('foo', {
const updateProfile = {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
identities: {
logto: { userId: userInfo.id, details: userInfo },
google: { userId: 'googleId', details: {} },
},
lastSignInAt: now,
});
};

expect(updateUserById).toBeCalledWith('foo', updateProfile);
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: updateProfile,
});
});

it('sign-in with mfaSkipped', async () => {
Expand Down Expand Up @@ -380,6 +432,15 @@ describe('submit action', () => {
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'foo' },
});
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: {
primaryEmail: 'email',
name: userInfo.name,
avatar: userInfo.avatar,
lastSignInAt: now,
},
});
});

it('reset password', async () => {
Expand All @@ -392,12 +453,18 @@ describe('submit action', () => {
await submitInteraction(interaction, ctx, tenant);

expect(encryptUserPassword).toBeCalledWith('password');

expect(updateUserById).toBeCalledWith('foo', {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
});

expect(assignInteractionResults).not.toBeCalled();
expect(ctx.assignDataHookContext).toBeCalledWith({
event: 'User.Updated',
user: {
passwordEncrypted: 'passwordEncrypted',
passwordEncryptionMethod: 'plain',
},
});
});
});
/* eslint-enable max-lines */
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { appInsights } from '@logto/app-insights/node';
import type { User, UserOnboardingData } from '@logto/schemas';
import {
AdminTenantRole,
SignInMode,
defaultTenantId,
adminTenantId,
InteractionEvent,
adminConsoleApplicationId,
MfaFactor,
getTenantOrganizationId,
getTenantRole,
OrganizationInvitationStatus,
SignInMode,
TenantRole,
adminConsoleApplicationId,
adminTenantId,
defaultManagementApiAdminName,
OrganizationInvitationStatus,
defaultTenantId,
getTenantOrganizationId,
getTenantRole,
userOnboardingDataKey,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
Expand All @@ -32,13 +32,13 @@ import type { WithInteractionDetailsContext } from '../middleware/koa-interactio
import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
import type {
VerifiedInteractionResult,
VerifiedSignInInteractionResult,
VerifiedRegisterInteractionResult,
VerifiedSignInInteractionResult,
} from '../types/index.js';
import { clearInteractionStorage } from '../utils/interaction.js';
import { userMfaDataKey } from '../verifications/mfa-verification.js';

import { postAffiliateLogs, parseUserProfile } from './helpers.js';
import { hasUpdatedProfile, parseUserProfile, postAffiliateLogs } from './helpers.js';

const parseBindMfas = ({
bindMfas,
Expand Down Expand Up @@ -133,7 +133,7 @@ async function handleSubmitRegister(
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
);

await insertUser(
const user = await insertUser(
{
id,
...userProfile,
Expand Down Expand Up @@ -184,10 +184,13 @@ async function handleSubmitRegister(
}

await assignInteractionResults(ctx, provider, { login: { accountId: id } });

ctx.assignInteractionHookResult({ userId: id });
ctx.assignDataHookContext({ event: 'User.Created', user });

log?.append({ userId: id });
appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.Register) });

void trySafe(postAffiliateLogs(ctx, cloudConnection, id, tenantId), (error) => {
getConsoleLogFromContext(ctx).warn('Failed to post affiliate logs', error);
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
Expand All @@ -211,7 +214,7 @@ async function handleSubmitSignIn(
const mfaVerifications = parseBindMfas(interaction);
const { mfaSkipped } = interaction;

await updateUserById(accountId, {
const updatedUser = await updateUserById(accountId, {
...updateUserProfile,
...conditional(
mfaVerifications.length > 0 && {
Expand All @@ -229,8 +232,14 @@ async function handleSubmitSignIn(
}
),
});

await assignInteractionResults(ctx, provider, { login: { accountId } });

ctx.assignInteractionHookResult({ userId: accountId });
// Trigger user.updated data hook event if the user profile or mfa data is updated
if (hasUpdatedProfile(updateUserProfile) || mfaVerifications.length > 0) {
ctx.assignDataHookContext({ event: 'User.Updated', user: updatedUser });
}

appInsights.client?.trackEvent({ name: getEventName(Component.Core, CoreEvent.SignIn) });
}
Expand Down Expand Up @@ -261,8 +270,10 @@ export default async function submitInteraction(
profile.password
);

await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod });
const user = await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod });
ctx.assignInteractionHookResult({ userId: accountId });
ctx.assignDataHookContext({ event: 'User.Updated', user });

await clearInteractionStorage(ctx, provider);
ctx.status = 204;
}
Loading
Loading