Skip to content

Commit

Permalink
feat(core): remove identities
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Nov 15, 2024
1 parent f8d21e4 commit dee8b57
Show file tree
Hide file tree
Showing 7 changed files with 521 additions and 148 deletions.
192 changes: 192 additions & 0 deletions packages/core/src/routes/profile/email-and-phone.ts
Original file line number Diff line number Diff line change
@@ -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();
}

Check warning on line 66 in packages/core/src/routes/profile/email-and-phone.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/email-and-phone.ts#L33-L66

Added lines #L33 - L66 were not covered by tests
);

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();
}

Check warning on line 105 in packages/core/src/routes/profile/email-and-phone.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/email-and-phone.ts#L75-L105

Added lines #L75 - L105 were not covered by tests
);

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();
}

Check warning on line 151 in packages/core/src/routes/profile/email-and-phone.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/email-and-phone.ts#L118-L151

Added lines #L118 - L151 were not covered by tests
);

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();
}

Check warning on line 190 in packages/core/src/routes/profile/email-and-phone.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/email-and-phone.ts#L160-L190

Added lines #L160 - L190 were not covered by tests
);
}
20 changes: 20 additions & 0 deletions packages/core/src/routes/profile/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@
"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": {
Expand Down Expand Up @@ -208,6 +218,16 @@
"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": {
Expand Down
113 changes: 15 additions & 98 deletions packages/core/src/routes/profile/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { emailRegEx, phoneRegEx, usernameRegEx, UserScope } from '@logto/core-kit';
import { usernameRegEx, UserScope } from '@logto/core-kit';
import {
VerificationType,
userProfileResponseGuard,
userProfileGuard,
AccountCenterControlValue,
SignInIdentifier,
} from '@logto/schemas';
import { z } from 'zod';

Expand All @@ -12,19 +12,19 @@ import koaGuard from '#src/middleware/koa-guard.js';
import { EnvSet } from '../../env-set/index.js';
import RequestError from '../../errors/RequestError/index.js';
import { encryptUserPassword } from '../../libraries/user.utils.js';
import { buildVerificationRecordByIdAndType } from '../../libraries/verification.js';
import assertThat from '../../utils/assert-that.js';
import { PasswordValidator } from '../experience/classes/libraries/password-validator.js';
import type { UserRouter, RouterInitArgs } from '../types.js';

import emailAndPhoneRoutes from './email-and-phone.js';
import identitiesRoutes from './identities.js';
import koaAccountCenter from './middlewares/koa-account-center.js';
import { getAccountCenterFilteredProfile, getScopedProfile } from './utils/get-scoped-profile.js';

export default function profileRoutes<T extends UserRouter>(...args: RouterInitArgs<T>) {
const [router, { queries, libraries }] = args;
const {
users: { updateUserById, findUserById, deleteUserIdentity },
users: { updateUserById, findUserById },
signInExperiences: { findDefaultSignInExperience },
} = queries;

Expand Down Expand Up @@ -58,7 +58,7 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
body: z.object({
name: z.string().nullable().optional(),
avatar: z.string().url().nullable().optional(),
username: z.string().regex(usernameRegEx).optional(),
username: z.string().regex(usernameRegEx).nullable().optional(),
}),
response: userProfileResponseGuard.partial(),
status: [200, 400, 422],
Expand All @@ -84,7 +84,15 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized');

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);
}

Check warning on line 95 in packages/core/src/routes/profile/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/profile/index.ts#L87-L95

Added lines #L87 - L95 were not covered by tests
}

const updatedUser = await updateUserById(userId, {
Expand Down Expand Up @@ -175,97 +183,6 @@ export default function profileRoutes<T extends UserRouter>(...args: RouterInitA
}
);

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.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();
}
);

emailAndPhoneRoutes(...args);
identitiesRoutes(...args);
}
Loading

0 comments on commit dee8b57

Please sign in to comment.