mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
Merge pull request #2039 from logto-io/feature/decouple-passcode-verification
refactor(core,phrases,schemas): decouple passcode verification
This commit is contained in:
commit
d38543122d
37 changed files with 1796 additions and 857 deletions
|
@ -1 +1 @@
|
|||
export const forgotPasswordVerificationTimeout = 10 * 60; // 10 mins.
|
||||
export const verificationTimeout = 10 * 60; // 10 mins.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { User } from '@logto/schemas';
|
||||
import { PasscodeType, User } from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
|
@ -6,7 +6,6 @@ import { mockPasswordEncrypted, mockUserWithPassword } from '@/__mocks__';
|
|||
import RequestError from '@/errors/RequestError';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
import { forgotPasswordVerificationTimeout } from './consts';
|
||||
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password';
|
||||
|
||||
const encryptUserPassword = jest.fn(async (password: string) => ({
|
||||
|
@ -14,7 +13,7 @@ const encryptUserPassword = jest.fn(async (password: string) => ({
|
|||
passwordEncryptionMethod: 'Argon2i',
|
||||
}));
|
||||
const findUserById = jest.fn(async (): Promise<User> => mockUserWithPassword);
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' }));
|
||||
|
||||
jest.mock('@/lib/user', () => ({
|
||||
...jest.requireActual('@/lib/user'),
|
||||
|
@ -24,16 +23,16 @@ jest.mock('@/lib/user', () => ({
|
|||
jest.mock('@/queries/user', () => ({
|
||||
...jest.requireActual('@/queries/user'),
|
||||
hasUserWithPhone: async (phone: string) => phone === '13000000000',
|
||||
findUserByPhone: async () => ({ id: 'id' }),
|
||||
findUserByPhone: async () => ({ userId: 'id' }),
|
||||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
||||
findUserByEmail: async () => ({ id: 'id' }),
|
||||
findUserByEmail: async () => ({ userId: 'id' }),
|
||||
findUserById: async () => findUserById(),
|
||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
||||
}));
|
||||
|
||||
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
|
||||
jest.mock('@/lib/passcode', () => ({
|
||||
createPasscode: async () => ({ id: 'id' }),
|
||||
createPasscode: async () => ({ userId: 'id' }),
|
||||
sendPasscode: async () => sendPasscode(),
|
||||
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
|
||||
if (code !== '1234') {
|
||||
|
@ -75,108 +74,6 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
],
|
||||
});
|
||||
|
||||
describe('POST /session/forgot-password/sms/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
it('should call sendPasscode', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/sms/send-passcode`)
|
||||
.send({ phone: '13000000000' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/forgot-password/sms/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000000', code: '1234' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
forgotPassword: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs(fakeTime)
|
||||
.add(forgotPasswordVerificationTimeout, 'second')
|
||||
.toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it('throw error if phone number does not exist', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000001', code: '1234' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000000', code: '1231' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/forgot-password/email/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
it('should call sendPasscode', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/email/send-passcode`)
|
||||
.send({ email: 'a@a.com' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/forgot-password/email/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/email/verify-passcode`)
|
||||
.send({ email: 'a@a.com', code: '1234' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
forgotPassword: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs(fakeTime)
|
||||
.add(forgotPasswordVerificationTimeout, 'second')
|
||||
.toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
it('throw error if email does not exist', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/email/verify-passcode`)
|
||||
.send({ email: 'b@a.com', code: '1234' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/sms/verify-passcode`)
|
||||
.send({ email: 'a@a.com', code: '1231' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/forgot-password/reset', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -184,7 +81,11 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
it('assign result and redirect', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -199,10 +100,13 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
it('should throw when `accountId` is missing', async () => {
|
||||
it('should throw when `id` is missing', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { expiresAt: dayjs().add(1, 'day').toISOString() },
|
||||
verification: {
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -211,10 +115,26 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
expect(response).toHaveProperty('status', 404);
|
||||
expect(updateUserById).toBeCalledTimes(0);
|
||||
});
|
||||
it('should throw when `forgotPassword.expiresAt` is not string', async () => {
|
||||
it('should throw when flow is not `forgot-password`', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: 0 },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
.post(`${forgotPasswordRoute}/reset`)
|
||||
.send({ password: mockPasswordEncrypted });
|
||||
expect(response).toHaveProperty('status', 404);
|
||||
expect(updateUserById).toBeCalledTimes(0);
|
||||
});
|
||||
it('should throw when `verification.expiresAt` is not string', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: { userId: 'id', expiresAt: 0, flow: PasscodeType.ForgotPassword },
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -226,7 +146,11 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
it('should throw when `expiresAt` is not a valid date string', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: 'invalid date string' },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: 'invalid date string',
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -238,7 +162,11 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
it('should throw when verification expires', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: dayjs().subtract(1, 'day').toISOString() },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs().subtract(1, 'day').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest
|
||||
|
@ -250,7 +178,11 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
it('should throw when new password is the same as old one', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockArgon2Verify.mockResolvedValueOnce(true);
|
||||
|
@ -263,7 +195,11 @@ describe('session -> forgotPasswordRoutes', () => {
|
|||
it('should redirect when there was no old password', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
forgotPassword: { userId: 'id', expiresAt: dayjs().add(1, 'day').toISOString() },
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
},
|
||||
});
|
||||
findUserById.mockResolvedValueOnce({
|
||||
|
|
|
@ -1,155 +1,49 @@
|
|||
import { emailRegEx, passwordRegEx, phoneRegEx } from '@logto/core-kit';
|
||||
import { PasscodeType } from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { passwordRegEx } from '@logto/core-kit';
|
||||
import { argon2Verify } from 'hash-wasm';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
|
||||
import { encryptUserPassword } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import {
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
findUserByPhone,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
updateUserById,
|
||||
} from '@/queries/user';
|
||||
import { findUserById, updateUserById } from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import { AnonymousRouter } from '../types';
|
||||
import { forgotPasswordVerificationTimeout } from './consts';
|
||||
import { getRoutePrefix } from './utils';
|
||||
import { forgotPasswordSessionResultGuard } from './types';
|
||||
import {
|
||||
clearVerificationResult,
|
||||
getRoutePrefix,
|
||||
getVerificationStorageFromInteraction,
|
||||
checkValidateExpiration,
|
||||
} from './utils';
|
||||
|
||||
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
|
||||
|
||||
const forgotPasswordVerificationGuard = z.object({
|
||||
forgotPassword: z.object({ userId: z.string(), expiresAt: z.string() }),
|
||||
});
|
||||
|
||||
export default function forgotPasswordRoutes<T extends AnonymousRouter>(
|
||||
router: T,
|
||||
provider: Provider
|
||||
) {
|
||||
router.post(
|
||||
`${forgotPasswordRoute}/sms/send-passcode`,
|
||||
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone } = ctx.guard.body;
|
||||
const type = 'ForgotPasswordSmsSendPasscode';
|
||||
ctx.log(type, { phone });
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { phone });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${forgotPasswordRoute}/sms/verify-passcode`,
|
||||
koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx), code: z.string() }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone, code } = ctx.guard.body;
|
||||
const type = 'ForgotPasswordSms';
|
||||
ctx.log(type, { phone, code });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
new RequestError({ code: 'user.phone_not_exists', status: 422 })
|
||||
);
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { phone });
|
||||
const { id } = await findUserByPhone(phone);
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await provider.interactionResult(ctx.req, ctx.res, {
|
||||
forgotPassword: {
|
||||
userId: id,
|
||||
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
});
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${forgotPasswordRoute}/email/send-passcode`,
|
||||
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email } = ctx.guard.body;
|
||||
const type = 'ForgotPasswordEmailSendPasscode';
|
||||
ctx.log(type, { email });
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.ForgotPassword, { email });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${forgotPasswordRoute}/email/verify-passcode`,
|
||||
koaGuard({ body: z.object({ email: z.string().regex(emailRegEx), code: z.string() }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email, code } = ctx.guard.body;
|
||||
const type = 'ForgotPasswordEmail';
|
||||
ctx.log(type, { email, code });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
new RequestError({ code: 'user.email_not_exists', status: 422 })
|
||||
);
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.ForgotPassword, code, { email });
|
||||
const { id } = await findUserByEmail(email);
|
||||
await provider.interactionResult(ctx.req, ctx.res, {
|
||||
forgotPassword: {
|
||||
userId: id,
|
||||
expiresAt: dayjs().add(forgotPasswordVerificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
});
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${forgotPasswordRoute}/reset`,
|
||||
koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { password } = ctx.guard.body;
|
||||
const forgotPasswordVerificationResult = forgotPasswordVerificationGuard.safeParse(result);
|
||||
|
||||
assertThat(
|
||||
forgotPasswordVerificationResult.success,
|
||||
new RequestError({ code: 'session.forgot_password_session_not_found', status: 404 })
|
||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
forgotPasswordSessionResultGuard
|
||||
);
|
||||
|
||||
const {
|
||||
forgotPassword: { userId: id, expiresAt },
|
||||
} = forgotPasswordVerificationResult.data;
|
||||
const type = 'ForgotPasswordReset';
|
||||
ctx.log(type, verificationStorage);
|
||||
|
||||
assertThat(
|
||||
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
|
||||
new RequestError({ code: 'session.forgot_password_verification_expired', status: 401 })
|
||||
);
|
||||
const { userId, expiresAt } = verificationStorage;
|
||||
|
||||
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(id);
|
||||
checkValidateExpiration(expiresAt);
|
||||
|
||||
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
!oldPasswordEncrypted ||
|
||||
|
@ -159,10 +53,10 @@ export default function forgotPasswordRoutes<T extends AnonymousRouter>(
|
|||
|
||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
||||
|
||||
const type = 'ForgotPasswordReset';
|
||||
ctx.log(type, { userId: id });
|
||||
ctx.log(type, { userId });
|
||||
|
||||
await updateUserById(id, { passwordEncrypted, passwordEncryptionMethod });
|
||||
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
|
||||
await clearVerificationResult(ctx, provider);
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
import { MiddlewareType } from 'koa';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { assignInteractionResults } from '@/lib/session';
|
||||
import { generateUserId, insertUser } from '@/lib/user';
|
||||
import { WithLogContext } from '@/middleware/koa-log';
|
||||
import {
|
||||
hasUserWithPhone,
|
||||
hasUserWithEmail,
|
||||
findUserByPhone,
|
||||
findUserByEmail,
|
||||
updateUserById,
|
||||
} from '@/queries/user';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import { smsSessionResultGuard, emailSessionResultGuard } from '../types';
|
||||
import {
|
||||
getVerificationStorageFromInteraction,
|
||||
getPasswordlessRelatedLogType,
|
||||
checkValidateExpiration,
|
||||
} from '../utils';
|
||||
|
||||
export const smsSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
||||
return async (ctx, next) => {
|
||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
smsSessionResultGuard
|
||||
);
|
||||
|
||||
const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'sms');
|
||||
ctx.log(type, verificationStorage);
|
||||
|
||||
const { phone, expiresAt } = verificationStorage;
|
||||
|
||||
checkValidateExpiration(expiresAt);
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
new RequestError({ code: 'user.phone_not_exists', status: 404 })
|
||||
);
|
||||
|
||||
const { id } = await findUserByPhone(phone);
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const emailSignInAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
||||
return async (ctx, next) => {
|
||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
emailSessionResultGuard
|
||||
);
|
||||
|
||||
const type = getPasswordlessRelatedLogType(PasscodeType.SignIn, 'email');
|
||||
ctx.log(type, verificationStorage);
|
||||
|
||||
const { email, expiresAt } = verificationStorage;
|
||||
|
||||
checkValidateExpiration(expiresAt);
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
new RequestError({ code: 'user.email_not_exists', status: 404 })
|
||||
);
|
||||
|
||||
const { id } = await findUserByEmail(email);
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const smsRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
||||
return async (ctx, next) => {
|
||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
smsSessionResultGuard
|
||||
);
|
||||
|
||||
const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'sms');
|
||||
ctx.log(type, verificationStorage);
|
||||
|
||||
const { phone, expiresAt } = verificationStorage;
|
||||
|
||||
checkValidateExpiration(expiresAt);
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
new RequestError({ code: 'user.phone_exists_register', status: 422 })
|
||||
);
|
||||
const id = await generateUserId();
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
|
||||
export const emailRegisterAction = <StateT, ContextT extends WithLogContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
||||
return async (ctx, next) => {
|
||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
||||
ctx,
|
||||
provider,
|
||||
emailSessionResultGuard
|
||||
);
|
||||
|
||||
const type = getPasswordlessRelatedLogType(PasscodeType.Register, 'email');
|
||||
ctx.log(type, verificationStorage);
|
||||
|
||||
const { email, expiresAt } = verificationStorage;
|
||||
|
||||
checkValidateExpiration(expiresAt);
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
new RequestError({ code: 'user.email_exists_register', status: 422 })
|
||||
);
|
||||
const id = await generateUserId();
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
|
@ -1,10 +1,14 @@
|
|||
import { User } from '@logto/schemas';
|
||||
/* eslint-disable max-lines */
|
||||
import { PasscodeType, User } from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockUser } from '@/__mocks__';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { createRequester } from '@/utils/test-utils';
|
||||
|
||||
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: 'id' }));
|
||||
|
@ -26,9 +30,15 @@ jest.mock('@/queries/user', () => ({
|
|||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
||||
}));
|
||||
|
||||
const smsSignInActionSpy = jest.spyOn(passwordlessActions, 'smsSignInAction');
|
||||
const emailSignInActionSpy = jest.spyOn(passwordlessActions, 'emailSignInAction');
|
||||
const smsRegisterActionSpy = jest.spyOn(passwordlessActions, 'smsRegisterAction');
|
||||
const emailRegisterActionSpy = jest.spyOn(passwordlessActions, 'emailRegisterAction');
|
||||
|
||||
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
|
||||
const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
||||
jest.mock('@/lib/passcode', () => ({
|
||||
createPasscode: async () => ({ id: 'id' }),
|
||||
createPasscode: async (..._args: unknown[]) => createPasscode(..._args),
|
||||
sendPasscode: async () => sendPasscode(),
|
||||
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
|
||||
if (code !== '1234') {
|
||||
|
@ -65,255 +75,750 @@ describe('session -> passwordlessRoutes', () => {
|
|||
],
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/sms/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
describe('POST /session/passwordless/sms/send', () => {
|
||||
beforeEach(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
it('should call sendPasscode', async () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
it('should call sendPasscode (with flow `sign-in`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/sms/send-passcode`)
|
||||
.send({ phone: '13000000000' });
|
||||
.post('/session/passwordless/sms/send')
|
||||
.send({ phone: '13000000000', flow: PasscodeType.SignIn });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
|
||||
phone: '13000000000',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
it('throw error if phone does not exist', async () => {
|
||||
it('should call sendPasscode (with flow `register`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/sms/send-passcode`)
|
||||
.send({ phone: '13000000001' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
.post('/session/passwordless/sms/send')
|
||||
.send({ phone: '13000000000', flow: PasscodeType.Register });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
|
||||
phone: '13000000000',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/sms/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
it('should call sendPasscode (with flow `forgot-password`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000000', code: '1234' });
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: 'id' } }),
|
||||
expect.anything()
|
||||
);
|
||||
.post('/session/passwordless/sms/send')
|
||||
.send({ phone: '13000000000', flow: PasscodeType.ForgotPassword });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, {
|
||||
phone: '13000000000',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
it('throw error if phone does not exist', async () => {
|
||||
it('throw when phone not given in input params', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000001', code: '1234' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000000', code: '1231' });
|
||||
.post('/session/passwordless/sms/send')
|
||||
.send({ flow: PasscodeType.Register });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/email/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
interactionDetails.mockResolvedValue({
|
||||
describe('POST /session/passwordless/email/send', () => {
|
||||
beforeEach(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
it('should call sendPasscode', async () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
it('should call sendPasscode (with flow `sign-in`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/send-passcode`)
|
||||
.send({ email: 'a@a.com' });
|
||||
.post('/session/passwordless/email/send')
|
||||
.send({ email: 'a@a.com', flow: PasscodeType.SignIn });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.SignIn, {
|
||||
email: 'a@a.com',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
it('throw error if email does not exist', async () => {
|
||||
it('should call sendPasscode (with flow `register`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/send-passcode`)
|
||||
.send({ email: 'b@a.com' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
.post('/session/passwordless/email/send')
|
||||
.send({ email: 'a@a.com', flow: PasscodeType.Register });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.Register, {
|
||||
email: 'a@a.com',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/email/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
it('should call sendPasscode (with flow `forgot-password`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/verify-passcode`)
|
||||
.send({ email: 'a@a.com', code: '1234' });
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: 'id' } }),
|
||||
expect.anything()
|
||||
);
|
||||
.post('/session/passwordless/email/send')
|
||||
.send({ email: 'a@a.com', flow: PasscodeType.ForgotPassword });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(createPasscode).toHaveBeenCalledWith('jti', PasscodeType.ForgotPassword, {
|
||||
email: 'a@a.com',
|
||||
});
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
});
|
||||
it('throw error if email does not exist', async () => {
|
||||
it('throw when email not given in input params', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/send-passcode`)
|
||||
.send({ email: 'b@a.com' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/verify-passcode`)
|
||||
.send({ email: 'a@a.com', code: '1231' });
|
||||
.post('/session/passwordless/email/send')
|
||||
.send({ flow: PasscodeType.Register });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/register/passwordless/sms/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
describe('POST /session/passwordless/sms/verify', () => {
|
||||
beforeEach(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call sendPasscode', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/send-passcode`)
|
||||
.send({ phone: '13000000001' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('throw error if phone not valid (charactors other than digits)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/send-passcode`)
|
||||
.send({ phone: '1300000000a' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
it('throw error if phone not valid (without digits)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/send-passcode`)
|
||||
.send({ phone: 'abcdefg' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
await sessionRequest
|
||||
.post('/session/passwordless/sms/verify')
|
||||
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.SignIn });
|
||||
|
||||
it('throw error if phone exists', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/send-passcode`)
|
||||
.send({ phone: '13000000000' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/register/passwordless/sms/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000001', code: '1234' });
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(insertUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user1', primaryPhone: '13000000001' })
|
||||
);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: 'user1' } }),
|
||||
expect.anything()
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
flow: PasscodeType.SignIn,
|
||||
phone: '13000000000',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Should call sign-in with sms properly
|
||||
expect(smsSignInActionSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
await sessionRequest
|
||||
.post('/session/passwordless/sms/verify')
|
||||
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.Register });
|
||||
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
flow: PasscodeType.Register,
|
||||
phone: '13000000000',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(smsRegisterActionSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `forgot-password`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
const response = await sessionRequest
|
||||
.post('/session/passwordless/sms/verify')
|
||||
.send({ phone: '13000000000', code: '1234', flow: PasscodeType.ForgotPassword });
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throw error if phone is invalid (characters other than digits)', async () => {
|
||||
it('throw 404 (with flow `forgot-password`)', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '1300000000a', code: '1234' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
.post('/session/passwordless/sms/verify')
|
||||
.send({ phone: '13000000001', code: '1234', flow: PasscodeType.ForgotPassword });
|
||||
expect(response.statusCode).toEqual(404);
|
||||
expect(interactionResult).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throw error if phone not valid (without digits)', async () => {
|
||||
it('throw when code is wrong', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/verify-passcode`)
|
||||
.send({ phone: 'abcdefg', code: '1234' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
|
||||
it('throw error if phone exists', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000000', code: '1234' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/sms/verify-passcode`)
|
||||
.send({ phone: '13000000001', code: '1231' });
|
||||
.post('/session/passwordless/sms/verify')
|
||||
.send({ phone: '13000000000', code: '1231', flow: PasscodeType.SignIn });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/register/passwordless/email/send-passcode', () => {
|
||||
beforeAll(() => {
|
||||
describe('POST /session/passwordless/email/verify', () => {
|
||||
beforeEach(() => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
jti: 'jti',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call sendPasscode', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/email/send-passcode`)
|
||||
.send({ email: 'b@a.com' });
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(sendPasscode).toHaveBeenCalled();
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('throw error if email not valid', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/email/send-passcode`)
|
||||
.send({ email: 'aaa.com' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
it('throw error if email exists', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/email/send-passcode`)
|
||||
.send({ email: 'a@a.com' });
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
await sessionRequest
|
||||
.post('/session/passwordless/email/verify')
|
||||
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.SignIn });
|
||||
|
||||
describe('POST /session/register/passwordless/email/verify-passcode', () => {
|
||||
it('assign result and redirect', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${registerRoute}/email/verify-passcode`)
|
||||
.send({ email: 'b@a.com', code: '1234' });
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toHaveProperty('redirectTo');
|
||||
expect(insertUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'user1', primaryEmail: 'b@a.com' })
|
||||
);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ login: { accountId: 'user1' } }),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
flow: PasscodeType.SignIn,
|
||||
email: 'a@a.com',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(emailSignInActionSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
await sessionRequest
|
||||
.post('/session/passwordless/email/verify')
|
||||
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.Register });
|
||||
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
flow: PasscodeType.Register,
|
||||
email: 'a@a.com',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(emailRegisterActionSpy).toBeCalled();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `forgot-password`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
|
||||
const response = await sessionRequest
|
||||
.post('/session/passwordless/email/verify')
|
||||
.send({ email: 'a@a.com', code: '1234', flow: PasscodeType.ForgotPassword });
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
verification: {
|
||||
userId: 'id',
|
||||
expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(),
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throw 404 (with flow `forgot-password`)', async () => {
|
||||
const fakeTime = new Date();
|
||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
||||
const response = await sessionRequest
|
||||
.post('/session/passwordless/email/verify')
|
||||
.send({ email: 'b@a.com', code: '1234', flow: PasscodeType.ForgotPassword });
|
||||
expect(response.statusCode).toEqual(404);
|
||||
expect(interactionResult).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throw when code is wrong', async () => {
|
||||
const response = await sessionRequest
|
||||
.post('/session/passwordless/email/verify')
|
||||
.send({ email: 'a@a.com', code: '1231', flow: PasscodeType.SignIn });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/sms', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'id' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throw error if email not valid', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/send-passcode`)
|
||||
.send({ email: 'aaa.com' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'id' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throw error if email exist', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/send-passcode`)
|
||||
.send({ email: 'b@a.com' });
|
||||
it('throw when verification session invalid', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when flow is not `sign-in` and `register`', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when expiresAt is not valid ISO date string', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: 'invalid date string',
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
it('throw when validation expired', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().subtract(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
|
||||
it('throw when phone not exist', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'XX@foo',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it("throw when phone not exist as user's primaryPhone", async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000001',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/sign-in/passwordless/email', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'a@a.com',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'id' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'a@a.com',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'id' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throw when verification session invalid', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'a@a.com',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when flow is not `sign-in` and `register`', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'a@a.com',
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when email not exist', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it("throw when email not exist as user's primaryEmail", async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'b@a.com',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session/register/passwordless/sms', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000001',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'user1' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000001',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'user1' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throw when verification session invalid', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000001',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when flow is not `register` and `sign-in`', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000001',
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when phone not exist', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it("throw when phone already exist as user's primaryPhone", async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
phone: '13000000000',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
it('throw error if verifyPasscode failed', async () => {
|
||||
const response = await sessionRequest
|
||||
.post(`${signInRoute}/email/verify-passcode`)
|
||||
.send({ email: 'a@a.com', code: '1231' });
|
||||
expect(response.statusCode).toEqual(400);
|
||||
describe('POST /session/register/passwordless/email', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `register`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'b@a.com',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'user1' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'b@a.com',
|
||||
flow: PasscodeType.SignIn,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(interactionResult).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
login: { accountId: 'user1' },
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throw when verification session invalid', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'b@a.com',
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when flow is not `register` and `sign-in`', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'b@a.com',
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it('throw when email not exist', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(404);
|
||||
});
|
||||
|
||||
it("throw when email already exist as user's primaryEmail", async () => {
|
||||
interactionDetails.mockResolvedValueOnce({
|
||||
result: {
|
||||
verification: {
|
||||
email: 'a@a.com',
|
||||
flow: PasscodeType.Register,
|
||||
expiresAt: dayjs().add(1, 'day').toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -5,20 +5,24 @@ import { object, string } from 'zod';
|
|||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
|
||||
import { assignInteractionResults } from '@/lib/session';
|
||||
import { generateUserId, insertUser } from '@/lib/user';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import {
|
||||
updateUserById,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
findUserByEmail,
|
||||
findUserByPhone,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
} from '@/queries/user';
|
||||
import { passcodeTypeGuard } from '@/routes/session/types';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import { AnonymousRouter } from '../types';
|
||||
import { getRoutePrefix } from './utils';
|
||||
import {
|
||||
smsSignInAction,
|
||||
emailSignInAction,
|
||||
smsRegisterAction,
|
||||
emailRegisterAction,
|
||||
} from './middleware/passwordless-action';
|
||||
import { assignVerificationResult, getPasswordlessRelatedLogType, getRoutePrefix } from './utils';
|
||||
|
||||
export const registerRoute = getRoutePrefix('register', 'passwordless');
|
||||
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
|
||||
|
@ -28,20 +32,23 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
|
|||
provider: Provider
|
||||
) {
|
||||
router.post(
|
||||
`${signInRoute}/sms/send-passcode`,
|
||||
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
|
||||
'/session/passwordless/sms/send',
|
||||
koaGuard({
|
||||
body: object({
|
||||
phone: string().regex(phoneRegEx),
|
||||
flow: passcodeTypeGuard,
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone } = ctx.guard.body;
|
||||
const type = 'SignInSmsSendPasscode';
|
||||
const {
|
||||
body: { phone, flow },
|
||||
} = ctx.guard;
|
||||
|
||||
const type = getPasswordlessRelatedLogType(flow, 'sms', 'send');
|
||||
ctx.log(type, { phone });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
new RequestError({ code: 'user.phone_not_exists', status: 422 })
|
||||
);
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.SignIn, { phone });
|
||||
const passcode = await createPasscode(jti, flow, { phone });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
|
@ -51,45 +58,23 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
|
|||
);
|
||||
|
||||
router.post(
|
||||
`${signInRoute}/sms/verify-passcode`,
|
||||
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
|
||||
'/session/passwordless/email/send',
|
||||
koaGuard({
|
||||
body: object({
|
||||
email: string().regex(emailRegEx),
|
||||
flow: passcodeTypeGuard,
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone, code } = ctx.guard.body;
|
||||
const type = 'SignInSms';
|
||||
ctx.log(type, { phone, code });
|
||||
const {
|
||||
body: { email, flow },
|
||||
} = ctx.guard;
|
||||
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
new RequestError({ code: 'user.phone_not_exists', status: 422 })
|
||||
);
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.SignIn, code, { phone });
|
||||
const { id } = await findUserByPhone(phone);
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${signInRoute}/email/send-passcode`,
|
||||
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email } = ctx.guard.body;
|
||||
const type = 'SignInEmailSendPasscode';
|
||||
const type = getPasswordlessRelatedLogType(flow, 'email', 'send');
|
||||
ctx.log(type, { email });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
new RequestError({ code: 'user.email_not_exists', status: 422 })
|
||||
);
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.SignIn, { email });
|
||||
const passcode = await createPasscode(jti, flow, { email });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
|
@ -99,123 +84,98 @@ export default function passwordlessRoutes<T extends AnonymousRouter>(
|
|||
);
|
||||
|
||||
router.post(
|
||||
`${signInRoute}/email/verify-passcode`,
|
||||
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
|
||||
'/session/passwordless/sms/verify',
|
||||
koaGuard({
|
||||
body: object({
|
||||
phone: string().regex(phoneRegEx),
|
||||
code: string(),
|
||||
flow: passcodeTypeGuard,
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email, code } = ctx.guard.body;
|
||||
const type = 'SignInEmail';
|
||||
ctx.log(type, { email, code });
|
||||
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
new RequestError({ code: 'user.email_not_exists', status: 422 })
|
||||
);
|
||||
const {
|
||||
body: { phone, code, flow },
|
||||
} = ctx.guard;
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.SignIn, code, { email });
|
||||
const { id } = await findUserByEmail(email);
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${registerRoute}/sms/send-passcode`,
|
||||
koaGuard({ body: object({ phone: string().regex(phoneRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone } = ctx.guard.body;
|
||||
const type = 'RegisterSmsSendPasscode';
|
||||
const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify');
|
||||
ctx.log(type, { phone });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
new RequestError({ code: 'user.phone_exists_register', status: 422 })
|
||||
);
|
||||
await verifyPasscode(jti, flow, code, { phone });
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.Register, { phone });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
if (flow === PasscodeType.ForgotPassword) {
|
||||
assertThat(
|
||||
await hasUserWithPhone(phone),
|
||||
new RequestError({ code: 'user.phone_not_exists', status: 404 })
|
||||
);
|
||||
|
||||
return next();
|
||||
const { id } = await findUserByPhone(phone);
|
||||
await assignVerificationResult(ctx, provider, { flow, userId: id });
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
await assignVerificationResult(ctx, provider, { flow, phone });
|
||||
|
||||
if (flow === PasscodeType.SignIn) {
|
||||
return smsSignInAction(provider)(ctx, next);
|
||||
}
|
||||
|
||||
return smsRegisterAction(provider)(ctx, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${registerRoute}/sms/verify-passcode`,
|
||||
koaGuard({ body: object({ phone: string().regex(phoneRegEx), code: string() }) }),
|
||||
'/session/passwordless/email/verify',
|
||||
koaGuard({
|
||||
body: object({
|
||||
email: string().regex(emailRegEx),
|
||||
code: string(),
|
||||
flow: passcodeTypeGuard,
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { phone, code } = ctx.guard.body;
|
||||
const type = 'RegisterSms';
|
||||
ctx.log(type, { phone, code });
|
||||
const {
|
||||
body: { email, code, flow },
|
||||
} = ctx.guard;
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithPhone(phone)),
|
||||
new RequestError({ code: 'user.phone_exists_register', status: 422 })
|
||||
);
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.Register, code, { phone });
|
||||
const id = await generateUserId();
|
||||
ctx.log(type, { userId: id });
|
||||
|
||||
await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${registerRoute}/email/send-passcode`,
|
||||
koaGuard({ body: object({ email: string().regex(emailRegEx) }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email } = ctx.guard.body;
|
||||
const type = 'RegisterEmailSendPasscode';
|
||||
const type = getPasswordlessRelatedLogType(flow, 'email', 'verify');
|
||||
ctx.log(type, { email });
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
new RequestError({ code: 'user.email_exists_register', status: 422 })
|
||||
);
|
||||
await verifyPasscode(jti, flow, code, { email });
|
||||
|
||||
const passcode = await createPasscode(jti, PasscodeType.Register, { email });
|
||||
const { dbEntry } = await sendPasscode(passcode);
|
||||
ctx.log(type, { connectorId: dbEntry.id });
|
||||
ctx.status = 204;
|
||||
if (flow === PasscodeType.ForgotPassword) {
|
||||
assertThat(
|
||||
await hasUserWithEmail(email),
|
||||
new RequestError({ code: 'user.email_not_exists', status: 404 })
|
||||
);
|
||||
|
||||
return next();
|
||||
const { id } = await findUserByEmail(email);
|
||||
|
||||
await assignVerificationResult(ctx, provider, { flow, userId: id });
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
await assignVerificationResult(ctx, provider, { flow, email });
|
||||
|
||||
if (flow === PasscodeType.SignIn) {
|
||||
return emailSignInAction(provider)(ctx, next);
|
||||
}
|
||||
|
||||
return emailRegisterAction(provider)(ctx, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${registerRoute}/email/verify-passcode`,
|
||||
koaGuard({ body: object({ email: string().regex(emailRegEx), code: string() }) }),
|
||||
async (ctx, next) => {
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { email, code } = ctx.guard.body;
|
||||
const type = 'RegisterEmail';
|
||||
ctx.log(type, { email, code });
|
||||
router.post(`${signInRoute}/sms`, smsSignInAction(provider));
|
||||
|
||||
assertThat(
|
||||
!(await hasUserWithEmail(email)),
|
||||
new RequestError({ code: 'user.email_exists_register', status: 422 })
|
||||
);
|
||||
router.post(`${signInRoute}/email`, emailSignInAction(provider));
|
||||
|
||||
await verifyPasscode(jti, PasscodeType.Register, code, { email });
|
||||
const id = await generateUserId();
|
||||
ctx.log(type, { userId: id });
|
||||
router.post(`${registerRoute}/sms`, smsRegisterAction(provider));
|
||||
|
||||
await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
router.post(`${registerRoute}/email`, emailRegisterAction(provider));
|
||||
}
|
||||
|
|
53
packages/core/src/routes/session/types.ts
Normal file
53
packages/core/src/routes/session/types.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const passcodeTypeGuard = z.nativeEnum(PasscodeType);
|
||||
|
||||
export const methodGuard = z.enum(['email', 'sms']);
|
||||
|
||||
export type Method = z.infer<typeof methodGuard>;
|
||||
|
||||
export const operationGuard = z.enum(['send', 'verify']);
|
||||
|
||||
export type Operation = z.infer<typeof operationGuard>;
|
||||
|
||||
const smsSessionStorageGuard = z.object({
|
||||
flow: z.literal(PasscodeType.SignIn).or(z.literal(PasscodeType.Register)),
|
||||
expiresAt: z.string(),
|
||||
phone: z.string(),
|
||||
});
|
||||
|
||||
export type SmsSessionStorage = z.infer<typeof smsSessionStorageGuard>;
|
||||
|
||||
export const smsSessionResultGuard = z.object({ verification: smsSessionStorageGuard });
|
||||
|
||||
const emailSessionStorageGuard = z.object({
|
||||
flow: z.literal(PasscodeType.SignIn).or(z.literal(PasscodeType.Register)),
|
||||
expiresAt: z.string(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type EmailSessionStorage = z.infer<typeof emailSessionStorageGuard>;
|
||||
|
||||
export const emailSessionResultGuard = z.object({
|
||||
verification: emailSessionStorageGuard,
|
||||
});
|
||||
|
||||
const forgotPasswordSessionStorageGuard = z.object({
|
||||
flow: z.literal(PasscodeType.ForgotPassword),
|
||||
expiresAt: z.string(),
|
||||
userId: z.string(),
|
||||
});
|
||||
|
||||
export type ForgotPasswordSessionStorage = z.infer<typeof forgotPasswordSessionStorageGuard>;
|
||||
|
||||
export const forgotPasswordSessionResultGuard = z.object({
|
||||
verification: forgotPasswordSessionStorageGuard,
|
||||
});
|
||||
|
||||
export type VerificationStorage =
|
||||
| SmsSessionStorage
|
||||
| EmailSessionStorage
|
||||
| ForgotPasswordSessionStorage;
|
||||
|
||||
export type VerificationResult<T = VerificationStorage> = { verification: T };
|
|
@ -1,4 +1,15 @@
|
|||
import { logTypeGuard, LogType, PasscodeType } from '@logto/schemas';
|
||||
import { Truthy } from '@silverhand/essentials';
|
||||
import dayjs from 'dayjs';
|
||||
import { Context } from 'koa';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import { z, ZodType } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
|
||||
import { verificationTimeout } from './consts';
|
||||
import { Method, Operation, VerificationResult, VerificationStorage } from './types';
|
||||
|
||||
export const getRoutePrefix = (
|
||||
type: 'sign-in' | 'register' | 'forgot-password',
|
||||
|
@ -9,3 +20,75 @@ export const getRoutePrefix = (
|
|||
.map((value) => '/' + value)
|
||||
.join('');
|
||||
};
|
||||
|
||||
export const getPasswordlessRelatedLogType = (
|
||||
flow: PasscodeType,
|
||||
method: Method,
|
||||
operation?: Operation
|
||||
): LogType => {
|
||||
const body = method === 'email' ? 'Email' : 'Sms';
|
||||
const suffix = operation === 'send' ? 'SendPasscode' : '';
|
||||
|
||||
const result = logTypeGuard.safeParse(flow + body + suffix);
|
||||
assertThat(result.success, new RequestError('log.invalid_type'));
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const getVerificationStorageFromInteraction = async <T = VerificationStorage>(
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
resultGuard: ZodType<VerificationResult<T>>
|
||||
): Promise<T> => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
const verificationResult = resultGuard.safeParse(result);
|
||||
|
||||
if (!verificationResult.success) {
|
||||
throw new RequestError(
|
||||
{
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
},
|
||||
verificationResult.error
|
||||
);
|
||||
}
|
||||
|
||||
return verificationResult.data.verification;
|
||||
};
|
||||
|
||||
export const checkValidateExpiration = (expiresAt: string) => {
|
||||
assertThat(
|
||||
dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()),
|
||||
new RequestError({ code: 'session.verification_expired', status: 401 })
|
||||
);
|
||||
};
|
||||
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
||||
|
||||
export const assignVerificationResult = async (
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
verificationData: DistributiveOmit<VerificationStorage, 'expiresAt'>
|
||||
) => {
|
||||
const verification: VerificationStorage = {
|
||||
...verificationData,
|
||||
expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(),
|
||||
};
|
||||
|
||||
await provider.interactionResult(ctx.req, ctx.res, {
|
||||
verification,
|
||||
});
|
||||
};
|
||||
|
||||
export const clearVerificationResult = async (ctx: Context, provider: Provider) => {
|
||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
const verificationGuard = z.object({ verification: z.unknown() });
|
||||
const verificationGuardResult = verificationGuard.safeParse(result);
|
||||
|
||||
if (result && verificationGuardResult.success) {
|
||||
const { verification, ...rest } = result;
|
||||
await provider.interactionResult(ctx.req, ctx.res, rest);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
type RedirectResponse = {
|
||||
|
@ -51,12 +53,13 @@ export const consent = async (interactionCookie: string) =>
|
|||
.json<RedirectResponse>();
|
||||
|
||||
export const sendRegisterUserWithEmailPasscode = (email: string, interactionCookie: string) =>
|
||||
api.post('session/register/passwordless/email/send-passcode', {
|
||||
api.post('session/passwordless/email/send', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -66,24 +69,35 @@ export const verifyRegisterUserWithEmailPasscode = (
|
|||
interactionCookie: string
|
||||
) =>
|
||||
api
|
||||
.post('session/register/passwordless/email/verify-passcode', {
|
||||
.post('session/passwordless/email/verify', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const checkVerificationSessionAndRegisterWithEmail = (interactionCookie: string) =>
|
||||
api
|
||||
.post('session/register/passwordless/email', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const sendSignInUserWithEmailPasscode = (email: string, interactionCookie: string) =>
|
||||
api.post('session/sign-in/passwordless/email/send-passcode', {
|
||||
api.post('session/passwordless/email/send', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -93,24 +107,35 @@ export const verifySignInUserWithEmailPasscode = (
|
|||
interactionCookie: string
|
||||
) =>
|
||||
api
|
||||
.post('session/sign-in/passwordless/email/verify-passcode', {
|
||||
.post('session/passwordless/email/verify', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const checkVerificationSessionAndSignInWithEmail = (interactionCookie: string) =>
|
||||
api
|
||||
.post('session/sign-in/passwordless/email', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const sendRegisterUserWithSmsPasscode = (phone: string, interactionCookie: string) =>
|
||||
api.post('session/register/passwordless/sms/send-passcode', {
|
||||
api.post('session/passwordless/sms/send', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -120,24 +145,35 @@ export const verifyRegisterUserWithSmsPasscode = (
|
|||
interactionCookie: string
|
||||
) =>
|
||||
api
|
||||
.post('session/register/passwordless/sms/verify-passcode', {
|
||||
.post('session/passwordless/sms/verify', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const checkVerificationSessionAndRegisterWithSms = (interactionCookie: string) =>
|
||||
api
|
||||
.post('session/register/passwordless/sms', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const sendSignInUserWithSmsPasscode = (phone: string, interactionCookie: string) =>
|
||||
api.post('session/sign-in/passwordless/sms/send-passcode', {
|
||||
api.post('session/passwordless/sms/send', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -147,13 +183,23 @@ export const verifySignInUserWithSmsPasscode = (
|
|||
interactionCookie: string
|
||||
) =>
|
||||
api
|
||||
.post('session/sign-in/passwordless/sms/verify-passcode', {
|
||||
.post('session/passwordless/sms/verify', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
||||
export const checkVerificationSessionAndSignInWithSms = (interactionCookie: string) =>
|
||||
api
|
||||
.post('session/sign-in/passwordless/sms', {
|
||||
headers: {
|
||||
cookie: interactionCookie,
|
||||
},
|
||||
})
|
||||
.json<RedirectResponse>();
|
||||
|
|
|
@ -48,6 +48,7 @@ const translation = {
|
|||
'The account with {{type}} {{value}} already exists, would you like to sign in?',
|
||||
sign_in_id_does_not_exists:
|
||||
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
|
||||
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.',
|
||||
bind_account_title: 'Link account',
|
||||
social_create_account: 'No account? You can create a new account and link.',
|
||||
social_bind_account: 'Already have an account? Sign in to link it with your social identity.',
|
||||
|
|
|
@ -50,6 +50,7 @@ const translation = {
|
|||
'Le compte avec {{type}} {{value}} existe déjà, voulez-vous vous connecter ?',
|
||||
sign_in_id_does_not_exists:
|
||||
"Le compte avec {{type}} {{value}} n'existe pas, voulez-vous créer un nouveau compte ?",
|
||||
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
|
||||
bind_account_title: 'Lier le compte',
|
||||
social_create_account: 'Pas de compte ? Vous pouvez créer un nouveau compte et un lien.',
|
||||
social_bind_account:
|
||||
|
|
|
@ -48,6 +48,7 @@ const translation = {
|
|||
continue_with: '계속하기',
|
||||
create_account_id_exists: '{{type}} {{value}} 계정이 이미 존재해요. 로그인하시겠어요?',
|
||||
sign_in_id_does_not_exists: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?',
|
||||
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
|
||||
bind_account_title: '계정 연동',
|
||||
social_create_account: '계정이 없으신가요? 새로운 계정을 만들고 연동해보세요.',
|
||||
social_bind_account: '계정이 이미 있으신가요? 로그인하여 다른 계정과 연동해보세요.',
|
||||
|
|
|
@ -48,6 +48,7 @@ const translation = {
|
|||
continue_with: 'Continuar com',
|
||||
create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de fazer login?',
|
||||
sign_in_id_does_not_exists: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?',
|
||||
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
|
||||
bind_account_title: 'Agregar conta',
|
||||
social_create_account: 'Sem conta? Pode criar uma nova e agregar.',
|
||||
social_bind_account: 'Já tem uma conta? Faça login para agregar a sua identidade social.',
|
||||
|
|
|
@ -49,6 +49,7 @@ const translation = {
|
|||
create_account_id_exists: '{{type}} {{value}} ile hesap mevcut, giriş yapmak ister misiniz?',
|
||||
sign_in_id_does_not_exists:
|
||||
'{{type}} {{value}} ile hesap mevcut değil, yeni bir hesap oluşturmak ister misiniz?',
|
||||
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
|
||||
bind_account_title: 'Hesap bağla',
|
||||
social_create_account: 'Hesabınız yok mu? Yeni bir hesap ve bağlantı oluşturabilirsiniz.',
|
||||
social_bind_account: 'Hesabınız zaten var mı? Hesabınıza bağlanmak için giriş yapınız.',
|
||||
|
|
|
@ -48,6 +48,7 @@ const translation = {
|
|||
continue_with: '通过以下方式继续',
|
||||
create_account_id_exists: '{{ type }}为 {{ value }} 的帐号已存在,你要登录吗?',
|
||||
sign_in_id_does_not_exists: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?',
|
||||
forgot_password_id_does_not_exits: '{{ type }}为 {{ value }} 的帐号不存在。',
|
||||
bind_account_title: '绑定帐号',
|
||||
social_create_account: '没有帐号?你可以创建一个帐号并绑定。',
|
||||
social_bind_account: '已有帐号?登录以绑定社交身份。',
|
||||
|
|
|
@ -42,7 +42,7 @@ const errors = {
|
|||
identity_exists: 'The social account has been registered.',
|
||||
invalid_role_names: 'role names ({{roleNames}}) are not valid',
|
||||
cannot_delete_self: 'You cannot delete yourself.',
|
||||
same_password: 'Your new password can not be the same as current password.',
|
||||
same_password: 'Your new password can’t be the same as your current password.',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
|
||||
|
@ -56,10 +56,10 @@ const errors = {
|
|||
insufficient_info: 'Insufficient sign-in info.',
|
||||
connector_id_mismatch: 'The connectorId is mismatched with session record.',
|
||||
connector_session_not_found: 'Connector session not found. Please go back and sign in again.',
|
||||
forgot_password_session_not_found:
|
||||
'Forgot password session not found. Please go back and verify.',
|
||||
forgot_password_verification_expired:
|
||||
'Forgot password verification has expired. Please go back and verify again.',
|
||||
verification_session_not_found:
|
||||
'The verification was not successful. Restart the verification flow and try again.',
|
||||
verification_expired:
|
||||
'The connection has timed out. Verify again to ensure your account safety.',
|
||||
unauthorized: 'Please sign in first.',
|
||||
unsupported_prompt_name: 'Unsupported prompt name.',
|
||||
},
|
||||
|
@ -123,6 +123,9 @@ const errors = {
|
|||
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
|
||||
not_found: 'The resource does not exist.',
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.',
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -43,7 +43,7 @@ const errors = {
|
|||
identity_exists: 'Le compte social a été enregistré.',
|
||||
invalid_role_names: 'les noms de rôles ({{roleNames}}) ne sont pas valides',
|
||||
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
|
||||
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
|
||||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
|
||||
|
@ -61,10 +61,10 @@ const errors = {
|
|||
connector_id_mismatch: "Le connectorId ne correspond pas à l'enregistrement de la session.",
|
||||
connector_session_not_found:
|
||||
"La session du connecteur n'a pas été trouvée. Veuillez revenir en arrière et vous connecter à nouveau.",
|
||||
forgot_password_session_not_found:
|
||||
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
|
||||
forgot_password_verification_expired:
|
||||
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
|
||||
verification_session_not_found:
|
||||
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
|
||||
verification_expired:
|
||||
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
|
||||
unauthorized: "Veuillez vous enregistrer d'abord.",
|
||||
unsupported_prompt_name: "Nom d'invite non supporté.",
|
||||
},
|
||||
|
@ -131,6 +131,9 @@ const errors = {
|
|||
not_exists_with_id: "Le {{name}} avec l'ID `{{id}}` n'existe pas.",
|
||||
not_found: "La ressource n'existe pas.",
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -41,7 +41,7 @@ const errors = {
|
|||
identity_exists: '소셜 계정이 이미 등록되있어요.',
|
||||
invalid_role_names: '직책 명({{roleNames}})이 유효하지 않아요.',
|
||||
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
|
||||
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
|
||||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
|
||||
|
@ -55,10 +55,10 @@ const errors = {
|
|||
insufficient_info: '로그인 정보가 충분하지 않아요.',
|
||||
connector_id_mismatch: '연동 ID가 세션 정보와 일치하지 않아요.',
|
||||
connector_session_not_found: '연동 세션을 찾을 수 없어요. 다시 로그인해주세요.',
|
||||
forgot_password_session_not_found:
|
||||
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
|
||||
forgot_password_verification_expired:
|
||||
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
|
||||
verification_session_not_found:
|
||||
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
|
||||
verification_expired:
|
||||
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
|
||||
unauthorized: '로그인을 먼저 해주세요.',
|
||||
unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.',
|
||||
},
|
||||
|
@ -120,6 +120,9 @@ const errors = {
|
|||
not_exists_with_id: '{{id}} ID를 가진 {{name}}는 존재하지 않아요.',
|
||||
not_found: '리소스가 존재하지 않아요.',
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -41,7 +41,7 @@ const errors = {
|
|||
identity_exists: 'A conta social foi registada.',
|
||||
invalid_role_names: '({{roleNames}}) não são válidos',
|
||||
cannot_delete_self: 'Não se pode remover a si mesmo.',
|
||||
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
|
||||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
|
||||
|
@ -57,10 +57,10 @@ const errors = {
|
|||
connector_id_mismatch: 'O connectorId não corresponde ao registado na sessão.',
|
||||
connector_session_not_found:
|
||||
'Sessão do conector não encontrada. Por favor, volte e faça login novamente.',
|
||||
forgot_password_session_not_found:
|
||||
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
|
||||
forgot_password_verification_expired:
|
||||
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
|
||||
verification_session_not_found:
|
||||
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
|
||||
verification_expired:
|
||||
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
|
||||
unauthorized: 'Faça login primeiro.',
|
||||
unsupported_prompt_name: 'Nome de prompt não suportado.',
|
||||
},
|
||||
|
@ -126,6 +126,9 @@ const errors = {
|
|||
not_exists_with_id: '{{name}} com o ID `{{id}}` não existe.',
|
||||
not_found: 'O recurso não existe.',
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -42,7 +42,7 @@ const errors = {
|
|||
identity_exists: 'Sosyal platform hesabı kaydedildi.',
|
||||
invalid_role_names: '({{roleNames}}) rol adları geçerli değil.',
|
||||
cannot_delete_self: 'You cannot delete yourself.', // UNTRANSLATED
|
||||
same_password: 'Your new password can not be the same as current password.', // UNTRANSLATED
|
||||
same_password: 'Your new password can’t be the same as your current password.', // UNTRANSLATED
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
|
||||
|
@ -57,10 +57,10 @@ const errors = {
|
|||
connector_id_mismatch: 'connectorId, oturum kaydı ile eşleşmiyor.',
|
||||
connector_session_not_found:
|
||||
'Bağlayıcı oturum bulunamadı. Lütfen geri dönüp tekrardan giriş yapınız.',
|
||||
forgot_password_session_not_found:
|
||||
'Forgot password session not found. Please go back and verify.', // UNTRANSLATED
|
||||
forgot_password_verification_expired:
|
||||
'Forgot password verification has expired. Please go back and verify again.', // UNTRANSLATED
|
||||
verification_session_not_found:
|
||||
'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED
|
||||
verification_expired:
|
||||
'The connection has timed out. Verify again to ensure your account safety.', // UNTRANSLATED
|
||||
unauthorized: 'Lütfen önce oturum açın.',
|
||||
unsupported_prompt_name: 'Desteklenmeyen prompt adı.',
|
||||
},
|
||||
|
@ -125,6 +125,9 @@ const errors = {
|
|||
not_exists_with_id: ' `{{id}}` id kimliğine sahip {{name}} mevcut değil.',
|
||||
not_found: 'Kaynak mevcut değil.',
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -41,7 +41,7 @@ const errors = {
|
|||
identity_exists: '该社交帐号已被注册',
|
||||
invalid_role_names: '角色名称({{roleNames}})无效',
|
||||
cannot_delete_self: '你无法删除自己',
|
||||
same_password: '新设置的密码不可与当前密码相同',
|
||||
same_password: '为确保你的账户安全,新密码不能与旧密码一致',
|
||||
},
|
||||
password: {
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}',
|
||||
|
@ -55,8 +55,8 @@ const errors = {
|
|||
insufficient_info: '登录信息缺失,请检查你的输入。',
|
||||
connector_id_mismatch: '传入的连接器 ID 与 session 中保存的记录不一致',
|
||||
connector_session_not_found: '无法找到连接器登录信息,请尝试重新登录。',
|
||||
forgot_password_session_not_found: '无法找到忘记密码验证信息,请尝试重新验证。',
|
||||
forgot_password_verification_expired: '忘记密码验证已过期,请尝试重新验证。',
|
||||
verification_session_not_found: '验证失败,请重新验证。',
|
||||
verification_expired: '无密码验证已过期。请返回重新验证。',
|
||||
unauthorized: '请先登录',
|
||||
unsupported_prompt_name: '不支持的 prompt name',
|
||||
},
|
||||
|
@ -114,6 +114,9 @@ const errors = {
|
|||
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在',
|
||||
not_found: '该资源不存在',
|
||||
},
|
||||
log: {
|
||||
invalid_type: 'The log type is invalid.', // UNTRANSLATED
|
||||
},
|
||||
};
|
||||
|
||||
export default errors;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
type Response = {
|
||||
|
@ -8,9 +10,10 @@ const forgotPasswordApiPrefix = '/api/session/forgot-password';
|
|||
|
||||
export const sendForgotPasswordSmsPasscode = async (phone: string) => {
|
||||
await api
|
||||
.post(`${forgotPasswordApiPrefix}/sms/send-passcode`, {
|
||||
.post('/api/session/passwordless/sms/send', {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -20,22 +23,24 @@ export const sendForgotPasswordSmsPasscode = async (phone: string) => {
|
|||
|
||||
export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => {
|
||||
await api
|
||||
.post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, {
|
||||
.post('/api/session/passwordless/sms/verify', {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
.json();
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmailPasscode = async (email: string) => {
|
||||
await api
|
||||
.post(`${forgotPasswordApiPrefix}/email/send-passcode`, {
|
||||
.post('/api/session/passwordless/email/send', {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -45,13 +50,14 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => {
|
|||
|
||||
export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => {
|
||||
await api
|
||||
.post(`${forgotPasswordApiPrefix}/email/verify-passcode`, {
|
||||
.post('/api/session/passwordless/email/verify', {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
.json();
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
import ky from 'ky';
|
||||
|
||||
import { consent } from './consent';
|
||||
|
@ -10,6 +11,8 @@ import {
|
|||
} from './forgot-password';
|
||||
import {
|
||||
register,
|
||||
registerWithSms,
|
||||
registerWithEmail,
|
||||
sendRegisterEmailPasscode,
|
||||
sendRegisterSmsPasscode,
|
||||
verifyRegisterEmailPasscode,
|
||||
|
@ -17,6 +20,8 @@ import {
|
|||
} from './register';
|
||||
import {
|
||||
signInBasic,
|
||||
signInWithSms,
|
||||
signInWithEmail,
|
||||
sendSignInSmsPasscode,
|
||||
sendSignInEmailPasscode,
|
||||
verifySignInEmailPasscode,
|
||||
|
@ -65,6 +70,26 @@ describe('api', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('signInWithSms', async () => {
|
||||
mockKyPost.mockReturnValueOnce({
|
||||
json: () => ({
|
||||
redirectTo: '/',
|
||||
}),
|
||||
});
|
||||
await signInWithSms();
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms');
|
||||
});
|
||||
|
||||
it('signInWithEmail', async () => {
|
||||
mockKyPost.mockReturnValueOnce({
|
||||
json: () => ({
|
||||
redirectTo: '/',
|
||||
}),
|
||||
});
|
||||
await signInWithEmail();
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email');
|
||||
});
|
||||
|
||||
it('signInBasic with bind social account', async () => {
|
||||
mockKyPost.mockReturnValueOnce({
|
||||
json: () => ({
|
||||
|
@ -87,9 +112,10 @@ describe('api', () => {
|
|||
|
||||
it('sendSignInSmsPasscode', async () => {
|
||||
await sendSignInSmsPasscode(phone);
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -100,20 +126,24 @@ describe('api', () => {
|
|||
redirectTo: '/',
|
||||
}),
|
||||
});
|
||||
|
||||
await verifySignInSmsPasscode(phone, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms/verify-passcode', {
|
||||
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sendSignInEmailPasscode', async () => {
|
||||
await sendSignInEmailPasscode(email);
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -124,11 +154,14 @@ describe('api', () => {
|
|||
redirectTo: '/',
|
||||
}),
|
||||
});
|
||||
|
||||
await verifySignInEmailPasscode(email, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email/verify-passcode', {
|
||||
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -148,78 +181,96 @@ describe('api', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('registerWithSms', async () => {
|
||||
await registerWithSms();
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms');
|
||||
});
|
||||
|
||||
it('registerWithEmail', async () => {
|
||||
await registerWithEmail();
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email');
|
||||
});
|
||||
|
||||
it('sendRegisterSmsPasscode', async () => {
|
||||
await sendRegisterSmsPasscode(phone);
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyRegisterSmsPasscode', async () => {
|
||||
await verifyRegisterSmsPasscode(phone, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms/verify-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sendRegisterEmailPasscode', async () => {
|
||||
await sendRegisterEmailPasscode(email);
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyRegisterEmailPasscode', async () => {
|
||||
await verifyRegisterEmailPasscode(email, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email/verify-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sendForgotPasswordSmsPasscode', async () => {
|
||||
await sendForgotPasswordSmsPasscode(phone);
|
||||
expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyForgotPasswordSmsPasscode', async () => {
|
||||
await verifyForgotPasswordSmsPasscode(phone, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/forgot-password/sms/verify-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sendForgotPasswordEmailPasscode', async () => {
|
||||
await sendForgotPasswordEmailPasscode(email);
|
||||
expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/send-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/send', {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('verifyForgotPasswordEmailPasscode', async () => {
|
||||
await verifyForgotPasswordEmailPasscode(email, code);
|
||||
expect(ky.post).toBeCalledWith('/api/session/forgot-password/email/verify-passcode', {
|
||||
expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.ForgotPassword,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
const registerApiPrefix = '/api/session/register';
|
||||
const apiPrefix = '/api/session';
|
||||
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export const register = async (username: string, password: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return api
|
||||
.post(`${registerApiPrefix}/username-password`, {
|
||||
.post(`${apiPrefix}/register/username-password`, {
|
||||
json: {
|
||||
username,
|
||||
password,
|
||||
|
@ -17,11 +19,18 @@ export const register = async (username: string, password: string) => {
|
|||
.json<Response>();
|
||||
};
|
||||
|
||||
export const registerWithSms = async () =>
|
||||
api.post(`${apiPrefix}/register/passwordless/sms`).json<Response>();
|
||||
|
||||
export const registerWithEmail = async () =>
|
||||
api.post(`${apiPrefix}/register/passwordless/email`).json<Response>();
|
||||
|
||||
export const sendRegisterSmsPasscode = async (phone: string) => {
|
||||
await api
|
||||
.post(`${registerApiPrefix}/passwordless/sms/send-passcode`, {
|
||||
.post(`${apiPrefix}/passwordless/sms/send`, {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -29,26 +38,23 @@ export const sendRegisterSmsPasscode = async (phone: string) => {
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
export const verifyRegisterSmsPasscode = async (phone: string, code: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return api
|
||||
.post(`${registerApiPrefix}/passwordless/sms/verify-passcode`, {
|
||||
export const verifyRegisterSmsPasscode = async (phone: string, code: string) =>
|
||||
api
|
||||
.post(`${apiPrefix}/passwordless/sms/verify`, {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
||||
|
||||
export const sendRegisterEmailPasscode = async (email: string) => {
|
||||
await api
|
||||
.post(`${registerApiPrefix}/passwordless/email/send-passcode`, {
|
||||
.post(`${apiPrefix}/passwordless/email/send`, {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -56,17 +62,13 @@ export const sendRegisterEmailPasscode = async (email: string) => {
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
export const verifyRegisterEmailPasscode = async (email: string, code: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
return api
|
||||
.post(`${registerApiPrefix}/passwordless/email/verify-passcode`, {
|
||||
export const verifyRegisterEmailPasscode = async (email: string, code: string) =>
|
||||
api
|
||||
.post(`${apiPrefix}/passwordless/email/verify`, {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.Register,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { PasscodeType } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
import { bindSocialAccount } from './social';
|
||||
|
||||
export const signInBasic = async (username: string, password: string, socialToBind?: string) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
const apiPrefix = '/api/session';
|
||||
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
export const signInBasic = async (username: string, password: string, socialToBind?: string) => {
|
||||
const result = await api
|
||||
.post('/api/session/sign-in/username-password', {
|
||||
.post(`${apiPrefix}/sign-in/username-password`, {
|
||||
json: {
|
||||
username,
|
||||
password,
|
||||
|
@ -22,11 +26,32 @@ export const signInBasic = async (username: string, password: string, socialToBi
|
|||
return result;
|
||||
};
|
||||
|
||||
export const signInWithSms = async (socialToBind?: string) => {
|
||||
const result = await api.post(`${apiPrefix}/sign-in/passwordless/sms`).json<Response>();
|
||||
|
||||
if (result.redirectTo && socialToBind) {
|
||||
await bindSocialAccount(socialToBind);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const signInWithEmail = async (socialToBind?: string) => {
|
||||
const result = await api.post(`${apiPrefix}/sign-in/passwordless/email`).json<Response>();
|
||||
|
||||
if (result.redirectTo && socialToBind) {
|
||||
await bindSocialAccount(socialToBind);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const sendSignInSmsPasscode = async (phone: string) => {
|
||||
await api
|
||||
.post('/api/session/sign-in/passwordless/sms/send-passcode', {
|
||||
.post(`${apiPrefix}/passwordless/sms/send`, {
|
||||
json: {
|
||||
phone,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -39,15 +64,12 @@ export const verifySignInSmsPasscode = async (
|
|||
code: string,
|
||||
socialToBind?: string
|
||||
) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
const result = await api
|
||||
.post('/api/session/sign-in/passwordless/sms/verify-passcode', {
|
||||
.post(`${apiPrefix}/passwordless/sms/verify`, {
|
||||
json: {
|
||||
phone,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
|
@ -61,9 +83,10 @@ export const verifySignInSmsPasscode = async (
|
|||
|
||||
export const sendSignInEmailPasscode = async (email: string) => {
|
||||
await api
|
||||
.post('/api/session/sign-in/passwordless/email/send-passcode', {
|
||||
.post(`${apiPrefix}/passwordless/email/send`, {
|
||||
json: {
|
||||
email,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -76,15 +99,12 @@ export const verifySignInEmailPasscode = async (
|
|||
code: string,
|
||||
socialToBind?: string
|
||||
) => {
|
||||
type Response = {
|
||||
redirectTo: string;
|
||||
};
|
||||
|
||||
const result = await api
|
||||
.post('/api/session/sign-in/passwordless/email/verify-passcode', {
|
||||
.post(`${apiPrefix}/passwordless/email/verify`, {
|
||||
json: {
|
||||
email,
|
||||
code,
|
||||
flow: PasscodeType.SignIn,
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
|
|
|
@ -8,12 +8,12 @@ import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils';
|
|||
import Passcode, { defaultLength } from '@/components/Passcode';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
import usePasscodeValidationErrorHandler from './use-passcode-validation-error-handler';
|
||||
|
||||
type Props = {
|
||||
type: UserFlow;
|
||||
|
@ -36,7 +36,6 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
|
|||
const [error, setError] = useState<string>();
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { seconds, isRunning, restart } = useTimer({
|
||||
|
@ -44,19 +43,15 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
|
|||
expiryTimestamp: getTimeout(),
|
||||
});
|
||||
|
||||
// Get the flow specific error handler hook
|
||||
const { errorHandler } = usePasscodeValidationErrorHandler(type, method, target);
|
||||
|
||||
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
...errorHandler,
|
||||
'passcode.expired': (error) => {
|
||||
setError(error.message);
|
||||
},
|
||||
'user.phone_not_exists': async (error) => {
|
||||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
'user.email_not_exists': async (error) => {
|
||||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
'passcode.code_mismatch': (error) => {
|
||||
setError(error.message);
|
||||
},
|
||||
|
@ -64,7 +59,7 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
|
|||
setCode([]);
|
||||
},
|
||||
}),
|
||||
[navigate, show]
|
||||
[errorHandler]
|
||||
);
|
||||
|
||||
const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
|
||||
const useForgotPasswordWithEmailErrorHandler = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const emailNotExistForgotPasswordHandler = useCallback(async () => {
|
||||
await show({
|
||||
type: 'alert',
|
||||
ModalContent: t('description.forgot_password_id_does_not_exits', {
|
||||
type: t(`description.email`),
|
||||
value: email,
|
||||
}),
|
||||
cancelText: 'action.got_it',
|
||||
});
|
||||
navigate(-1);
|
||||
}, [navigate, show, t, email]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.email_not_exists': emailNotExistForgotPasswordHandler,
|
||||
}),
|
||||
[emailNotExistForgotPasswordHandler]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useForgotPasswordWithEmailErrorHandler;
|
|
@ -0,0 +1,38 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
const useForgotPasswordWithSmsErrorHandler = (phone: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const phoneNotExistForgotPasswordHandler = useCallback(async () => {
|
||||
await show({
|
||||
type: 'alert',
|
||||
ModalContent: t('description.forgot_password_id_does_not_exits', {
|
||||
type: t(`description.phone_number`),
|
||||
value: formatPhoneNumberWithCountryCallingCode(phone),
|
||||
}),
|
||||
cancelText: 'action.got_it',
|
||||
});
|
||||
navigate(-1);
|
||||
}, [navigate, show, t, phone]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.phone_not_exists': phoneNotExistForgotPasswordHandler,
|
||||
}),
|
||||
[phoneNotExistForgotPasswordHandler]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useForgotPasswordWithSmsErrorHandler;
|
|
@ -0,0 +1,43 @@
|
|||
import { UserFlow } from '@/types';
|
||||
|
||||
import useForgotPasswordWithEmailErrorHandler from './use-forgot-password-with-email-error-handler';
|
||||
import useForgotPasswordWithSmsErrorHandler from './use-forgot-password-with-sms-error-handler';
|
||||
import useRegisterWithSmsErrorHandler from './use-register-with-sms-error-handler';
|
||||
import useSignInWithEmailErrorHandler from './use-sign-in-with-email-error-handler';
|
||||
import useSignInWithSmsErrorHandler from './use-sign-in-with-sms-error-handler';
|
||||
import useRegisterWithEmailErrorHandler from './user-register-with-email-error-handler';
|
||||
|
||||
type Method = 'email' | 'sms';
|
||||
|
||||
const getPasscodeValidationErrorHandlersByFlowAndMethod = (flow: UserFlow, method: Method) => {
|
||||
if (flow === 'sign-in' && method === 'email') {
|
||||
return useSignInWithEmailErrorHandler;
|
||||
}
|
||||
|
||||
if (flow === 'sign-in' && method === 'sms') {
|
||||
return useSignInWithSmsErrorHandler;
|
||||
}
|
||||
|
||||
if (flow === 'register' && method === 'email') {
|
||||
return useRegisterWithEmailErrorHandler;
|
||||
}
|
||||
|
||||
if (flow === 'register' && method === 'sms') {
|
||||
return useRegisterWithSmsErrorHandler;
|
||||
}
|
||||
|
||||
if (flow === 'forgot-password' && method === 'email') {
|
||||
return useForgotPasswordWithEmailErrorHandler;
|
||||
}
|
||||
|
||||
return useForgotPasswordWithSmsErrorHandler;
|
||||
};
|
||||
|
||||
const usePasscodeValidationErrorHandler = (type: UserFlow, method: Method, target: string) => {
|
||||
const useFlowErrorHandler = getPasscodeValidationErrorHandlersByFlowAndMethod(type, method);
|
||||
const { errorHandler } = useFlowErrorHandler(target);
|
||||
|
||||
return { errorHandler };
|
||||
};
|
||||
|
||||
export default usePasscodeValidationErrorHandler;
|
|
@ -0,0 +1,53 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { signInWithSms } from '@/apis/sign-in';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
const useRegisterWithSmsErrorHandler = (phone: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { run: signInWithSmsAsync } = useApi(signInWithSms);
|
||||
|
||||
const phoneExistRegisterHandler = useCallback(async () => {
|
||||
const [confirm] = await show({
|
||||
confirmText: 'action.sign_in',
|
||||
ModalContent: t('description.create_account_id_exists', {
|
||||
type: t(`description.phone_number`),
|
||||
value: formatPhoneNumberWithCountryCallingCode(phone),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
navigate(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signInWithSmsAsync();
|
||||
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [navigate, phone, show, signInWithSmsAsync, t]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.phone_exists_register': async () => {
|
||||
await phoneExistRegisterHandler();
|
||||
},
|
||||
}),
|
||||
[phoneExistRegisterHandler]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRegisterWithSmsErrorHandler;
|
|
@ -0,0 +1,63 @@
|
|||
import { useCallback, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerWithEmail } from '@/apis/register';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
|
||||
const useSignInWithEmailErrorHandler = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { run: registerWithEmailAsync } = useApi(registerWithEmail);
|
||||
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
const emailNotExistSignInHandler = useCallback(async () => {
|
||||
const [confirm] = await show({
|
||||
confirmText: 'action.create',
|
||||
ModalContent: t('description.sign_in_id_does_not_exists', {
|
||||
type: t(`description.email`),
|
||||
value: email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
navigate(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registerWithEmailAsync();
|
||||
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [navigate, registerWithEmailAsync, show, t, email]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.email_not_exists': async (error) => {
|
||||
// Directly display the error if user is trying to bind with social
|
||||
if (socialToBind) {
|
||||
setToast(error.message);
|
||||
}
|
||||
|
||||
await emailNotExistSignInHandler();
|
||||
},
|
||||
}),
|
||||
[emailNotExistSignInHandler, setToast, socialToBind]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSignInWithEmailErrorHandler;
|
|
@ -0,0 +1,64 @@
|
|||
import { useCallback, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { registerWithSms } from '@/apis/register';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
const useSignInWithSmsErrorHandler = (phone: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { run: registerWithSmsAsync } = useApi(registerWithSms);
|
||||
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
const phoneNotExistSignInHandler = useCallback(async () => {
|
||||
const [confirm] = await show({
|
||||
ModalContent: t('description.sign_in_id_does_not_exists', {
|
||||
confirmText: 'action.create',
|
||||
type: t(`description.phone_number`),
|
||||
value: formatPhoneNumberWithCountryCallingCode(phone),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
navigate(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await registerWithSmsAsync();
|
||||
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [navigate, registerWithSmsAsync, show, t, phone]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.phone_not_exists': async (error) => {
|
||||
// Directly display the error if user is trying to bind with social
|
||||
if (socialToBind) {
|
||||
setToast(error.message);
|
||||
}
|
||||
|
||||
await phoneNotExistSignInHandler();
|
||||
},
|
||||
}),
|
||||
[phoneNotExistSignInHandler, setToast, socialToBind]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSignInWithSmsErrorHandler;
|
|
@ -0,0 +1,52 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { signInWithEmail } from '@/apis/sign-in';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
|
||||
const useRegisterWithEmailErrorHandler = (email: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { show } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { run: signInWithEmailAsync } = useApi(signInWithEmail);
|
||||
|
||||
const emailExistRegisterHandler = useCallback(async () => {
|
||||
const [confirm] = await show({
|
||||
ModalContent: t('description.create_account_id_exists', {
|
||||
confirmText: 'action.sign_in',
|
||||
type: t(`description.email`),
|
||||
value: email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!confirm) {
|
||||
navigate(-1);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signInWithEmailAsync();
|
||||
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [navigate, show, signInWithEmailAsync, t, email]);
|
||||
|
||||
const errorHandler = useMemo<ErrorHandlers>(
|
||||
() => ({
|
||||
'user.email_exists_register': async () => {
|
||||
await emailExistRegisterHandler();
|
||||
},
|
||||
}),
|
||||
[emailExistRegisterHandler]
|
||||
);
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRegisterWithEmailErrorHandler;
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
@ -9,13 +9,10 @@ import Input from '@/components/Input';
|
|||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { UserFlow } from '@/types';
|
||||
import { emailValidation } from '@/utils/field-validations';
|
||||
|
||||
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
|
||||
import PasswordlessSwitch from './PasswordlessSwitch';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -41,7 +38,6 @@ const EmailPasswordless = ({
|
|||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -49,30 +45,13 @@ const EmailPasswordless = ({
|
|||
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
|
||||
useForm(defaultState);
|
||||
|
||||
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.email_not_exists': (error) => {
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
// Directly display the error if user is trying to bind with social
|
||||
if (socialToBind) {
|
||||
setToast(error.message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPasswordlessConfirmModal(true);
|
||||
},
|
||||
'user.email_exists_register': () => {
|
||||
setShowPasswordlessConfirmModal(true);
|
||||
},
|
||||
'guard.invalid_input': () => {
|
||||
setFieldErrors({ email: 'invalid_email' });
|
||||
},
|
||||
}),
|
||||
[setFieldErrors, setToast]
|
||||
[setFieldErrors]
|
||||
);
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, 'email');
|
||||
|
@ -95,10 +74,6 @@ const EmailPasswordless = ({
|
|||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email]
|
||||
);
|
||||
|
||||
const onModalCloseHandler = useCallback(() => {
|
||||
setShowPasswordlessConfirmModal(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
navigate(
|
||||
|
@ -112,39 +87,30 @@ const EmailPasswordless = ({
|
|||
}, [fieldValue.email, navigate, result, type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder={t('input.email')}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
{...register('email', emailValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, email: '' }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
|
||||
</div>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder={t('input.email')}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
{...register('email', emailValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, email: '' }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
|
||||
</div>
|
||||
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
<PasswordlessConfirmModal
|
||||
isOpen={showPasswordlessConfirmModal}
|
||||
type={type === 'sign-in' ? 'register' : 'sign-in'}
|
||||
method="email"
|
||||
value={fieldValue.email}
|
||||
onClose={onModalCloseHandler}
|
||||
/>
|
||||
</>
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import { useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getSendPasscodeApi, PasscodeChannel } from '@/apis/utils';
|
||||
import { WebModal, MobileModal } from '@/components/ConfirmModal';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import usePlatform from '@/hooks/use-platform';
|
||||
import { UserFlow } from '@/types';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
isOpen?: boolean;
|
||||
type: UserFlow;
|
||||
method: PasscodeChannel;
|
||||
value: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const PasswordlessConfirmModal = ({ className, isOpen, type, method, value, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const sendPasscode = getSendPasscodeApi(type, method);
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = usePlatform();
|
||||
|
||||
const { result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
const ConfirmModal = isMobile ? MobileModal : WebModal;
|
||||
|
||||
const onConfirmHandler = useCallback(() => {
|
||||
onClose();
|
||||
void asyncSendPasscode(value);
|
||||
}, [asyncSendPasscode, onClose, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/${type}/${method}/passcode-validation`,
|
||||
},
|
||||
{ state: { [method]: value } }
|
||||
);
|
||||
}
|
||||
}, [method, result, type, value, navigate, onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
className={className}
|
||||
isOpen={isOpen}
|
||||
confirmText={type === 'sign-in' ? 'action.sign_in' : 'action.create'}
|
||||
onClose={onClose}
|
||||
onConfirm={onConfirmHandler}
|
||||
>
|
||||
{t(
|
||||
type === 'sign-in'
|
||||
? 'description.create_account_id_exists'
|
||||
: 'description.sign_in_id_does_not_exists',
|
||||
{
|
||||
type: t(`description.${method === 'email' ? 'email' : 'phone_number'}`),
|
||||
value: method === 'sms' ? formatPhoneNumberWithCountryCallingCode(value) : value,
|
||||
}
|
||||
)}
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordlessConfirmModal;
|
|
@ -1,5 +1,5 @@
|
|||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
|
@ -9,13 +9,10 @@ import { PhoneInput } from '@/components/Input';
|
|||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePhoneNumber from '@/hooks/use-phone-number';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
|
||||
import PasswordlessSwitch from './PasswordlessSwitch';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -41,7 +38,6 @@ const PhonePasswordless = ({
|
|||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
|
@ -50,30 +46,13 @@ const PhonePasswordless = ({
|
|||
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
|
||||
useForm(defaultState);
|
||||
|
||||
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'user.phone_not_exists': (error) => {
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
// Directly display the error if user is trying to bind with social
|
||||
if (socialToBind) {
|
||||
setToast(error.message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPasswordlessConfirmModal(true);
|
||||
},
|
||||
'user.phone_exists_register': () => {
|
||||
setShowPasswordlessConfirmModal(true);
|
||||
},
|
||||
'guard.invalid_input': () => {
|
||||
setFieldErrors({ phone: 'invalid_phone' });
|
||||
},
|
||||
}),
|
||||
[setFieldErrors, setToast]
|
||||
[setFieldErrors]
|
||||
);
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, 'sms');
|
||||
|
@ -105,10 +84,6 @@ const PhonePasswordless = ({
|
|||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.phone]
|
||||
);
|
||||
|
||||
const onModalCloseHandler = useCallback(() => {
|
||||
setShowPasswordlessConfirmModal(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync phoneNumber
|
||||
setFieldValue((previous) => ({
|
||||
|
@ -127,40 +102,31 @@ const PhonePasswordless = ({
|
|||
}, [fieldValue.phone, navigate, result, type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<PhoneInput
|
||||
name="phone"
|
||||
placeholder={t('input.phone_number')}
|
||||
className={styles.inputField}
|
||||
countryCallingCode={phoneNumber.countryCallingCode}
|
||||
nationalNumber={phoneNumber.nationalNumber}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
countryList={countryList}
|
||||
{...register('phone', phoneNumberValidation)}
|
||||
onChange={(data) => {
|
||||
setPhoneNumber((previous) => ({ ...previous, ...data }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
|
||||
</div>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<PhoneInput
|
||||
name="phone"
|
||||
placeholder={t('input.phone_number')}
|
||||
className={styles.inputField}
|
||||
countryCallingCode={phoneNumber.countryCallingCode}
|
||||
nationalNumber={phoneNumber.nationalNumber}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
countryList={countryList}
|
||||
{...register('phone', phoneNumberValidation)}
|
||||
onChange={(data) => {
|
||||
setPhoneNumber((previous) => ({ ...previous, ...data }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
|
||||
</div>
|
||||
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
<PasswordlessConfirmModal
|
||||
isOpen={showPasswordlessConfirmModal}
|
||||
type={type === 'sign-in' ? 'register' : 'sign-in'}
|
||||
method="sms"
|
||||
value={fieldValue.phone}
|
||||
onClose={onModalCloseHandler}
|
||||
/>
|
||||
</>
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -47,11 +47,11 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
|
|||
|
||||
const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.forgot_password_session_not_found': async (error) => {
|
||||
'session.verification_session_not_found': async (error) => {
|
||||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
'session.forgot_password_verification_expired': async (error) => {
|
||||
'session.verification_expired': async (error) => {
|
||||
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
|
||||
navigate(-1);
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue