0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core,ui,schemas)!: refactor social identity verification payload (#2924)

This commit is contained in:
simeng-li 2023-01-16 18:15:31 +08:00 committed by GitHub
parent 089b138c77
commit ace9a01327
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 641 additions and 369 deletions

View file

@ -172,6 +172,10 @@ describe('interaction routes', () => {
});
it('should call identifier and profile verification properly', async () => {
verifyProfile.mockReturnValueOnce({
event: InteractionEvent.SignIn,
});
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();
@ -185,6 +189,10 @@ describe('interaction routes', () => {
event: InteractionEvent.ForgotPassword,
});
verifyProfile.mockReturnValueOnce({
event: InteractionEvent.ForgotPassword,
});
await sessionRequest.post(path).send();
expect(getInteractionStorage).toBeCalled();
expect(verifyIdentifier).toBeCalled();

View file

@ -28,6 +28,7 @@ import {
getInteractionStorage,
storeInteractionResult,
mergeIdentifiers,
isForgotPasswordInteractionResult,
} from './utils/interaction.js';
import {
verifySignInModeSettings,
@ -287,6 +288,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
async (ctx, next) => {
const { interactionDetails, createLog } = ctx;
const interactionStorage = getInteractionStorage(interactionDetails.result);
const { event } = interactionStorage;
const log = createLog(`Interaction.${event}.Submit`);
@ -294,13 +296,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
const accountVerifiedInteraction = await verifyIdentifier(ctx, tenant, interactionStorage);
const verifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
const profileVerifiedInteraction = await verifyProfile(tenant, accountVerifiedInteraction);
if (event !== InteractionEvent.ForgotPassword) {
await validateMandatoryUserProfile(queries.users, ctx, verifiedInteraction);
}
const interaction = isForgotPasswordInteractionResult(profileVerifiedInteraction)
? profileVerifiedInteraction
: await validateMandatoryUserProfile(queries.users, ctx, profileVerifiedInteraction);
await submitInteraction(verifiedInteraction, ctx, tenant, log);
await submitInteraction(interaction, ctx, tenant, log);
return next();
}

View file

@ -4,6 +4,8 @@ import type {
EmailPasswordPayload,
PhonePasswordPayload,
InteractionEvent,
SocialEmailPayload,
SocialPhonePayload,
} from '@logto/schemas';
import type { z } from 'zod';
@ -27,6 +29,8 @@ export type PasswordIdentifierPayload =
| EmailPasswordPayload
| PhonePasswordPayload;
export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload;
export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>;
/* Interaction Types */

View file

@ -1,6 +1,7 @@
import type { ConnectorSession } from '@logto/connector-kit';
import { connectorSessionGuard } from '@logto/connector-kit';
import type { Profile, InteractionEvent } from '@logto/schemas';
import type { Profile } from '@logto/schemas';
import { InteractionEvent } from '@logto/schemas';
import type { Context } from 'koa';
import type { InteractionResults } from 'oidc-provider';
import type Provider from 'oidc-provider';
@ -14,6 +15,8 @@ import type {
Identifier,
AnonymousInteractionResult,
AccountVerifiedInteractionResult,
VerifiedForgotPasswordInteractionResult,
VerifiedInteractionResult,
} from '../types/index.js';
const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => {
@ -84,6 +87,11 @@ export const isAccountVerifiedInteractionResult = (
interaction: AnonymousInteractionResult
): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId);
export const isForgotPasswordInteractionResult = (
interaction: VerifiedInteractionResult
): interaction is VerifiedForgotPasswordInteractionResult =>
interaction.event === InteractionEvent.ForgotPassword;
export const storeInteractionResult = async (
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: InteractionEvent },
ctx: Context,

View file

@ -193,7 +193,7 @@ describe('identifier verification', () => {
],
};
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
const identifierPayload = Object.freeze({ connectorId: 'logto', email: 'email@logto.io' });
const result = await identifierPayloadVerification(
baseCtx,
@ -201,6 +201,7 @@ describe('identifier verification', () => {
identifierPayload,
interactionRecord
);
expect(result).toEqual({
key: 'emailVerified',
value: 'email@logto.io',
@ -208,7 +209,7 @@ describe('identifier verification', () => {
});
it('verified social email should throw if social session not found', async () => {
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
const identifierPayload = Object.freeze({ connectorId: 'logto', email: 'email@logto.io' });
await expect(
identifierPayloadVerification(baseCtx, tenant, identifierPayload, interactionStorage)
@ -224,12 +225,13 @@ describe('identifier verification', () => {
connectorId: 'logto',
userInfo: {
id: 'foo',
email: 'email@googl.io',
},
},
],
};
const identifierPayload = Object.freeze({ connectorId: 'logto', identityType: 'email' });
const identifierPayload = Object.freeze({ connectorId: 'logto', email: 'email@logto.io' });
await expect(
identifierPayloadVerification(baseCtx, tenant, identifierPayload, interactionRecord)

View file

@ -2,7 +2,6 @@ import type {
InteractionEvent,
IdentifierPayload,
SocialConnectorPayload,
SocialIdentityPayload,
VerifyVerificationCodePayload,
} from '@logto/schemas';
@ -20,6 +19,7 @@ import type {
AnonymousInteractionResult,
Identifier,
AccountIdIdentifier,
SocialVerifiedIdentifierPayload,
} from '../types/index.js';
import findUserByIdentifier from '../utils/find-user-by-identifier.js';
import {
@ -81,13 +81,15 @@ const verifySocialIdentifier = async (
return { key: 'social', connectorId: identifier.connectorId, userInfo };
};
const verifySocialIdentityInInteractionRecord = async (
{ connectorId, identityType }: SocialIdentityPayload,
const verifySocialVerifiedIdentifier = async (
payload: SocialVerifiedIdentifierPayload,
ctx: WithLogContext,
interactionRecord?: AnonymousInteractionResult
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
const log = ctx.createLog(`Interaction.SignIn.Identifier.Social.Submit`);
log.append({ connectorId, identityType });
log.append(payload);
const { connectorId } = payload;
// Sign-In with social verified email or phone requires a social identifier in the interaction result
const socialIdentifierRecord = interactionRecord?.identifiers?.find(
@ -95,13 +97,32 @@ const verifySocialIdentityInInteractionRecord = async (
entity.key === 'social' && entity.connectorId === connectorId
);
const verifiedSocialIdentity = socialIdentifierRecord?.userInfo[identityType];
assertThat(socialIdentifierRecord, new RequestError('session.connector_session_not_found'));
assertThat(verifiedSocialIdentity, new RequestError('session.connector_session_not_found'));
// Verified Email Payload
if ('email' in payload) {
const { email } = payload;
assertThat(
socialIdentifierRecord.userInfo.email === email,
new RequestError('session.connector_session_not_found')
);
return {
key: 'emailVerified',
value: email,
};
}
// Verified Phone Payload
const { phone } = payload;
assertThat(
socialIdentifierRecord.userInfo.phone === phone,
new RequestError('session.connector_session_not_found')
);
return {
key: identityType === 'email' ? 'emailVerified' : 'phoneVerified',
value: verifiedSocialIdentity,
key: 'phoneVerified',
value: phone,
};
};
@ -126,5 +147,5 @@ export default async function identifierPayloadVerification(
}
// Sign-In with social verified email or phone
return verifySocialIdentityInInteractionRecord(identifierPayload, ctx, interactionStorage);
return verifySocialVerifiedIdentifier(identifierPayload, ctx, interactionStorage);
}

View file

@ -13,7 +13,10 @@ const { jest } = import.meta;
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
const findUserById = jest.fn();
const { users } = new MockQueries({ users: { findUserById } });
const hasUserWithEmail = jest.fn();
const hasUserWithPhone = jest.fn();
const { users } = new MockQueries({ users: { findUserById, hasUserWithEmail, hasUserWithPhone } });
const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({
isUserPasswordSet: jest.fn(),
@ -36,81 +39,127 @@ describe('validateMandatoryUserProfile', () => {
accountId: 'foo',
};
it('username and password missing but required', async () => {
await expect(validateMandatoryUserProfile(users, baseCtx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
)
);
describe('username and password required', () => {
it('username and password missing should throw', async () => {
await expect(validateMandatoryUserProfile(users, baseCtx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
)
);
await expect(
validateMandatoryUserProfile(users, baseCtx, {
await expect(
validateMandatoryUserProfile(users, baseCtx, {
...interaction,
profile: {
username: 'username',
password: 'password',
},
})
).resolves.not.toThrow();
});
it('user account has username and password should not throw', async () => {
findUserById.mockResolvedValueOnce({
username: 'foo',
});
isUserPasswordSet.mockResolvedValueOnce(true);
await expect(
validateMandatoryUserProfile(users, baseCtx, interaction)
).resolves.not.toThrow();
});
it('register user has social profile and username should not throw', async () => {
await expect(
validateMandatoryUserProfile(users, baseCtx, {
event: InteractionEvent.Register,
profile: {
username: 'foo',
connectorId: 'logto',
},
})
).resolves.not.toThrow();
});
});
describe('email required', () => {
const emailRequiredCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
},
};
it('email missing but required should throw', async () => {
await expect(
validateMandatoryUserProfile(users, emailRequiredCtx, interaction)
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.email] }
)
);
});
it('user account has email should not throw', async () => {
findUserById.mockResolvedValueOnce({
primaryEmail: 'email',
});
await expect(
validateMandatoryUserProfile(users, emailRequiredCtx, interaction)
).resolves.not.toThrow();
});
it('profile includes email should not throw', async () => {
await expect(
validateMandatoryUserProfile(users, emailRequiredCtx, {
...interaction,
profile: {
email: 'email',
},
})
).resolves.not.toThrow();
});
it('identifier includes social with verified email but email occupied should throw', async () => {
hasUserWithEmail.mockResolvedValueOnce(true);
await expect(
validateMandatoryUserProfile(users, emailRequiredCtx, {
...interaction,
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { email: 'email', id: 'foo' }, connectorId: 'logto' },
],
})
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.email] }
)
);
});
it('identifier includes social with verified email should not throw', async () => {
hasUserWithEmail.mockResolvedValueOnce(false);
const updatedInteraction = await validateMandatoryUserProfile(users, emailRequiredCtx, {
...interaction,
profile: {
username: 'username',
password: 'password',
},
})
).resolves.not.toThrow();
});
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { email: 'email', id: 'foo' }, connectorId: 'logto' },
],
});
it('user account has username and password', async () => {
findUserById.mockResolvedValueOnce({
username: 'foo',
expect(updatedInteraction.profile).toEqual({ email: 'email' });
});
isUserPasswordSet.mockResolvedValueOnce(true);
await expect(validateMandatoryUserProfile(users, baseCtx, interaction)).resolves.not.toThrow();
});
it('register user has social profile', async () => {
await expect(
validateMandatoryUserProfile(users, baseCtx, {
event: InteractionEvent.Register,
profile: {
username: 'foo',
connectorId: 'logto',
},
})
).resolves.not.toThrow();
});
it('email missing but required', async () => {
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
},
};
await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.email] }
)
);
});
it('user account has email', async () => {
findUserById.mockResolvedValueOnce({
primaryEmail: 'email',
});
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
},
};
await expect(validateMandatoryUserProfile(users, ctx, interaction)).resolves.not.toThrow();
});
it('phone missing but required', async () => {
const ctx = {
describe('phone required', () => {
const phoneRequiredCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
@ -118,31 +167,73 @@ describe('validateMandatoryUserProfile', () => {
},
};
await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.phone] }
)
);
});
it('user account has phone', async () => {
findUserById.mockResolvedValueOnce({
primaryPhone: 'phone',
it('phone missing should throw', async () => {
await expect(
validateMandatoryUserProfile(users, phoneRequiredCtx, interaction)
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.phone] }
)
);
});
const ctx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
signUp: { identifiers: [SignInIdentifier.Phone], password: false, verify: true },
},
};
it('user account has phone should not throw', async () => {
findUserById.mockResolvedValueOnce({
primaryPhone: 'phone',
});
await expect(validateMandatoryUserProfile(users, ctx, interaction)).resolves.not.toThrow();
await expect(
validateMandatoryUserProfile(users, phoneRequiredCtx, interaction)
).resolves.not.toThrow();
});
it('profile includes phone should not throw', async () => {
await expect(
validateMandatoryUserProfile(users, phoneRequiredCtx, {
...interaction,
profile: {
phone: '123456',
},
})
).resolves.not.toThrow();
});
it('identifier includes social with verified phone but phone occupied should throw', async () => {
hasUserWithPhone.mockResolvedValueOnce(true);
await expect(
validateMandatoryUserProfile(users, phoneRequiredCtx, {
...interaction,
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { phone: '123456', id: 'foo' }, connectorId: 'logto' },
],
})
).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.phone] }
)
);
});
it('identifier includes social with verified phone should not throw', async () => {
hasUserWithPhone.mockResolvedValueOnce(false);
const updatedInteraction = await validateMandatoryUserProfile(users, phoneRequiredCtx, {
...interaction,
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { phone: '123456', id: 'foo' }, connectorId: 'logto' },
],
});
expect(updatedInteraction.profile).toEqual({ phone: '123456' });
});
});
it('email or Phone required', async () => {
describe('email or Phone required', () => {
const ctx = {
...baseCtx,
signInExperience: {
@ -155,26 +246,56 @@ describe('validateMandatoryUserProfile', () => {
},
};
await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.emailOrPhone] }
)
);
it('missing email and phone should throw', async () => {
await expect(validateMandatoryUserProfile(users, ctx, interaction)).rejects.toMatchError(
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: [MissingProfile.emailOrPhone] }
)
);
});
await expect(
validateMandatoryUserProfile(users, ctx, {
...interaction,
profile: { email: 'email' },
})
).resolves.not.toThrow();
it('profile includes email should not throw', async () => {
await expect(
validateMandatoryUserProfile(users, ctx, {
...interaction,
profile: { email: 'email' },
})
).resolves.not.toThrow();
});
await expect(
validateMandatoryUserProfile(users, ctx, {
it('profile includes phone should not throw', async () => {
await expect(
validateMandatoryUserProfile(users, ctx, {
...interaction,
profile: { phone: '123456' },
})
).resolves.not.toThrow();
});
it('identifier includes social with verified email should not throw', async () => {
const updatedInteraction = await validateMandatoryUserProfile(users, ctx, {
...interaction,
profile: { phone: '123456' },
})
).resolves.not.toThrow();
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { email: 'email', id: 'foo' }, connectorId: 'logto' },
],
});
expect(updatedInteraction.profile).toEqual({ email: 'email' });
});
it('identifier includes social with verified phone should not throw', async () => {
const updatedInteraction = await validateMandatoryUserProfile(users, ctx, {
...interaction,
identifiers: [
...interaction.identifiers,
{ key: 'social', userInfo: { phone: '123456', id: 'foo' }, connectorId: 'logto' },
],
});
expect(updatedInteraction.profile).toEqual({ phone: '123456' });
});
});
it('register fallback profile validation', async () => {

View file

@ -8,8 +8,17 @@ import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import type { WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
import type {
SocialIdentifier,
VerifiedSignInInteractionResult,
VerifiedRegisterInteractionResult,
} from '../types/index.js';
import { isUserPasswordSet } from '../utils/index.js';
import { mergeIdentifiers } from '../utils/interaction.js';
type MandatoryProfileValidationInteraction =
| VerifiedSignInInteractionResult
| VerifiedRegisterInteractionResult;
// eslint-disable-next-line complexity
const getMissingProfileBySignUpIdentifiers = ({
@ -75,7 +84,7 @@ const getMissingProfileBySignUpIdentifiers = ({
return missingProfile;
};
// This is a fallback logic make sure the user has a valid identifier for register should be guarded by the SIE already
// This is a fallback logic make sure the user has a valid identifier for sign-up. Should be guarded by the SIE already
const validateRegisterMandatoryUserProfile = (profile?: Profile) => {
assertThat(
profile && (profile.username ?? profile.email ?? profile.phone ?? profile.connectorId),
@ -83,20 +92,99 @@ const validateRegisterMandatoryUserProfile = (profile?: Profile) => {
);
};
// Fill the missing email or phone from the social identity if any
const fillMissingProfileWithSocialIdentity = async (
missingProfile: Set<MissingProfile>,
interaction: MandatoryProfileValidationInteraction,
userQueries: Queries['users']
): Promise<[Set<MissingProfile>, MandatoryProfileValidationInteraction]> => {
const { identifiers = [], profile } = interaction;
const socialIdentifier = identifiers.find(
(identifier): identifier is SocialIdentifier => identifier.key === 'social'
);
if (!socialIdentifier) {
return [missingProfile, interaction];
}
const {
userInfo: { email, phone },
} = socialIdentifier;
if (
(missingProfile.has(MissingProfile.email) || missingProfile.has(MissingProfile.emailOrPhone)) &&
email &&
// Email taken
!(await userQueries.hasUserWithEmail(email))
) {
missingProfile.delete(MissingProfile.email);
missingProfile.delete(MissingProfile.emailOrPhone);
// Assign social verified email to the interaction
return [
missingProfile,
{
...interaction,
identifiers: mergeIdentifiers({ key: 'emailVerified', value: email }, identifiers),
profile: {
...profile,
email,
},
},
];
}
if (
(missingProfile.has(MissingProfile.phone) || missingProfile.has(MissingProfile.emailOrPhone)) &&
phone &&
// Phone taken
!(await userQueries.hasUserWithPhone(phone))
) {
missingProfile.delete(MissingProfile.phone);
missingProfile.delete(MissingProfile.emailOrPhone);
// Assign social verified phone to the interaction
return [
missingProfile,
{
...interaction,
identifiers: mergeIdentifiers({ key: 'phoneVerified', value: phone }, identifiers),
profile: {
...profile,
phone,
},
},
];
}
return [missingProfile, interaction];
};
export default async function validateMandatoryUserProfile(
userQueries: Queries['users'],
ctx: WithInteractionSieContext<Context>,
interaction: IdentifierVerifiedInteractionResult
interaction: MandatoryProfileValidationInteraction
) {
const { signUp } = ctx.signInExperience;
const { event, accountId, profile } = interaction;
const { event, profile } = interaction;
const user =
event === InteractionEvent.Register ? null : await userQueries.findUserById(accountId);
event === InteractionEvent.Register
? null
: // eslint-disable-next-line unicorn/consistent-destructuring -- have to infer the accountId existence by event !== register
await userQueries.findUserById(interaction.accountId);
const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile });
const [updatedMissingProfileSet, updatedInteraction] = await fillMissingProfileWithSocialIdentity(
missingProfileSet,
interaction,
userQueries
);
assertThat(
missingProfileSet.size === 0,
updatedMissingProfileSet.size === 0,
new RequestError(
{ code: 'user.missing_profile', status: 422 },
{ missingProfile: Array.from(missingProfileSet) }
@ -106,4 +194,6 @@ export default async function validateMandatoryUserProfile(
if (event === InteractionEvent.Register) {
validateRegisterMandatoryUserProfile(profile);
}
return updatedInteraction;
}

View file

@ -97,7 +97,13 @@ describe('verifyUserAccount', () => {
const interaction: SignInInteractionResult = {
event: InteractionEvent.SignIn,
identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }],
identifiers: [
{
key: 'social',
connectorId: 'connectorId',
userInfo: { id: 'foo', email: 'email@logto.io' },
},
],
};
await expect(verifyUserAccount(tenant, interaction)).rejects.toMatchError(
@ -106,13 +112,13 @@ describe('verifyUserAccount', () => {
code: 'user.identity_not_exist',
status: 422,
},
null
{ email: 'email@logto.io' }
)
);
expect(findUserByIdentifierMock).toBeCalledWith(tenant, {
connectorId: 'connectorId',
userInfo: { id: 'foo' },
userInfo: { id: 'foo', email: 'email@logto.io' },
});
});

View file

@ -54,7 +54,11 @@ const identifyUserBySocialIdentifier = async (
code: 'user.identity_not_exist',
status: 422,
},
relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }
{
...(relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }),
...(userInfo.email && { email: userInfo.email }),
...(userInfo.phone && { phone: userInfo.phone }),
}
);
}

View file

@ -1,4 +1,4 @@
import { ConnectorType, InteractionEvent } from '@logto/schemas';
import { ConnectorType, InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js';
import {
@ -8,17 +8,19 @@ import {
putInteractionEvent,
patchInteractionIdentifiers,
putInteractionProfile,
patchInteractionProfile,
} from '#src/api/index.js';
import { initClient, logoutClient, processSession } from '#src/helpers/client.js';
import {
clearConnectorsByTypes,
clearConnectorById,
setSocialConnector,
setEmailConnector,
setSmsConnector,
} from '#src/helpers/connector.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { generateUserId } from '#src/utils.js';
import { generateUserId, generateUsername, generateEmail, generatePhone } from '#src/utils.js';
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
@ -29,13 +31,16 @@ describe('Social Identifier Interactions', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
await clearConnectorsByTypes([ConnectorType.Social]);
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
const { id } = await setSocialConnector();
await setEmailConnector();
await setSmsConnector();
connectorIdMap.set(mockSocialConnectorId, id);
});
afterAll(async () => {
await clearConnectorById(connectorIdMap.get(mockSocialConnectorId) ?? '');
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
});
describe('register new and sign-in', () => {
@ -94,62 +99,6 @@ describe('Social Identifier Interactions', () => {
});
});
describe('bind to existing account and sign-in', () => {
const socialUserId = generateUserId();
it('bind new social to a existing account', async () => {
const {
userProfile: { username, password },
} = await generateNewUser({ username: true, password: true });
const client = await initClient();
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 },
});
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(patchInteractionIdentifiers, { username, password });
await client.successSend(putInteractionProfile, { connectorId });
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
});
it('sign in with social', async () => {
const client = await initClient();
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 },
});
const { redirectTo } = await client.submitInteraction();
const id = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(id);
});
});
describe('bind with existing email account', () => {
const socialUserId = generateUserId();
@ -178,7 +127,10 @@ describe('Social Identifier Interactions', () => {
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(patchInteractionIdentifiers, { connectorId, identityType: 'email' });
await client.successSend(patchInteractionIdentifiers, {
connectorId,
email: userProfile.primaryEmail,
});
await client.successSend(putInteractionProfile, { connectorId });
const { redirectTo } = await client.submitInteraction();
@ -208,4 +160,114 @@ describe('Social Identifier Interactions', () => {
await deleteUser(id);
});
});
describe('register and link mandatory profile', () => {
const socialUserId = generateUserId();
it('bind username', async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
});
const client = await initClient();
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 },
});
await expectRejects(client.submitInteraction(), 'user.identity_not_exist');
await client.successSend(putInteractionEvent, { event: InteractionEvent.Register });
await client.successSend(putInteractionProfile, { connectorId });
await expectRejects(client.submitInteraction(), 'user.missing_profile');
await client.successSend(patchInteractionProfile, { username: generateUsername() });
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
});
it('directly bind social trusted email', async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Email],
password: true,
verify: true,
});
const client = await initClient();
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: generateEmail() },
});
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 userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
});
it('directly bind social trusted phone', async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Phone],
password: true,
verify: true,
});
const client = await initClient();
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: generatePhone() },
});
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 userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
});
});
});

View file

@ -39,11 +39,19 @@ export const socialConnectorPayloadGuard = z.object({
});
export type SocialConnectorPayload = z.infer<typeof socialConnectorPayloadGuard>;
export const socialIdentityPayloadGuard = z.object({
export const socialEmailPayloadGuard = z.object({
connectorId: z.string(),
identityType: z.union([z.literal('phone'), z.literal('email')]),
email: z.string(),
});
export type SocialIdentityPayload = z.infer<typeof socialIdentityPayloadGuard>;
export type SocialEmailPayload = z.infer<typeof socialEmailPayloadGuard>;
export const socialPhonePayloadGuard = z.object({
connectorId: z.string(),
phone: z.string(),
});
export type SocialPhonePayload = z.infer<typeof socialPhonePayloadGuard>;
// Interaction Payload Guard
@ -63,7 +71,8 @@ export const identifierPayloadGuard = z.union([
emailVerificationCodePayloadGuard,
phoneVerificationCodePayloadGuard,
socialConnectorPayloadGuard,
socialIdentityPayloadGuard,
socialEmailPayloadGuard,
socialPhonePayloadGuard,
]);
export type IdentifierPayload =
@ -73,7 +82,8 @@ export type IdentifierPayload =
| EmailVerificationCodePayload
| PhoneVerificationCodePayload
| SocialConnectorPayload
| SocialIdentityPayload;
| SocialPhonePayload
| SocialEmailPayload;
export const profileGuard = z.object({
username: z.string().regex(usernameRegEx).optional(),

View file

@ -8,7 +8,8 @@ import type {
EmailVerificationCodePayload,
PhoneVerificationCodePayload,
SocialConnectorPayload,
SocialIdentityPayload,
SocialEmailPayload,
SocialPhonePayload,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
@ -26,25 +27,13 @@ export type PasswordSignInPayload =
| EmailPasswordPayload
| PhonePasswordPayload;
export const signInWithPasswordIdentifier = async (
payload: PasswordSignInPayload,
socialToBind?: string
) => {
if (socialToBind) {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
} else {
await api.put(`${interactionPrefix}`, {
json: {
event: InteractionEvent.SignIn,
identifier: payload,
},
});
}
export const signInWithPasswordIdentifier = async (payload: PasswordSignInPayload) => {
await api.put(`${interactionPrefix}`, {
json: {
event: InteractionEvent.SignIn,
identifier: payload,
},
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
@ -89,23 +78,17 @@ export const sendVerificationCode = async (payload: SendVerificationCodePayload)
};
export const signInWithVerificationCodeIdentifier = async (
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload,
socialToBind?: string
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload
) => {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const addProfileWithVerificationCodeIdentifier = async (
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload,
socialToBind?: string
payload: EmailVerificationCodePayload | PhoneVerificationCodePayload
) => {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
@ -117,10 +100,6 @@ export const addProfileWithVerificationCodeIdentifier = async (
json: identifier,
});
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
@ -160,16 +139,9 @@ export const registerWithVerifiedIdentifier = async (payload: SendVerificationCo
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const addProfile = async (
payload: { username: string } | { password: string },
socialToBind?: string
) => {
export const addProfile = async (payload: { username: string } | { password: string }) => {
await api.patch(`${interactionPrefix}/profile`, { json: payload });
await api.patch(`${interactionPrefix}/profile`, {
json: { connectorId: socialToBind },
});
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
@ -215,7 +187,7 @@ export const registerWithVerifiedSocial = async (connectorId: string) => {
return api.post(`${interactionPrefix}/submit`).json<Response>();
};
export const bindSocialRelatedUser = async (payload: SocialIdentityPayload) => {
export const bindSocialRelatedUser = async (payload: SocialEmailPayload | SocialPhonePayload) => {
await api.patch(`${interactionPrefix}/identifiers`, {
json: payload,
});

View file

@ -1,7 +1,6 @@
import { InteractionEvent } from '@logto/schemas';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { UserFlow } from '@/types';
import type { SendVerificationCodePayload } from './interaction';
import { putInteraction, sendVerificationCode } from './interaction';
@ -12,10 +11,8 @@ export const getSendVerificationCodeApi =
await putInteraction(InteractionEvent.ForgotPassword);
}
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
// Init a new interaction only if the user is not binding with a social
if (type === UserFlow.signIn && !socialToBind) {
if (type === UserFlow.signIn) {
await putInteraction(InteractionEvent.SignIn);
}

View file

@ -43,7 +43,7 @@ describe('EmailContinue', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/email/verification-code', search: '' },
{ pathname: '/continue/email/verification-code' },
{ state: { email } }
);
});

View file

@ -44,7 +44,7 @@ describe('EmailRegister', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/email/verification-code', search: '' },
{ pathname: '/register/email/verification-code' },
{ state: { email } }
);
});

View file

@ -47,7 +47,6 @@ describe('EmailRegister', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/verification-code`,
search: '',
},
{ state: { email } }
);

View file

@ -55,7 +55,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password', search: '' },
{ pathname: '/sign-in/email/password' },
{ state: { email } }
);
});
@ -90,7 +90,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password', search: '' },
{ pathname: '/sign-in/email/password' },
{ state: { email } }
);
});
@ -126,7 +126,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code', search: '' },
{ pathname: '/sign-in/email/verification-code' },
{ state: { email } }
);
});
@ -162,7 +162,7 @@ describe('EmailSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/verification-code', search: '' },
{ pathname: '/sign-in/email/verification-code' },
{ state: { email } }
);
});

View file

@ -180,13 +180,10 @@ describe('<EmailPassword>', () => {
act(() => {
void waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith(
{
email: 'email',
password: 'password',
},
undefined
);
expect(signInWithPasswordIdentifier).toBeCalledWith({
email: 'email',
password: 'password',
});
});
});
});

View file

@ -14,8 +14,6 @@ import * as styles from './index.module.scss';
type Props = {
methods: SignInIdentifier[];
flow: Exclude<UserFlow, 'forgot-password'>;
// Allows social page to pass additional query params to the sign-in pages
search?: string;
className?: string;
template: TFuncKey<'translation', 'secondary'>;
};
@ -28,7 +26,7 @@ const SignInMethodsKeyMap: {
[SignInIdentifier.Phone]: 'phone_number',
};
const OtherMethodsLink = ({ methods, template, search, flow, className }: Props) => {
const OtherMethodsLink = ({ methods, template, flow, className }: Props) => {
const { t } = useTranslation();
const methodsLink = useMemo(
@ -39,10 +37,10 @@ const OtherMethodsLink = ({ methods, template, search, flow, className }: Props)
className={styles.signInMethodLink}
type="inlinePrimary"
text={`input.${SignInMethodsKeyMap[identifier]}`}
to={{ pathname: `/${flow}/${identifier}`, search }}
to={{ pathname: `/${flow}/${identifier}` }}
/>
)),
[flow, methods, search]
[flow, methods]
);
if (methodsLink.length === 0) {

View file

@ -68,7 +68,7 @@ describe('PasswordSignInForm', () => {
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ email, password }, undefined);
expect(signInWithPasswordIdentifier).toBeCalledWith({ email, password });
});
const sendVerificationCodeLink = getByText('action.sign_in_via_passcode');
@ -87,7 +87,6 @@ describe('PasswordSignInForm', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/verification-code`,
search: '',
},
{
state: { email },
@ -114,7 +113,7 @@ describe('PasswordSignInForm', () => {
});
await waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith({ phone, password }, undefined);
expect(signInWithPasswordIdentifier).toBeCalledWith({ phone, password });
});
const sendVerificationCodeLink = getByText('action.sign_in_via_passcode');
@ -133,7 +132,6 @@ describe('PasswordSignInForm', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Phone}/verification-code`,
search: '',
},
{
state: { phone },

View file

@ -51,7 +51,7 @@ describe('PhoneContinue', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/continue/phone/verification-code', search: '' },
{ pathname: '/continue/phone/verification-code' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -52,7 +52,7 @@ describe('PhoneRegister', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/phone/verification-code', search: '' },
{ pathname: '/register/phone/verification-code' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -55,7 +55,6 @@ describe('PhoneRegister', () => {
expect(mockedNavigate).toBeCalledWith(
{
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Phone}/verification-code`,
search: '',
},
{ state: { phone: fullPhoneNumber } }
);

View file

@ -63,7 +63,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/password', search: '' },
{ pathname: '/sign-in/phone/password' },
{ state: { phone: fullPhoneNumber } }
);
});
@ -98,7 +98,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).not.toBeCalled();
expect(sendVerificationCode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/password', search: '' },
{ pathname: '/sign-in/phone/password' },
{ state: { phone: fullPhoneNumber } }
);
});
@ -134,7 +134,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ pathname: '/sign-in/phone/verification-code' },
{ state: { phone: fullPhoneNumber } }
);
});
@ -170,7 +170,7 @@ describe('PhoneSignIn', () => {
expect(putInteraction).toBeCalledWith(InteractionEvent.SignIn);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/phone/verification-code', search: '' },
{ pathname: '/sign-in/phone/verification-code' },
{ state: { phone: fullPhoneNumber } }
);
});

View file

@ -187,13 +187,10 @@ describe('<PhonePassword>', () => {
act(() => {
void waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith(
{
phone: 'phone',
password: 'password',
},
undefined
);
expect(signInWithPasswordIdentifier).toBeCalledWith({
phone: 'phone',
password: 'password',
});
});
});
});

View file

@ -11,7 +11,9 @@ const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
useLocation: () => ({ state: { relatedUser: { type: 'email', value: 'foo@logto.io' } } }),
useLocation: () => ({
state: { relatedUser: { type: 'email', value: 'foo@logto.io' }, email: 'email@logto.io' },
}),
}));
jest.mock('@/apis/interaction', () => ({
@ -28,7 +30,6 @@ describe('SocialCreateAccount', () => {
);
expect(queryByText('description.social_create_account')).not.toBeNull();
expect(queryByText('description.social_bind_with_existing')).not.toBeNull();
expect(queryByText('secondary.social_bind_with')).not.toBeNull();
});
it('should call registerWithVerifiedSocial when click create button', async () => {
@ -48,6 +49,9 @@ describe('SocialCreateAccount', () => {
await waitFor(() => {
fireEvent.click(bindButton);
});
expect(bindSocialRelatedUser).toBeCalledWith({ connectorId: 'github', identityType: 'email' });
expect(bindSocialRelatedUser).toBeCalledWith({
connectorId: 'github',
email: 'email@logto.io',
});
});
});

View file

@ -4,10 +4,7 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import useBindSocial from '@/hooks/use-bind-social';
import { useSieMethods } from '@/hooks/use-sie';
import { SearchParameters, UserFlow } from '@/types';
import { queryStringify } from '@/utils';
import OtherMethodsLink from '../OtherMethodsLink';
import * as styles from './index.module.scss';
type Props = {
@ -17,19 +14,29 @@ type Props = {
const SocialCreateAccount = ({ connectorId, className }: Props) => {
const { t } = useTranslation();
const { relatedUser, registerWithSocial, bindSocialRelatedUser } = useBindSocial();
const { relatedUser, socialIdentity, registerWithSocial, bindSocialRelatedUser } =
useBindSocial();
const { signInMethods } = useSieMethods();
const relatedIdentifier = relatedUser && socialIdentity?.[relatedUser.type];
return (
<div className={classNames(styles.container, className)}>
{relatedUser && (
{relatedIdentifier && (
<>
<div className={styles.desc}>{t('description.social_bind_with_existing')}</div>
<Button
title="action.bind"
i18nProps={{ address: relatedUser.value }}
onClick={() => {
bindSocialRelatedUser({ connectorId, identityType: relatedUser.type });
bindSocialRelatedUser({
connectorId,
...(relatedUser.type === 'email'
? { email: relatedIdentifier }
: { phone: relatedIdentifier }),
});
}}
/>
</>
@ -42,13 +49,6 @@ const SocialCreateAccount = ({ connectorId, className }: Props) => {
registerWithSocial(connectorId);
}}
/>
<OtherMethodsLink
methods={signInMethods.map(({ identifier }) => identifier)}
template="social_bind_with"
flow={UserFlow.signIn}
className={styles.desc}
search={queryStringify({ [SearchParameters.bindWithSocial]: connectorId })}
/>
</div>
);
};

View file

@ -37,7 +37,7 @@ describe('<UsernameRegister />', () => {
});
await waitFor(() => {
expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
expect(addProfile).toBeCalledWith({ username: 'username' });
});
});
});

View file

@ -5,8 +5,6 @@ import { addProfile } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSetUsername = () => {
const navigate = useNavigate();
@ -32,8 +30,7 @@ const useSetUsername = () => {
const onSubmit = useCallback(
async (username: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncAddProfile({ username }, socialToBind);
await asyncAddProfile({ username });
},
[asyncAddProfile]
);

View file

@ -193,13 +193,10 @@ describe('<UsernameSignIn>', () => {
act(() => {
void waitFor(() => {
expect(signInWithPasswordIdentifier).toBeCalledWith(
{
username: 'username',
password: 'password',
},
undefined
);
expect(signInWithPasswordIdentifier).toBeCalledWith({
username: 'username',
password: 'password',
});
});
});
});

View file

@ -103,10 +103,10 @@ describe('<VerificationCode />', () => {
}
await waitFor(() => {
expect(signInWithVerificationCodeIdentifier).toBeCalledWith(
{ email, verificationCode: '111111' },
undefined
);
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({
email,
verificationCode: '111111',
});
});
await waitFor(() => {
@ -131,13 +131,10 @@ describe('<VerificationCode />', () => {
}
await waitFor(() => {
expect(signInWithVerificationCodeIdentifier).toBeCalledWith(
{
phone,
verificationCode: '111111',
},
undefined
);
expect(signInWithVerificationCodeIdentifier).toBeCalledWith({
phone,
verificationCode: '111111',
});
});
await waitFor(() => {
@ -287,13 +284,10 @@ describe('<VerificationCode />', () => {
}
await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith(
{
email,
verificationCode: '111111',
},
undefined
);
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
email,
verificationCode: '111111',
});
});
await waitFor(() => {
@ -319,13 +313,10 @@ describe('<VerificationCode />', () => {
}
await waitFor(() => {
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith(
{
phone,
verificationCode: '111111',
},
undefined
);
expect(addProfileWithVerificationCodeIdentifier).toBeCalledWith({
phone,
verificationCode: '111111',
});
});
await waitFor(() => {

View file

@ -7,8 +7,6 @@ import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import type { VerificationCodeIdentifier } from '@/types';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
@ -61,8 +59,7 @@ const useContinueFlowCodeVerification = (
const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const result = await verifyVerificationCode(payload, socialToBind);
const result = await verifyVerificationCode(payload);
if (result?.redirectTo) {
window.location.replace(result.redirectTo);

View file

@ -14,8 +14,6 @@ import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import type { VerificationCodeIdentifier } from '@/types';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
@ -42,13 +40,11 @@ const useSignInFlowCodeVerification = (
requiredProfileErrorHandlers
);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const showIdentifierErrorAlert = useIdentifierErrorAlert();
const identifierNotExistErrorHandler = useCallback(async () => {
// Should not redirect user to register if is sign-in only mode or bind social flow
if (signInMode === SignInMode.SignIn || socialToBind) {
if (signInMode === SignInMode.SignIn) {
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, method, target);
return;
@ -85,7 +81,6 @@ const useSignInFlowCodeVerification = (
show,
showIdentifierErrorAlert,
signInMode,
socialToBind,
t,
target,
]);
@ -118,9 +113,9 @@ const useSignInFlowCodeVerification = (
const onSubmit = useCallback(
async (payload: EmailVerificationCodePayload | PhoneVerificationCodePayload) => {
return asyncSignInWithVerificationCodeIdentifier(payload, socialToBind);
return asyncSignInWithVerificationCodeIdentifier(payload);
},
[asyncSignInWithVerificationCodeIdentifier, socialToBind]
[asyncSignInWithVerificationCodeIdentifier]
);
return {

View file

@ -1,4 +1,4 @@
import type { SocialIdentityPayload } from '@logto/schemas';
import type { SocialEmailPayload, SocialPhonePayload } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
@ -12,6 +12,7 @@ import useRequiredProfileErrorHandler from './use-required-profile-error-handler
const useBindSocial = () => {
const { state } = useLocation();
const requiredProfileErrorHandlers = useRequiredProfileErrorHandler();
const { result: registerResult, run: asyncRegisterWithSocial } = useApi(
@ -31,7 +32,7 @@ const useBindSocial = () => {
);
const bindRelatedUserHandler = useCallback(
(payload: SocialIdentityPayload) => {
(payload: SocialEmailPayload | SocialPhonePayload) => {
void asyncBindSocialRelatedUser(payload);
},
[asyncBindSocialRelatedUser]
@ -51,6 +52,12 @@ const useBindSocial = () => {
return {
relatedUser: conditional(is(state, bindSocialStateGuard) && state.relatedUser),
socialIdentity: conditional(
is(state, bindSocialStateGuard) && {
email: state.email,
phone: state.phone,
}
),
registerWithSocial: createAccountHandler,
bindSocialRelatedUser: bindRelatedUserHandler,
};

View file

@ -14,7 +14,6 @@ const useContinueSignInWithPassword = <T extends SignInIdentifier.Email | SignIn
navigate(
{
pathname: `/${UserFlow.signIn}/${method}/password`,
search: location.search,
},
{ state: payload }
);

View file

@ -4,8 +4,6 @@ import type { PasswordSignInPayload } from '@/apis/interaction';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useRequiredProfileErrorHandler from './use-required-profile-error-handler';
@ -38,8 +36,7 @@ const usePasswordSignIn = () => {
const onSubmit = useCallback(
async (payload: PasswordSignInPayload) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncSignIn(payload, socialToBind);
await asyncSignIn(payload);
},
[asyncSignIn]
);

View file

@ -26,7 +26,6 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
navigate(
{
pathname: `/${UserFlow.continue}/${missingProfile}`,
search: location.search,
},
{ replace }
);
@ -35,7 +34,6 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
navigate(
{
pathname: `/${UserFlow.continue}/phone`,
search: location.search,
},
{ replace }
);
@ -44,7 +42,6 @@ const useRequiredProfileErrorHandler = (replace?: boolean) => {
navigate(
{
pathname: `/${UserFlow.continue}/email-or-phone/email`,
search: location.search,
},
{ replace }
);

View file

@ -45,7 +45,6 @@ const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdenti
navigate(
{
pathname: `/${flow}/${method}/verification-code`,
search: location.search,
},
{
state: payload,

View file

@ -52,7 +52,7 @@ describe('SetPassword', () => {
});
await waitFor(() => {
expect(addProfile).toBeCalledWith({ password: '123456' }, undefined);
expect(addProfile).toBeCalledWith({ password: '123456' });
});
});
});

View file

@ -6,8 +6,6 @@ import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSetPassword = () => {
const navigate = useNavigate();
@ -30,8 +28,7 @@ const useSetPassword = () => {
const setPassword = useCallback(
async (password: string) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
await asyncAddProfile({ password }, socialToBind);
await asyncAddProfile({ password });
},
[asyncAddProfile]
);

View file

@ -46,7 +46,7 @@ describe('SetPassword', () => {
});
await waitFor(() => {
expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
expect(addProfile).toBeCalledWith({ username: 'username' });
});
});
});

View file

@ -33,7 +33,6 @@ const SignIn = () => {
methods={otherMethods}
template="sign_in_with"
flow={UserFlow.signIn}
search={location.search}
/>
)
}

View file

@ -8,6 +8,8 @@ export const bindSocialStateGuard = s.object({
type: s.union([s.literal('email'), s.literal('phone')]),
value: s.string(),
}),
email: s.optional(s.string()),
phone: s.optional(s.string()),
});
export const verificationCodeStateGuard = s.object({

View file

@ -13,7 +13,6 @@ export enum UserFlow {
}
export enum SearchParameters {
bindWithSocial = 'bind_with',
nativeCallbackLink = 'native_callback',
redirectTo = 'redirect_to',
}