mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): remove identities (#6809)
This commit is contained in:
parent
f8d21e49fa
commit
3cfa6843bd
7 changed files with 464 additions and 100 deletions
192
packages/core/src/routes/profile/email-and-phone.ts
Normal file
192
packages/core/src/routes/profile/email-and-phone.ts
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
import { emailRegEx, phoneRegEx, UserScope } from '@logto/core-kit';
|
||||||
|
import { VerificationType, AccountCenterControlValue, SignInIdentifier } from '@logto/schemas';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
|
|
||||||
|
import RequestError from '../../errors/RequestError/index.js';
|
||||||
|
import { buildVerificationRecordByIdAndType } from '../../libraries/verification.js';
|
||||||
|
import assertThat from '../../utils/assert-that.js';
|
||||||
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
export default function emailAndPhoneRoutes<T extends UserRouter>(...args: RouterInitArgs<T>) {
|
||||||
|
const [router, { queries, libraries }] = args;
|
||||||
|
const {
|
||||||
|
users: { updateUserById, findUserById },
|
||||||
|
signInExperiences: { findDefaultSignInExperience },
|
||||||
|
} = queries;
|
||||||
|
|
||||||
|
const {
|
||||||
|
users: { checkIdentifierCollision },
|
||||||
|
} = libraries;
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/profile/primary-email',
|
||||||
|
koaGuard({
|
||||||
|
body: z.object({
|
||||||
|
email: z.string().regex(emailRegEx),
|
||||||
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
|
}),
|
||||||
|
status: [204, 400, 401],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { email, newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
|
const { fields } = ctx.accountCenter;
|
||||||
|
assertThat(
|
||||||
|
fields.email === AccountCenterControlValue.Edit,
|
||||||
|
'account_center.filed_not_editable'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
|
||||||
|
|
||||||
|
// Check new identifier
|
||||||
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
|
type: VerificationType.EmailVerificationCode,
|
||||||
|
id: newIdentifierVerificationRecordId,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
});
|
||||||
|
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
|
||||||
|
assertThat(newVerificationRecord.identifier.value === email, 'verification_record.not_found');
|
||||||
|
|
||||||
|
await checkIdentifierCollision({ primaryEmail: email }, userId);
|
||||||
|
|
||||||
|
const updatedUser = await updateUserById(userId, { primaryEmail: email });
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/profile/primary-email',
|
||||||
|
koaGuard({
|
||||||
|
status: [204, 400, 401],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { fields } = ctx.accountCenter;
|
||||||
|
assertThat(
|
||||||
|
fields.email === AccountCenterControlValue.Edit,
|
||||||
|
'account_center.filed_not_editable'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
|
||||||
|
|
||||||
|
const { signUp } = await findDefaultSignInExperience();
|
||||||
|
|
||||||
|
if (signUp.identifiers.includes(SignInIdentifier.Email)) {
|
||||||
|
// If email is the only sign-up identifier, we need to keep the email
|
||||||
|
assertThat(signUp.identifiers.includes(SignInIdentifier.Phone), 'user.email_required');
|
||||||
|
// If phone is also a sign-up identifier, check if phone is set
|
||||||
|
const user = await findUserById(userId);
|
||||||
|
assertThat(user.primaryPhone, 'user.email_or_phone_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await updateUserById(userId, { primaryEmail: null });
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/profile/primary-phone',
|
||||||
|
koaGuard({
|
||||||
|
body: z.object({
|
||||||
|
phone: z.string().regex(phoneRegEx),
|
||||||
|
newIdentifierVerificationRecordId: z.string(),
|
||||||
|
}),
|
||||||
|
status: [204, 400, 401],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { phone, newIdentifierVerificationRecordId } = ctx.guard.body;
|
||||||
|
const { fields } = ctx.accountCenter;
|
||||||
|
assertThat(
|
||||||
|
fields.phone === AccountCenterControlValue.Edit,
|
||||||
|
'account_center.filed_not_editable'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized');
|
||||||
|
|
||||||
|
// Check new identifier
|
||||||
|
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
||||||
|
type: VerificationType.PhoneVerificationCode,
|
||||||
|
id: newIdentifierVerificationRecordId,
|
||||||
|
queries,
|
||||||
|
libraries,
|
||||||
|
});
|
||||||
|
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
|
||||||
|
assertThat(newVerificationRecord.identifier.value === phone, 'verification_record.not_found');
|
||||||
|
|
||||||
|
await checkIdentifierCollision({ primaryPhone: phone }, userId);
|
||||||
|
|
||||||
|
const updatedUser = await updateUserById(userId, { primaryPhone: phone });
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/profile/primary-phone',
|
||||||
|
koaGuard({
|
||||||
|
status: [204, 400, 401],
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { id: userId, scopes, identityVerified } = ctx.auth;
|
||||||
|
assertThat(
|
||||||
|
identityVerified,
|
||||||
|
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
||||||
|
);
|
||||||
|
const { fields } = ctx.accountCenter;
|
||||||
|
assertThat(
|
||||||
|
fields.phone === AccountCenterControlValue.Edit,
|
||||||
|
'account_center.filed_not_editable'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized');
|
||||||
|
|
||||||
|
const { signUp } = await findDefaultSignInExperience();
|
||||||
|
|
||||||
|
if (signUp.identifiers.includes(SignInIdentifier.Phone)) {
|
||||||
|
// If phone is the only sign-up identifier, we need to keep the phone
|
||||||
|
assertThat(signUp.identifiers.includes(SignInIdentifier.Email), 'user.phone_required');
|
||||||
|
// If email is also a sign-up identifier, check if email is set
|
||||||
|
const user = await findUserById(userId);
|
||||||
|
assertThat(user.primaryEmail, 'user.email_or_phone_required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await updateUserById(userId, { primaryPhone: null });
|
||||||
|
|
||||||
|
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
||||||
|
|
||||||
|
ctx.status = 204;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -174,6 +174,16 @@
|
||||||
"description": "Permission denied, the verification record is invalid."
|
"description": "Permission denied, the verification record is invalid."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "DeletePrimaryEmail",
|
||||||
|
"summary": "Delete primary email",
|
||||||
|
"description": "Delete primary email for the user, a verification-record-id in header is required for checking sensitive permissions.",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "The primary email was deleted successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/profile/primary-phone": {
|
"/api/profile/primary-phone": {
|
||||||
|
@ -208,6 +218,16 @@
|
||||||
"description": "Permission denied, the verification record is invalid."
|
"description": "Permission denied, the verification record is invalid."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "DeletePrimaryPhone",
|
||||||
|
"summary": "Delete primary phone",
|
||||||
|
"description": "Delete primary phone for the user, a verification-record-id in header is required for checking sensitive permissions.",
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "The primary phone was deleted successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/profile/identities": {
|
"/api/profile/identities": {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { emailRegEx, phoneRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
|
import { usernameRegEx, UserScope } from '@logto/core-kit';
|
||||||
import {
|
import {
|
||||||
VerificationType,
|
|
||||||
userProfileResponseGuard,
|
userProfileResponseGuard,
|
||||||
userProfileGuard,
|
userProfileGuard,
|
||||||
AccountCenterControlValue,
|
AccountCenterControlValue,
|
||||||
|
SignInIdentifier,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -12,11 +12,11 @@ import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import { EnvSet } from '../../env-set/index.js';
|
import { EnvSet } from '../../env-set/index.js';
|
||||||
import RequestError from '../../errors/RequestError/index.js';
|
import RequestError from '../../errors/RequestError/index.js';
|
||||||
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
import { encryptUserPassword } from '../../libraries/user.utils.js';
|
||||||
import { buildVerificationRecordByIdAndType } from '../../libraries/verification.js';
|
|
||||||
import assertThat from '../../utils/assert-that.js';
|
import assertThat from '../../utils/assert-that.js';
|
||||||
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
|
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
|
||||||
import type { UserRouter, RouterInitArgs } from '../types.js';
|
import type { UserRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
import emailAndPhoneRoutes from './email-and-phone.js';
|
||||||
import identitiesRoutes from './identities.js';
|
import identitiesRoutes from './identities.js';
|
||||||
import koaAccountCenter from './middlewares/koa-account-center.js';
|
import koaAccountCenter from './middlewares/koa-account-center.js';
|
||||||
import { getAccountCenterFilteredProfile, getScopedProfile } from './utils/get-scoped-profile.js';
|
import { getAccountCenterFilteredProfile, getScopedProfile } from './utils/get-scoped-profile.js';
|
||||||
|
@ -24,7 +24,7 @@ import { getAccountCenterFilteredProfile, getScopedProfile } from './utils/get-s
|
||||||
export default function profileRoutes<T extends UserRouter>(...args: RouterInitArgs<T>) {
|
export default function profileRoutes<T extends UserRouter>(...args: RouterInitArgs<T>) {
|
||||||
const [router, { queries, libraries }] = args;
|
const [router, { queries, libraries }] = args;
|
||||||
const {
|
const {
|
||||||
users: { updateUserById, findUserById, deleteUserIdentity },
|
users: { updateUserById, findUserById },
|
||||||
signInExperiences: { findDefaultSignInExperience },
|
signInExperiences: { findDefaultSignInExperience },
|
||||||
} = queries;
|
} = queries;
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
|
||||||
body: z.object({
|
body: z.object({
|
||||||
name: z.string().nullable().optional(),
|
name: z.string().nullable().optional(),
|
||||||
avatar: z.string().url().nullable().optional(),
|
avatar: z.string().url().nullable().optional(),
|
||||||
username: z.string().regex(usernameRegEx).optional(),
|
username: z.string().regex(usernameRegEx).nullable().optional(),
|
||||||
}),
|
}),
|
||||||
response: userProfileResponseGuard.partial(),
|
response: userProfileResponseGuard.partial(),
|
||||||
status: [200, 400, 422],
|
status: [200, 400, 422],
|
||||||
|
@ -84,7 +84,15 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
|
||||||
assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized');
|
assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized');
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) {
|
||||||
await checkIdentifierCollision({ username }, userId);
|
if (username === null) {
|
||||||
|
const { signUp } = await findDefaultSignInExperience();
|
||||||
|
assertThat(
|
||||||
|
!signUp.identifiers.includes(SignInIdentifier.Username),
|
||||||
|
'user.username_required'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await checkIdentifierCollision({ username }, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
const updatedUser = await updateUserById(userId, {
|
||||||
|
@ -175,97 +183,6 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
emailAndPhoneRoutes(...args);
|
||||||
'/profile/primary-email',
|
|
||||||
koaGuard({
|
|
||||||
body: z.object({
|
|
||||||
email: z.string().regex(emailRegEx),
|
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
|
||||||
}),
|
|
||||||
status: [204, 400, 401],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { id: userId, scopes, identityVerified } = ctx.auth;
|
|
||||||
assertThat(
|
|
||||||
identityVerified,
|
|
||||||
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
|
||||||
);
|
|
||||||
const { email, newIdentifierVerificationRecordId } = ctx.guard.body;
|
|
||||||
const { fields } = ctx.accountCenter;
|
|
||||||
assertThat(
|
|
||||||
fields.email === AccountCenterControlValue.Edit,
|
|
||||||
'account_center.filed_not_editable'
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Email), 'auth.unauthorized');
|
|
||||||
|
|
||||||
// Check new identifier
|
|
||||||
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
|
||||||
type: VerificationType.EmailVerificationCode,
|
|
||||||
id: newIdentifierVerificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
|
|
||||||
assertThat(newVerificationRecord.identifier.value === email, 'verification_record.not_found');
|
|
||||||
|
|
||||||
await checkIdentifierCollision({ primaryEmail: email }, userId);
|
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, { primaryEmail: email });
|
|
||||||
|
|
||||||
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
|
||||||
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/profile/primary-phone',
|
|
||||||
koaGuard({
|
|
||||||
body: z.object({
|
|
||||||
phone: z.string().regex(phoneRegEx),
|
|
||||||
newIdentifierVerificationRecordId: z.string(),
|
|
||||||
}),
|
|
||||||
status: [204, 400, 401],
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { id: userId, scopes, identityVerified } = ctx.auth;
|
|
||||||
assertThat(
|
|
||||||
identityVerified,
|
|
||||||
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
|
|
||||||
);
|
|
||||||
const { phone, newIdentifierVerificationRecordId } = ctx.guard.body;
|
|
||||||
const { fields } = ctx.accountCenter;
|
|
||||||
assertThat(
|
|
||||||
fields.phone === AccountCenterControlValue.Edit,
|
|
||||||
'account_center.filed_not_editable'
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized');
|
|
||||||
|
|
||||||
// Check new identifier
|
|
||||||
const newVerificationRecord = await buildVerificationRecordByIdAndType({
|
|
||||||
type: VerificationType.PhoneVerificationCode,
|
|
||||||
id: newIdentifierVerificationRecordId,
|
|
||||||
queries,
|
|
||||||
libraries,
|
|
||||||
});
|
|
||||||
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');
|
|
||||||
assertThat(newVerificationRecord.identifier.value === phone, 'verification_record.not_found');
|
|
||||||
|
|
||||||
await checkIdentifierCollision({ primaryPhone: phone }, userId);
|
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, { primaryPhone: phone });
|
|
||||||
|
|
||||||
ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
|
|
||||||
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
identitiesRoutes(...args);
|
identitiesRoutes(...args);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,11 @@ export const updatePrimaryEmail = async (
|
||||||
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const deletePrimaryEmail = async (api: KyInstance, verificationRecordId: string) =>
|
||||||
|
api.delete('api/profile/primary-email', {
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
|
});
|
||||||
|
|
||||||
export const updatePrimaryPhone = async (
|
export const updatePrimaryPhone = async (
|
||||||
api: KyInstance,
|
api: KyInstance,
|
||||||
phone: string,
|
phone: string,
|
||||||
|
@ -35,6 +40,11 @@ export const updatePrimaryPhone = async (
|
||||||
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const deletePrimaryPhone = async (api: KyInstance, verificationRecordId: string) =>
|
||||||
|
api.delete('api/profile/primary-phone', {
|
||||||
|
headers: { [verificationRecordIdHeader]: verificationRecordId },
|
||||||
|
});
|
||||||
|
|
||||||
export const updateIdentities = async (
|
export const updateIdentities = async (
|
||||||
api: KyInstance,
|
api: KyInstance,
|
||||||
verificationRecordId: string,
|
verificationRecordId: string,
|
||||||
|
|
|
@ -3,7 +3,13 @@ import { SignInIdentifier } from '@logto/schemas';
|
||||||
|
|
||||||
import { enableAllAccountCenterFields } from '#src/api/account-center.js';
|
import { enableAllAccountCenterFields } from '#src/api/account-center.js';
|
||||||
import { authedAdminApi } from '#src/api/api.js';
|
import { authedAdminApi } from '#src/api/api.js';
|
||||||
import { getUserInfo, updatePrimaryEmail, updatePrimaryPhone } from '#src/api/profile.js';
|
import {
|
||||||
|
deletePrimaryEmail,
|
||||||
|
deletePrimaryPhone,
|
||||||
|
getUserInfo,
|
||||||
|
updatePrimaryEmail,
|
||||||
|
updatePrimaryPhone,
|
||||||
|
} from '#src/api/profile.js';
|
||||||
import {
|
import {
|
||||||
createAndVerifyVerificationCode,
|
createAndVerifyVerificationCode,
|
||||||
createVerificationRecordByPassword,
|
createVerificationRecordByPassword,
|
||||||
|
@ -135,6 +141,102 @@ describe('profile (email and phone)', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DELETE /profile/primary-email', () => {
|
||||||
|
it('should fail if scope is missing', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryEmail(api, verificationRecordId), {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if verification record is invalid', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Email],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryEmail(api, 'invalid-verification-record-id'), {
|
||||||
|
code: 'verification_record.permission_denied',
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if email is the only sign-up identifier', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Email],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await enableAllPasswordSignInMethods({
|
||||||
|
identifiers: [SignInIdentifier.Email],
|
||||||
|
password: true,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryEmail(api, verificationRecordId), {
|
||||||
|
code: 'user.email_required',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if email or phone is the sign-up identifier', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Email],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await enableAllPasswordSignInMethods({
|
||||||
|
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||||
|
password: true,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryEmail(api, verificationRecordId), {
|
||||||
|
code: 'user.email_or_phone_required',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to delete primary email', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Email],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
const newEmail = generateEmail();
|
||||||
|
const newVerificationRecordId = await createAndVerifyVerificationCode(api, {
|
||||||
|
type: SignInIdentifier.Email,
|
||||||
|
value: newEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updatePrimaryEmail(api, newEmail, verificationRecordId, newVerificationRecordId);
|
||||||
|
|
||||||
|
const userInfo = await getUserInfo(api);
|
||||||
|
expect(userInfo).toHaveProperty('primaryEmail', newEmail);
|
||||||
|
|
||||||
|
await deletePrimaryEmail(api, verificationRecordId);
|
||||||
|
|
||||||
|
const userInfoAfterDelete = await getUserInfo(api);
|
||||||
|
expect(userInfoAfterDelete).toHaveProperty('primaryEmail', null);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /profile/primary-phone', () => {
|
describe('POST /profile/primary-phone', () => {
|
||||||
it('should fail if scope is missing', async () => {
|
it('should fail if scope is missing', async () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
@ -241,4 +343,100 @@ describe('profile (email and phone)', () => {
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DELETE /profile/primary-phone', () => {
|
||||||
|
it('should fail if scope is missing', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryPhone(api, verificationRecordId), {
|
||||||
|
code: 'auth.unauthorized',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if verification record is invalid', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Phone],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryPhone(api, 'invalid-verification-record-id'), {
|
||||||
|
code: 'verification_record.permission_denied',
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if phone is the only sign-up identifier', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Phone],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await enableAllPasswordSignInMethods({
|
||||||
|
identifiers: [SignInIdentifier.Phone],
|
||||||
|
password: true,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryPhone(api, verificationRecordId), {
|
||||||
|
code: 'user.phone_required',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if email or phone is the sign-up identifier', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Phone],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
await enableAllPasswordSignInMethods({
|
||||||
|
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||||
|
password: true,
|
||||||
|
verify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expectRejects(deletePrimaryPhone(api, verificationRecordId), {
|
||||||
|
code: 'user.email_or_phone_required',
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be able to delete primary phone', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password, {
|
||||||
|
scopes: [UserScope.Profile, UserScope.Phone],
|
||||||
|
});
|
||||||
|
const verificationRecordId = await createVerificationRecordByPassword(api, password);
|
||||||
|
const newPhone = generatePhone();
|
||||||
|
const newVerificationRecordId = await createAndVerifyVerificationCode(api, {
|
||||||
|
type: SignInIdentifier.Phone,
|
||||||
|
value: newPhone,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updatePrimaryPhone(api, newPhone, verificationRecordId, newVerificationRecordId);
|
||||||
|
|
||||||
|
const userInfo = await getUserInfo(api);
|
||||||
|
expect(userInfo).toHaveProperty('primaryPhone', newPhone);
|
||||||
|
|
||||||
|
await deletePrimaryPhone(api, verificationRecordId);
|
||||||
|
|
||||||
|
const userInfoAfterDelete = await getUserInfo(api);
|
||||||
|
expect(userInfoAfterDelete).toHaveProperty('primaryPhone', null);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { UserScope } from '@logto/core-kit';
|
import { UserScope } from '@logto/core-kit';
|
||||||
import { hookEvents } from '@logto/schemas';
|
import { hookEvents, SignInIdentifier } from '@logto/schemas';
|
||||||
|
|
||||||
import { enableAllAccountCenterFields } from '#src/api/account-center.js';
|
import { enableAllAccountCenterFields } from '#src/api/account-center.js';
|
||||||
import { getUserInfo, updateOtherProfile, updatePassword, updateUser } from '#src/api/profile.js';
|
import { getUserInfo, updateOtherProfile, updatePassword, updateUser } from '#src/api/profile.js';
|
||||||
|
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||||
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
|
import { createVerificationRecordByPassword } from '#src/api/verification-record.js';
|
||||||
import { WebHookApiTest } from '#src/helpers/hook.js';
|
import { WebHookApiTest } from '#src/helpers/hook.js';
|
||||||
import { expectRejects } from '#src/helpers/index.js';
|
import { expectRejects } from '#src/helpers/index.js';
|
||||||
|
@ -174,6 +175,27 @@ describe('profile', () => {
|
||||||
await deleteDefaultTenantUser(user.id);
|
await deleteDefaultTenantUser(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to update username to null', async () => {
|
||||||
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
|
const api = await signInAndGetUserApi(username, password);
|
||||||
|
|
||||||
|
await updateSignInExperience({
|
||||||
|
signUp: {
|
||||||
|
identifiers: [SignInIdentifier.Email],
|
||||||
|
password: true,
|
||||||
|
verify: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await updateUser(api, { username: null });
|
||||||
|
expect(response).toMatchObject({ username: null });
|
||||||
|
await enableAllPasswordSignInMethods();
|
||||||
|
|
||||||
|
const userInfo = await getUserInfo(api);
|
||||||
|
expect(userInfo).toHaveProperty('username', null);
|
||||||
|
|
||||||
|
await deleteDefaultTenantUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fail if username is already in use', async () => {
|
it('should fail if username is already in use', async () => {
|
||||||
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
const { user, username, password } = await createDefaultTenantUserWithPassword();
|
||||||
const { user: user2, username: username2 } = await createDefaultTenantUserWithPassword();
|
const { user: user2, username: username2 } = await createDefaultTenantUserWithPassword();
|
||||||
|
|
|
@ -37,6 +37,11 @@ const user = {
|
||||||
personal_access_token_name_exists: 'Personal access token name already exists.',
|
personal_access_token_name_exists: 'Personal access token name already exists.',
|
||||||
totp_secret_invalid: 'Invalid TOTP secret supplied.',
|
totp_secret_invalid: 'Invalid TOTP secret supplied.',
|
||||||
wrong_backup_code_format: 'Backup code format is invalid.',
|
wrong_backup_code_format: 'Backup code format is invalid.',
|
||||||
|
username_required: 'Username is a required identifier, you can not set it to null.',
|
||||||
|
email_or_phone_required:
|
||||||
|
'Email address or phone number is a required identifier, at least one is required.',
|
||||||
|
email_required: 'Email address is a required identifier, you can not set it to null.',
|
||||||
|
phone_required: 'Phone number is a required identifier, you can not set it to null.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(user);
|
export default Object.freeze(user);
|
||||||
|
|
Loading…
Reference in a new issue