mirror of
https://github.com/logto-io/logto.git
synced 2025-02-10 21:58:23 -05:00
feat(core): continue sign in with social (#2281)
This commit is contained in:
parent
cb46ad9fd6
commit
f419fc8279
15 changed files with 387 additions and 62 deletions
|
@ -6,10 +6,13 @@ import { createRequester } from '@/utils/test-utils';
|
|||
|
||||
import continueRoutes, { continueRoute } from './continue';
|
||||
|
||||
const getVerificationStorageFromInteraction = jest.fn();
|
||||
|
||||
const checkRequiredProfile = jest.fn();
|
||||
jest.mock('./utils', () => ({
|
||||
...jest.requireActual('./utils'),
|
||||
checkRequiredProfile: () => checkRequiredProfile(),
|
||||
getVerificationStorageFromInteraction: () => getVerificationStorageFromInteraction(),
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/sign-in-experience', () => ({
|
||||
|
@ -18,10 +21,16 @@ jest.mock('@/queries/sign-in-experience', () => ({
|
|||
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
const findUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
const hasUser = jest.fn();
|
||||
const hasUserWithPhone = jest.fn();
|
||||
const hasUserWithEmail = jest.fn();
|
||||
|
||||
jest.mock('@/queries/user', () => ({
|
||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
||||
findUserById: async () => findUserById(),
|
||||
hasUser: async () => hasUser(),
|
||||
hasUserWithPhone: async () => hasUserWithPhone(),
|
||||
hasUserWithEmail: async () => hasUserWithEmail(),
|
||||
}));
|
||||
|
||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
||||
|
@ -82,32 +91,138 @@ describe('session -> continueRoutes', () => {
|
|||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on empty continue sign in storage', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {},
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/password`).send({
|
||||
password: 'password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
it('throws on expired continue sign in storage', async () => {
|
||||
describe('POST /session/sign-in/continue/username', () => {
|
||||
it('updates user username, checks required profile, and sign in', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {
|
||||
continueSignIn: {
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs().subtract(1, 'second').toISOString(),
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/password`).send({
|
||||
password: 'password',
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
username: null,
|
||||
});
|
||||
expect(response.statusCode).toEqual(401);
|
||||
const response = await sessionRequest.post(`${continueRoute}/username`).send({
|
||||
username: 'username',
|
||||
});
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
||||
expect(hasUser).toHaveBeenCalled();
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/continue/email', () => {
|
||||
beforeEach(() => {
|
||||
getVerificationStorageFromInteraction.mockResolvedValueOnce({ email: 'email' });
|
||||
});
|
||||
|
||||
it('updates user email, checks required profile, and sign in', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {
|
||||
continueSignIn: {
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
primaryEmail: null,
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/email`).send();
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
||||
expect(hasUser).toHaveBeenCalled();
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/continue/sms', () => {
|
||||
it('updates user phone, checks required profile, and sign in', async () => {
|
||||
getVerificationStorageFromInteraction.mockResolvedValueOnce({ phone: 'phone' });
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {
|
||||
continueSignIn: {
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
findUserById.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
primaryPhone: null,
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/sms`).send();
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
||||
expect(hasUserWithPhone).toHaveBeenCalled();
|
||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('general invalid cases', () => {
|
||||
test.each(['password', 'username', 'email', 'sms'])(
|
||||
'throws on empty continue sign in storage',
|
||||
async (route) => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {},
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/${route}`).send({
|
||||
password: 'password',
|
||||
username: 'username',
|
||||
});
|
||||
expect(response.statusCode).toEqual(401);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(['password', 'username', 'email', 'sms'])(
|
||||
'throws on expired continue sign in storage',
|
||||
async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
result: {
|
||||
continueSignIn: {
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs().subtract(1, 'second').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${continueRoute}/password`).send({
|
||||
password: 'password',
|
||||
});
|
||||
expect(response.statusCode).toEqual(401);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { passwordRegEx } from '@logto/core-kit';
|
||||
import { passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
|
@ -7,11 +7,23 @@ import { assignInteractionResults } from '@/lib/session';
|
|||
import { encryptUserPassword } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
import { findUserById, updateUserById } from '@/queries/user';
|
||||
import {
|
||||
findUserById,
|
||||
hasUser,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
updateUserById,
|
||||
} from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import type { AnonymousRouter } from '../types';
|
||||
import { checkRequiredProfile, getContinueSignInResult, getRoutePrefix } from './utils';
|
||||
import { emailSessionResultGuard, smsSessionResultGuard } from './types';
|
||||
import {
|
||||
checkRequiredProfile,
|
||||
getContinueSignInResult,
|
||||
getRoutePrefix,
|
||||
getVerificationStorageFromInteraction,
|
||||
} from './utils';
|
||||
|
||||
export const continueRoute = getRoutePrefix('sign-in', 'continue');
|
||||
|
||||
|
@ -48,4 +60,110 @@ export default function continueRoutes<T extends AnonymousRouter>(router: T, pro
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${continueRoute}/username`,
|
||||
koaGuard({
|
||||
body: object({
|
||||
username: string().regex(usernameRegEx),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { username } = ctx.guard.body;
|
||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
||||
const user = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
!user.username,
|
||||
new RequestError({
|
||||
code: 'user.username_exists',
|
||||
})
|
||||
);
|
||||
|
||||
assertThat(
|
||||
!(await hasUser(username)),
|
||||
new RequestError({
|
||||
code: 'user.username_exists_register',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedUser = await updateUserById(userId, {
|
||||
username,
|
||||
});
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(`${continueRoute}/email`, async (ctx, next) => {
|
||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
||||
const { email } = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
emailSessionResultGuard
|
||||
);
|
||||
const user = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
!user.primaryEmail,
|
||||
new RequestError({
|
||||
code: 'user.email_exists',
|
||||
})
|
||||
);
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
new RequestError({
|
||||
code: 'user.email_exists_register',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedUser = await updateUserById(userId, {
|
||||
primaryEmail: email,
|
||||
});
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.post(`${continueRoute}/sms`, async (ctx, next) => {
|
||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
||||
const { phone } = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
smsSessionResultGuard
|
||||
);
|
||||
const user = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
!user.primaryPhone,
|
||||
new RequestError({
|
||||
code: 'user.sms_exists',
|
||||
})
|
||||
);
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
new RequestError({
|
||||
code: 'user.phone_exists_register',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedUser = await updateUserById(userId, {
|
||||
primaryPhone: phone,
|
||||
});
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
||||
|
||||
return next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,10 +12,10 @@ import { verificationTimeout } from './consts';
|
|||
import * as passwordlessActions from './middleware/passwordless-action';
|
||||
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless';
|
||||
|
||||
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'foo' }));
|
||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
||||
const findUserByEmail = jest.fn(async (): Promise<User> => mockUser);
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'foo' }));
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
const findDefaultSignInExperience = jest.fn(async () => ({
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
|
@ -33,7 +33,7 @@ jest.mock('@/lib/user', () => ({
|
|||
|
||||
jest.mock('@/queries/user', () => ({
|
||||
findUserById: async () => findUserById(),
|
||||
findUserByPhone: async () => ({ id: 'foo' }),
|
||||
findUserByPhone: async () => mockUser,
|
||||
findUserByEmail: async () => findUserByEmail(),
|
||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
||||
hasUser: async (username: string) => username === 'username1',
|
||||
|
@ -250,7 +250,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
userId: 'foo',
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
|
@ -346,7 +346,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
userId: 'foo',
|
||||
userId: mockUser.id,
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
|
@ -391,7 +391,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'foo' },
|
||||
login: { accountId: mockUser.id },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -413,7 +413,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'foo' },
|
||||
login: { accountId: mockUser.id },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -554,7 +554,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'foo' },
|
||||
login: { accountId: mockUser.id },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -578,7 +578,7 @@ describe('session -> passwordlessRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'foo' },
|
||||
login: { accountId: mockUser.id },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import type { User } from '@logto/schemas';
|
||||
import { SignUpIdentifier } from '@logto/schemas';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockLogtoConnectorList, mockUser } from '@/__mocks__';
|
||||
import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '@/__mocks__';
|
||||
import { getLogtoConnectorById } from '@/connectors';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
@ -24,7 +25,7 @@ jest.mock('@/lib/social', () => ({
|
|||
}
|
||||
|
||||
if (data.code === '123456') {
|
||||
return { id: 'id' };
|
||||
return { id: mockUser.id };
|
||||
}
|
||||
|
||||
// This mocks the case that can not get userInfo with access token and auth code
|
||||
|
@ -32,16 +33,16 @@ jest.mock('@/lib/social', () => ({
|
|||
throw new Error(' ');
|
||||
},
|
||||
}));
|
||||
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
||||
|
||||
jest.mock('@/queries/user', () => ({
|
||||
findUserById: async () => findUserById(),
|
||||
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
|
||||
findUserByIdentity: async () => mockUser,
|
||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
||||
hasUserWithIdentity: async (target: string, userId: string) =>
|
||||
target === 'connectorTarget' && userId === 'id',
|
||||
target === 'connectorTarget' && userId === mockUser.id,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/user', () => ({
|
||||
|
@ -49,6 +50,16 @@ jest.mock('@/lib/user', () => ({
|
|||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
||||
}));
|
||||
|
||||
jest.mock('@/queries/sign-in-experience', () => ({
|
||||
findDefaultSignInExperience: async () => ({
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
...mockSignInExperience.signUp,
|
||||
identifier: SignUpIdentifier.None,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
||||
const database = {
|
||||
enabled: connectorId === 'social_enabled',
|
||||
|
@ -170,9 +181,6 @@ describe('session -> socialRoutes', () => {
|
|||
|
||||
describe('POST /session/sign-in/social/auth', () => {
|
||||
const connectorTarget = 'connectorTarget';
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throw error when auth code is wrong', async () => {
|
||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
||||
|
@ -213,16 +221,19 @@ describe('session -> socialRoutes', () => {
|
|||
},
|
||||
});
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
'id',
|
||||
mockUser.id,
|
||||
expect.objectContaining({
|
||||
identities: { connectorTarget: { userId: 'id', details: { id: 'id' } } },
|
||||
identities: {
|
||||
...mockUser.identities,
|
||||
connectorTarget: { userId: mockUser.id, details: { id: mockUser.id } },
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: 'id' } }),
|
||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
@ -244,7 +255,7 @@ describe('session -> socialRoutes', () => {
|
|||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: 'id' } },
|
||||
socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: mockUser.id } },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
|
@ -259,9 +270,6 @@ describe('session -> socialRoutes', () => {
|
|||
metadata: { target: 'connectorTarget' },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('throw if session is not authorized', async () => {
|
||||
await expect(
|
||||
sessionRequest
|
||||
|
@ -321,9 +329,6 @@ describe('session -> socialRoutes', () => {
|
|||
metadata: { target: 'connectorTarget' },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('register with social, assign result and redirect', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
|
@ -359,7 +364,7 @@ describe('session -> socialRoutes', () => {
|
|||
});
|
||||
|
||||
it('throw error if result parsing fails', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: 'id' } } });
|
||||
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: mockUser.id } } });
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}`)
|
||||
.send({ connectorId: 'connectorId' });
|
||||
|
@ -370,7 +375,7 @@ describe('session -> socialRoutes', () => {
|
|||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
login: { accountId: 'user1' },
|
||||
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'id' } },
|
||||
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: mockUser.id } },
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -387,9 +392,6 @@ describe('session -> socialRoutes', () => {
|
|||
metadata: { target: 'connectorTarget' },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
it('throw if session is not authorized', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({});
|
||||
await expect(
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from '@/lib/social';
|
||||
import { generateUserId, insertUser } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
|
||||
import {
|
||||
hasUserWithIdentity,
|
||||
findUserById,
|
||||
|
@ -24,7 +25,7 @@ import assertThat from '@/utils/assert-that';
|
|||
import { maskUserInfo } from '@/utils/format';
|
||||
|
||||
import type { AnonymousRouter } from '../types';
|
||||
import { getRoutePrefix } from './utils';
|
||||
import { checkRequiredProfile, getRoutePrefix } from './utils';
|
||||
|
||||
export const registerRoute = getRoutePrefix('register', 'social');
|
||||
export const signInRoute = getRoutePrefix('sign-in', 'social');
|
||||
|
@ -92,7 +93,8 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
);
|
||||
}
|
||||
|
||||
const { id, identities } = await findUserByIdentity(target, userInfo.id);
|
||||
const user = await findUserByIdentity(target, userInfo.id);
|
||||
const { id, identities } = user;
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
// Update social connector's user info
|
||||
|
@ -100,6 +102,9 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } },
|
||||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
|
@ -131,10 +136,13 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
const { id, identities } = relatedInfo[1];
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await updateUserById(id, {
|
||||
const user = await updateUserById(id, {
|
||||
identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } },
|
||||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
|
@ -167,7 +175,7 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
assertThat(!(await hasUserWithIdentity(target, userInfo.id)), 'user.identity_exists');
|
||||
|
||||
const id = await generateUserId();
|
||||
await insertUser({
|
||||
const user = await insertUser({
|
||||
id,
|
||||
name: userInfo.name ?? null,
|
||||
avatar: userInfo.avatar ?? null,
|
||||
|
@ -181,6 +189,8 @@ export default function socialRoutes<T extends AnonymousRouter>(router: T, provi
|
|||
});
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
const signInExperience = await findDefaultSignInExperience();
|
||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
|
|
|
@ -6,7 +6,7 @@ import type {
|
|||
SignInIdentifier,
|
||||
User,
|
||||
} from '@logto/schemas';
|
||||
import { logTypeGuard } from '@logto/schemas';
|
||||
import { SignUpIdentifier, logTypeGuard } from '@logto/schemas';
|
||||
import type { Nullable, Truthy } from '@silverhand/essentials';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Context } from 'koa';
|
||||
|
@ -121,7 +121,10 @@ export const assignContinueSignInResult = async (
|
|||
});
|
||||
};
|
||||
|
||||
export const getContinueSignInResult = async (ctx: Context, provider: Provider) => {
|
||||
export const getContinueSignInResult = async (
|
||||
ctx: Context,
|
||||
provider: Provider
|
||||
): Promise<{ userId: string }> => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
const signInResult = z
|
||||
|
@ -147,6 +150,7 @@ export const getContinueSignInResult = async (ctx: Context, provider: Provider)
|
|||
return rest;
|
||||
};
|
||||
|
||||
/* eslint-disable complexity */
|
||||
export const checkRequiredProfile = async (
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
|
@ -154,7 +158,7 @@ export const checkRequiredProfile = async (
|
|||
signInExperience: SignInExperience
|
||||
) => {
|
||||
const { signUp } = signInExperience;
|
||||
const { passwordEncrypted, id } = user;
|
||||
const { passwordEncrypted, id, username, primaryEmail, primaryPhone } = user;
|
||||
|
||||
// If check failed, save the sign in result, the user can continue after requirements are meet
|
||||
|
||||
|
@ -162,7 +166,28 @@ export const checkRequiredProfile = async (
|
|||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
||||
throw new RequestError({ code: 'user.require_password', status: 422 });
|
||||
}
|
||||
|
||||
if (signUp.identifier === SignUpIdentifier.Username && !username) {
|
||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
||||
throw new RequestError({ code: 'user.require_username', status: 422 });
|
||||
}
|
||||
|
||||
if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) {
|
||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
||||
throw new RequestError({ code: 'user.require_email', status: 422 });
|
||||
}
|
||||
|
||||
if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) {
|
||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
||||
throw new RequestError({ code: 'user.require_sms', status: 422 });
|
||||
}
|
||||
|
||||
if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) {
|
||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
||||
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
|
||||
}
|
||||
};
|
||||
/* eslint-enable complexity */
|
||||
|
||||
type SignInWithPasswordParameter = {
|
||||
identifier: SignInIdentifier;
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
import { SignUpIdentifier } from '@logto/schemas';
|
||||
|
||||
import type { StatisticsData } from '@/api';
|
||||
import { getTotalUsersCount, getNewUsersData, getActiveUsersData } from '@/api';
|
||||
import { createUserByAdmin, registerNewUser, signIn } from '@/helpers';
|
||||
import { createUserByAdmin, registerNewUser, setSignUpIdentifier, signIn } from '@/helpers';
|
||||
import { generateUsername, generatePassword } from '@/utils';
|
||||
|
||||
describe('admin console dashboard', () => {
|
||||
beforeAll(async () => {
|
||||
await setSignUpIdentifier(SignUpIdentifier.Username);
|
||||
});
|
||||
|
||||
it('should get total user count successfully', async () => {
|
||||
const { totalUserCount: originTotalUserCount } = await getTotalUsersCount();
|
||||
|
||||
|
|
|
@ -1,13 +1,18 @@
|
|||
import { SignUpIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
|
||||
import { getLogs, getLog } from '@/api';
|
||||
import { registerNewUser } from '@/helpers';
|
||||
import { registerNewUser, setSignUpIdentifier } from '@/helpers';
|
||||
import { generateUsername, generatePassword } from '@/utils';
|
||||
|
||||
describe('admin console logs', () => {
|
||||
const username = generateUsername();
|
||||
const password = generatePassword();
|
||||
|
||||
beforeAll(async () => {
|
||||
await setSignUpIdentifier(SignUpIdentifier.Username);
|
||||
});
|
||||
|
||||
it('should get logs and visit log details successfully', async () => {
|
||||
await registerNewUser(username, password);
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { SignUpIdentifier } from '@logto/schemas';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
|
@ -15,7 +16,7 @@ import {
|
|||
getUser,
|
||||
} from '@/api';
|
||||
import MockClient from '@/client';
|
||||
import { setUpConnector, createUserByAdmin } from '@/helpers';
|
||||
import { setUpConnector, createUserByAdmin, setSignUpIdentifier } from '@/helpers';
|
||||
import { generateUsername, generatePassword } from '@/utils';
|
||||
|
||||
const state = 'foo_state';
|
||||
|
@ -27,6 +28,7 @@ describe('social sign-in and register', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
await setUpConnector(mockSocialConnectorId, mockSocialConnectorConfig);
|
||||
await setSignUpIdentifier(SignUpIdentifier.None, false);
|
||||
});
|
||||
|
||||
it('register with social', async () => {
|
||||
|
|
|
@ -47,6 +47,13 @@ const errors = {
|
|||
same_password: 'Your new password can’t be the same as your current password.',
|
||||
require_password: 'You need to set a password before sign in.',
|
||||
password_exists: 'Your password has been set.',
|
||||
require_username: 'You need to set a username before sign in.',
|
||||
username_exists: 'Your username has been set.',
|
||||
require_email: 'You need to set an email before sign in.',
|
||||
email_exists: 'Your email has been set.',
|
||||
require_sms: 'You need to set a phone before sign in.',
|
||||
sms_exists: 'Your phone has been set.',
|
||||
require_email_or_sms: 'You need to set a phone or email before sign in.',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
|
||||
|
|
|
@ -48,6 +48,13 @@ const errors = {
|
|||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
|
||||
password_exists: 'Your password has been set.', // UNTRANSLATED
|
||||
require_username: 'You need to set a username before sign in.', // UNTRANSLATED
|
||||
username_exists: 'Your username has been set.', // UNTRANSLATED
|
||||
require_email: 'You need to set an email before sign in.', // UNTRANSLATED
|
||||
email_exists: 'Your email has been set.', // UNTRANSLATED
|
||||
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
|
||||
sms_exists: 'Your phone has been set.', // UNTRANSLATED
|
||||
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
|
||||
|
|
|
@ -46,6 +46,13 @@ const errors = {
|
|||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
|
||||
password_exists: 'Your password has been set.', // UNTRANSLATED
|
||||
require_username: 'You need to set a username before sign in.', // UNTRANSLATED
|
||||
username_exists: 'Your username has been set.', // UNTRANSLATED
|
||||
require_email: 'You need to set an email before sign in.', // UNTRANSLATED
|
||||
email_exists: 'Your email has been set.', // UNTRANSLATED
|
||||
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
|
||||
sms_exists: 'Your phone has been set.', // UNTRANSLATED
|
||||
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
|
||||
|
|
|
@ -46,6 +46,13 @@ const errors = {
|
|||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
|
||||
password_exists: 'Your password has been set.', // UNTRANSLATED
|
||||
require_username: 'You need to set a username before sign in.', // UNTRANSLATED
|
||||
username_exists: 'Your username has been set.', // UNTRANSLATED
|
||||
require_email: 'You need to set an email before sign in.', // UNTRANSLATED
|
||||
email_exists: 'Your email has been set.', // UNTRANSLATED
|
||||
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
|
||||
sms_exists: 'Your phone has been set.', // UNTRANSLATED
|
||||
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
|
||||
|
|
|
@ -47,6 +47,13 @@ const errors = {
|
|||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
require_password: 'You need to set a password before sign in.', // UNTRANSLATED
|
||||
password_exists: 'Your password has been set.', // UNTRANSLATED
|
||||
require_username: 'You need to set a username before sign in.', // UNTRANSLATED
|
||||
username_exists: 'Your username has been set.', // UNTRANSLATED
|
||||
require_email: 'You need to set an email before sign in.', // UNTRANSLATED
|
||||
email_exists: 'Your email has been set.', // UNTRANSLATED
|
||||
require_sms: 'You need to set a phone before sign in.', // UNTRANSLATED
|
||||
sms_exists: 'Your phone has been set.', // UNTRANSLATED
|
||||
require_email_or_sms: 'You need to set a phone or email before sign in.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
|
||||
|
|
|
@ -46,6 +46,13 @@ const errors = {
|
|||
same_password: '为确保你的账户安全,新密码不能与旧密码一致',
|
||||
require_password: '请设置密码',
|
||||
password_exists: '密码已设置过',
|
||||
require_username: '请设置用户名',
|
||||
username_exists: '用户名已设置过',
|
||||
require_email: '请绑定邮箱地址',
|
||||
email_exists: '已绑定邮箱地址',
|
||||
require_sms: '请绑定手机号码',
|
||||
sms_exists: '已绑定手机号码',
|
||||
require_email_or_sms: '请绑定邮箱地址或手机号码',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}',
|
||||
|
|
Loading…
Add table
Reference in a new issue