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:
parent
089b138c77
commit
ace9a01327
45 changed files with 641 additions and 369 deletions
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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 }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -47,7 +47,6 @@ describe('EmailRegister', () => {
|
|||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/verification-code`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { email } }
|
||||
);
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -180,13 +180,10 @@ describe('<EmailPassword>', () => {
|
|||
|
||||
act(() => {
|
||||
void waitFor(() => {
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -55,7 +55,6 @@ describe('PhoneRegister', () => {
|
|||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Phone}/verification-code`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { phone: fullPhoneNumber } }
|
||||
);
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
|
|
|
@ -187,13 +187,10 @@ describe('<PhonePassword>', () => {
|
|||
|
||||
act(() => {
|
||||
void waitFor(() => {
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith(
|
||||
{
|
||||
phone: 'phone',
|
||||
password: 'password',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({
|
||||
phone: 'phone',
|
||||
password: 'password',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -37,7 +37,7 @@ describe('<UsernameRegister />', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
|
||||
expect(addProfile).toBeCalledWith({ username: 'username' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -193,13 +193,10 @@ describe('<UsernameSignIn>', () => {
|
|||
|
||||
act(() => {
|
||||
void waitFor(() => {
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith(
|
||||
{
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(signInWithPasswordIdentifier).toBeCalledWith({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -14,7 +14,6 @@ const useContinueSignInWithPassword = <T extends SignInIdentifier.Email | SignIn
|
|||
navigate(
|
||||
{
|
||||
pathname: `/${UserFlow.signIn}/${method}/password`,
|
||||
search: location.search,
|
||||
},
|
||||
{ state: payload }
|
||||
);
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
@ -45,7 +45,6 @@ const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdenti
|
|||
navigate(
|
||||
{
|
||||
pathname: `/${flow}/${method}/verification-code`,
|
||||
search: location.search,
|
||||
},
|
||||
{
|
||||
state: payload,
|
||||
|
|
|
@ -52,7 +52,7 @@ describe('SetPassword', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ password: '123456' }, undefined);
|
||||
expect(addProfile).toBeCalledWith({ password: '123456' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -46,7 +46,7 @@ describe('SetPassword', () => {
|
|||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addProfile).toBeCalledWith({ username: 'username' }, undefined);
|
||||
expect(addProfile).toBeCalledWith({ username: 'username' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,7 +33,6 @@ const SignIn = () => {
|
|||
methods={otherMethods}
|
||||
template="sign_in_with"
|
||||
flow={UserFlow.signIn}
|
||||
search={location.search}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -13,7 +13,6 @@ export enum UserFlow {
|
|||
}
|
||||
|
||||
export enum SearchParameters {
|
||||
bindWithSocial = 'bind_with',
|
||||
nativeCallbackLink = 'native_callback',
|
||||
redirectTo = 'redirect_to',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue