diff --git a/packages/core/src/routes/account-center/index.openapi.json b/packages/core/src/routes/account-center/index.openapi.json index 16f98454d..39acda19f 100644 --- a/packages/core/src/routes/account-center/index.openapi.json +++ b/packages/core/src/routes/account-center/index.openapi.json @@ -11,6 +11,7 @@ "paths": { "/api/account-center": { "get": { + "operationId": "GetAccountCenterSettings", "summary": "Get account center settings", "description": "Get the account center settings.", "responses": { @@ -20,6 +21,7 @@ } }, "patch": { + "operationId": "UpdateAccountCenterSettings", "summary": "Update account center settings", "description": "Update the account center settings with the provided settings.", "requestBody": { diff --git a/packages/core/src/routes/account/constants.ts b/packages/core/src/routes/account/constants.ts new file mode 100644 index 000000000..0728f936e --- /dev/null +++ b/packages/core/src/routes/account/constants.ts @@ -0,0 +1 @@ +export const accountApiPrefix = '/account'; diff --git a/packages/core/src/routes/profile/email-and-phone.ts b/packages/core/src/routes/account/email-and-phone.ts similarity index 96% rename from packages/core/src/routes/profile/email-and-phone.ts rename to packages/core/src/routes/account/email-and-phone.ts index accd2aa78..7ddb40f3a 100644 --- a/packages/core/src/routes/profile/email-and-phone.ts +++ b/packages/core/src/routes/account/email-and-phone.ts @@ -9,6 +9,8 @@ import { buildVerificationRecordByIdAndType } from '../../libraries/verification import assertThat from '../../utils/assert-that.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +import { accountApiPrefix } from './constants.js'; + export default function emailAndPhoneRoutes(...args: RouterInitArgs) { const [router, { queries, libraries }] = args; const { @@ -21,7 +23,7 @@ export default function emailAndPhoneRoutes(...args: Route } = libraries; router.post( - '/profile/primary-email', + `${accountApiPrefix}/primary-email`, koaGuard({ body: z.object({ email: z.string().regex(emailRegEx), @@ -67,7 +69,7 @@ export default function emailAndPhoneRoutes(...args: Route ); router.delete( - '/profile/primary-email', + `${accountApiPrefix}/primary-email`, koaGuard({ status: [204, 400, 401], }), @@ -106,7 +108,7 @@ export default function emailAndPhoneRoutes(...args: Route ); router.post( - '/profile/primary-phone', + `${accountApiPrefix}/primary-phone`, koaGuard({ body: z.object({ phone: z.string().regex(phoneRegEx), @@ -152,7 +154,7 @@ export default function emailAndPhoneRoutes(...args: Route ); router.delete( - '/profile/primary-phone', + `${accountApiPrefix}/primary-phone`, koaGuard({ status: [204, 400, 401], }), diff --git a/packages/core/src/routes/profile/identities.ts b/packages/core/src/routes/account/identities.ts similarity index 96% rename from packages/core/src/routes/profile/identities.ts rename to packages/core/src/routes/account/identities.ts index ac515bdb9..1582109a0 100644 --- a/packages/core/src/routes/profile/identities.ts +++ b/packages/core/src/routes/account/identities.ts @@ -9,6 +9,8 @@ import { buildVerificationRecordByIdAndType } from '../../libraries/verification import assertThat from '../../utils/assert-that.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +import { accountApiPrefix } from './constants.js'; + export default function identitiesRoutes( ...[router, { queries, libraries }]: RouterInitArgs ) { @@ -21,7 +23,7 @@ export default function identitiesRoutes( } = libraries; router.post( - '/profile/identities', + `${accountApiPrefix}/identities`, koaGuard({ body: z.object({ newIdentifierVerificationRecordId: z.string(), @@ -81,7 +83,7 @@ export default function identitiesRoutes( ); router.delete( - '/profile/identities/:target', + `${accountApiPrefix}/identities/:target`, koaGuard({ params: z.object({ target: z.string() }), status: [204, 400, 401, 404], diff --git a/packages/core/src/routes/profile/index.openapi.json b/packages/core/src/routes/account/index.openapi.json similarity index 95% rename from packages/core/src/routes/profile/index.openapi.json rename to packages/core/src/routes/account/index.openapi.json index d6aea6bcd..816e689cf 100644 --- a/packages/core/src/routes/profile/index.openapi.json +++ b/packages/core/src/routes/account/index.openapi.json @@ -1,15 +1,15 @@ { "tags": [ { - "name": "Profile", - "description": "Profile routes provide functionality for managing user profiles for the end user to interact directly with access tokens." + "name": "Account", + "description": "Account routes provide functionality for managing user profile for the end user to interact directly with access tokens." }, { "name": "Dev feature" } ], "paths": { - "/api/profile": { + "/api/account": { "get": { "operationId": "GetProfile", "summary": "Get profile", @@ -56,7 +56,7 @@ } } }, - "/api/profile/profile": { + "/api/account/profile": { "patch": { "operationId": "UpdateOtherProfile", "summary": "Update other profile", @@ -114,7 +114,7 @@ } } }, - "/api/profile/password": { + "/api/account/password": { "post": { "operationId": "UpdatePassword", "summary": "Update password", @@ -142,7 +142,7 @@ } } }, - "/api/profile/primary-email": { + "/api/account/primary-email": { "post": { "operationId": "UpdatePrimaryEmail", "summary": "Update primary email", @@ -186,7 +186,7 @@ } } }, - "/api/profile/primary-phone": { + "/api/account/primary-phone": { "post": { "operationId": "UpdatePrimaryPhone", "summary": "Update primary phone", @@ -230,7 +230,7 @@ } } }, - "/api/profile/identities": { + "/api/account/identities": { "post": { "operationId": "AddUserIdentities", "summary": "Add a user identity", @@ -255,7 +255,7 @@ } } }, - "/api/profile/identities/{target}": { + "/api/account/identities/{target}": { "delete": { "operationId": "DeleteIdentity", "summary": "Delete a user identity", diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/account/index.ts similarity index 95% rename from packages/core/src/routes/profile/index.ts rename to packages/core/src/routes/account/index.ts index ec74c7c48..5863e0957 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/account/index.ts @@ -16,12 +16,13 @@ import assertThat from '../../utils/assert-that.js'; import { PasswordValidator } from '../experience/classes/libraries/password-validator.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +import { accountApiPrefix } from './constants.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(...args: RouterInitArgs) { +export default function accountRoutes(...args: RouterInitArgs) { const [router, { queries, libraries }] = args; const { users: { updateUserById, findUserById }, @@ -39,7 +40,7 @@ export default function profileRoutes(...args: RouterInitA } router.get( - '/profile', + `${accountApiPrefix}`, koaGuard({ response: userProfileResponseGuard.partial(), status: [200], @@ -53,7 +54,7 @@ export default function profileRoutes(...args: RouterInitA ); router.patch( - '/profile', + `${accountApiPrefix}`, koaGuard({ body: z.object({ name: z.string().nullable().optional(), @@ -111,7 +112,7 @@ export default function profileRoutes(...args: RouterInitA ); router.patch( - '/profile/profile', + `${accountApiPrefix}/profile`, koaGuard({ body: userProfileGuard, response: userProfileGuard, @@ -146,7 +147,7 @@ export default function profileRoutes(...args: RouterInitA ); router.post( - '/profile/password', + `${accountApiPrefix}/password`, koaGuard({ body: z.object({ password: z.string().min(1) }), status: [204, 400, 401, 422], diff --git a/packages/core/src/routes/profile/middlewares/koa-account-center.ts b/packages/core/src/routes/account/middlewares/koa-account-center.ts similarity index 100% rename from packages/core/src/routes/profile/middlewares/koa-account-center.ts rename to packages/core/src/routes/account/middlewares/koa-account-center.ts diff --git a/packages/core/src/routes/profile/utils/get-scoped-profile.ts b/packages/core/src/routes/account/utils/get-scoped-profile.ts similarity index 100% rename from packages/core/src/routes/profile/utils/get-scoped-profile.ts rename to packages/core/src/routes/account/utils/get-scoped-profile.ts diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 96a6879e8..3c62df56b 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -13,6 +13,8 @@ import koaAuth from '../middleware/koa-auth/index.js'; import koaOidcAuth from '../middleware/koa-auth/koa-oidc-auth.js'; import koaCors from '../middleware/koa-cors.js'; +import { accountApiPrefix } from './account/constants.js'; +import accountRoutes from './account/index.js'; import accountCentersRoutes from './account-center/index.js'; import adminUserRoutes from './admin-user/index.js'; import applicationOrganizationRoutes from './applications/application-organization.js'; @@ -34,7 +36,6 @@ import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; import logtoConfigRoutes from './logto-config/index.js'; import organizationRoutes from './organization/index.js'; -import profileRoutes from './profile/index.js'; import resourceRoutes from './resource.js'; import resourceScopeRoutes from './resource.scope.js'; import roleRoutes from './role.js'; @@ -47,7 +48,7 @@ import swaggerRoutes from './swagger/index.js'; import systemRoutes from './system.js'; import type { AnonymousRouter, ManagementApiRouter, UserRouter } from './types.js'; import userAssetsRoutes from './user-assets.js'; -import verificationRoutes from './verification/index.js'; +import verificationRoutes, { verificationApiPrefix } from './verification/index.js'; import verificationCodeRoutes from './verification-code.js'; import wellKnownRoutes from './well-known/index.js'; import wellKnownOpenApiRoutes from './well-known/well-known.openapi.js'; @@ -107,7 +108,7 @@ const createRouters = (tenant: TenantContext) => { userRouter.use(koaOidcAuth(tenant)); // TODO(LOG-10147): Rename to koaApiHooks, this middleware is used for both management API and user API userRouter.use(koaManagementApiHooks(tenant.libraries.hooks)); - profileRoutes(userRouter, tenant); + accountRoutes(userRouter, tenant); verificationRoutes(userRouter, tenant); wellKnownRoutes(anonymousRouter, tenant); @@ -136,7 +137,7 @@ const createRouters = (tenant: TenantContext) => { export default function initApis(tenant: TenantContext): Koa { const apisApp = new Koa(); const { adminUrlSet, cloudUrlSet } = EnvSet.values; - apisApp.use(koaCors([adminUrlSet, cloudUrlSet], ['/profile', '/verifications'])); + apisApp.use(koaCors([adminUrlSet, cloudUrlSet], [accountApiPrefix, verificationApiPrefix])); apisApp.use(koaBodyEtag()); for (const router of createRouters(tenant)) { diff --git a/packages/core/src/routes/swagger/utils/operation-id.ts b/packages/core/src/routes/swagger/utils/operation-id.ts index 7f206a184..2d0d8d55c 100644 --- a/packages/core/src/routes/swagger/utils/operation-id.ts +++ b/packages/core/src/routes/swagger/utils/operation-id.ts @@ -124,7 +124,7 @@ const exceptionPrefixes = Object.freeze([ '/interaction', '/experience', '/sign-in-exp/default/check-password', - '/profile', + '/account', '/verifications', ]); diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index 942bcdefc..016ce6b8f 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -7,7 +7,7 @@ import type { WithI18nContext } from '#src/middleware/koa-i18next.js'; import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js'; import type TenantContext from '#src/tenants/TenantContext.js'; -import { type WithAccountCenterContext } from './profile/middlewares/koa-account-center.js'; +import { type WithAccountCenterContext } from './account/middlewares/koa-account-center.js'; export type AnonymousRouter = Router; diff --git a/packages/core/src/routes/verification/index.ts b/packages/core/src/routes/verification/index.ts index ebbfc2927..0b31e4b5f 100644 --- a/packages/core/src/routes/verification/index.ts +++ b/packages/core/src/routes/verification/index.ts @@ -24,6 +24,8 @@ import { PasswordVerification } from '../experience/classes/verifications/passwo import { SocialVerification } from '../experience/classes/verifications/social-verification.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; +export const verificationApiPrefix = '/verifications'; + export default function verificationRoutes( ...[router, tenantContext]: RouterInitArgs ) { @@ -34,7 +36,7 @@ export default function verificationRoutes( } router.post( - '/verifications/password', + `${verificationApiPrefix}/password`, koaGuard({ body: z.object({ password: z.string().min(1) }), response: z.object({ verificationRecordId: z.string(), expiresAt: z.string() }), @@ -76,7 +78,7 @@ export default function verificationRoutes( ); router.post( - '/verifications/verification-code', + `${verificationApiPrefix}/verification-code`, koaGuard({ body: z.object({ identifier: verificationCodeIdentifierGuard, @@ -119,7 +121,7 @@ export default function verificationRoutes( ); router.post( - '/verifications/verification-code/verify', + `${verificationApiPrefix}/verification-code/verify`, koaGuard({ body: z.object({ identifier: verificationCodeIdentifierGuard, @@ -164,7 +166,7 @@ export default function verificationRoutes( ); router.post( - '/verifications/social', + `${verificationApiPrefix}/social`, koaGuard({ body: socialAuthorizationUrlPayloadGuard.extend({ connectorId: z.string(), @@ -202,7 +204,7 @@ export default function verificationRoutes( ); router.post( - '/verifications/social/verify', + `${verificationApiPrefix}/social/verify`, koaGuard({ body: socialVerificationCallbackPayloadGuard .pick({ diff --git a/packages/core/src/routes/well-known/well-known.openapi.ts b/packages/core/src/routes/well-known/well-known.openapi.ts index 5f1f6b7b2..ae6e34041 100644 --- a/packages/core/src/routes/well-known/well-known.openapi.ts +++ b/packages/core/src/routes/well-known/well-known.openapi.ts @@ -31,7 +31,7 @@ export default function openapiRoutes - api.post('api/profile/password', { + api.post('api/account/password', { json: { password }, headers: { [verificationRecordIdHeader]: verificationRecordId }, }); @@ -19,13 +19,13 @@ export const updatePrimaryEmail = async ( verificationRecordId: string, newIdentifierVerificationRecordId: string ) => - api.post('api/profile/primary-email', { + api.post('api/account/primary-email', { json: { email, newIdentifierVerificationRecordId }, headers: { [verificationRecordIdHeader]: verificationRecordId }, }); export const deletePrimaryEmail = async (api: KyInstance, verificationRecordId: string) => - api.delete('api/profile/primary-email', { + api.delete('api/account/primary-email', { headers: { [verificationRecordIdHeader]: verificationRecordId }, }); @@ -35,13 +35,13 @@ export const updatePrimaryPhone = async ( verificationRecordId: string, newIdentifierVerificationRecordId: string ) => - api.post('api/profile/primary-phone', { + api.post('api/account/primary-phone', { json: { phone, newIdentifierVerificationRecordId }, headers: { [verificationRecordIdHeader]: verificationRecordId }, }); export const deletePrimaryPhone = async (api: KyInstance, verificationRecordId: string) => - api.delete('api/profile/primary-phone', { + api.delete('api/account/primary-phone', { headers: { [verificationRecordIdHeader]: verificationRecordId }, }); @@ -50,7 +50,7 @@ export const updateIdentities = async ( verificationRecordId: string, newIdentifierVerificationRecordId: string ) => - api.post('api/profile/identities', { + api.post('api/account/identities', { json: { newIdentifierVerificationRecordId }, headers: { [verificationRecordIdHeader]: verificationRecordId }, }); @@ -60,15 +60,15 @@ export const deleteIdentity = async ( target: string, verificationRecordId: string ) => - api.delete(`api/profile/identities/${target}`, { + api.delete(`api/account/identities/${target}`, { headers: { [verificationRecordIdHeader]: verificationRecordId }, }); export const updateUser = async (api: KyInstance, body: Record) => - api.patch('api/profile', { json: body }).json>(); + api.patch('api/account', { json: body }).json>(); export const updateOtherProfile = async (api: KyInstance, body: Record) => - api.patch('api/profile/profile', { json: body }).json>(); + api.patch('api/account/profile', { json: body }).json>(); export const getUserInfo = async (api: KyInstance) => - api.get('api/profile').json>(); + api.get('api/account').json>(); diff --git a/packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts b/packages/integration-tests/src/tests/api/account/account-center-reject.test.ts similarity index 96% rename from packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts rename to packages/integration-tests/src/tests/api/account/account-center-reject.test.ts index 31aa24df1..e4c7ea6ea 100644 --- a/packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts +++ b/packages/integration-tests/src/tests/api/account/account-center-reject.test.ts @@ -30,7 +30,7 @@ const expectedError = { status: 400, }; -describe('profile, account center fields disabled', () => { +describe('account center fields disabled', () => { beforeAll(async () => { await enableAllPasswordSignInMethods(); await updateAccountCenter({ @@ -42,7 +42,7 @@ describe('profile, account center fields disabled', () => { }); }); - it('should return only name in GET /profile', async () => { + it('should return only name in GET /account', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password, { scopes: [UserScope.Email], diff --git a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts b/packages/integration-tests/src/tests/api/account/email-and-phone.test.ts similarity index 60% rename from packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts rename to packages/integration-tests/src/tests/api/account/email-and-phone.test.ts index e24c7cd55..c776e978c 100644 --- a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts +++ b/packages/integration-tests/src/tests/api/account/email-and-phone.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { UserScope } from '@logto/core-kit'; import { SignInIdentifier } from '@logto/schemas'; @@ -26,7 +27,7 @@ import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js'; const { describe, it } = devFeatureTest; -describe('profile (email and phone)', () => { +describe('account (email and phone)', () => { beforeAll(async () => { await enableAllPasswordSignInMethods(); await setEmailConnector(authedAdminApi); @@ -34,7 +35,7 @@ describe('profile (email and phone)', () => { await enableAllAccountCenterFields(authedAdminApi); }); - describe('POST /profile/primary-email', () => { + describe('POST /account/primary-email', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -141,7 +142,7 @@ describe('profile (email and phone)', () => { }); }); - describe('DELETE /profile/primary-email', () => { + describe('DELETE /account/primary-email', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -237,20 +238,112 @@ describe('profile (email and phone)', () => { }); }); - describe('POST /profile/primary-phone', () => { + describe('POST /account/primary-phone', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); - const newPhone = generatePhone(); const verificationRecordId = await createVerificationRecordByPassword(api, password); - await expectRejects( - updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'), - { - code: 'auth.unauthorized', - status: 400, - } - ); + 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('DELETE /account/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); }); @@ -260,50 +353,171 @@ describe('profile (email and phone)', () => { const api = await signInAndGetUserApi(username, password, { scopes: [UserScope.Profile, UserScope.Phone], }); - const newPhone = generatePhone(); - await expectRejects( - updatePrimaryPhone( - api, - newPhone, - 'invalid-verification-record-id', - 'new-verification-record-id' - ), - { - code: 'verification_record.permission_denied', - status: 401, - } - ); + await expectRejects(deletePrimaryPhone(api, 'invalid-verification-record-id'), { + code: 'verification_record.permission_denied', + status: 401, + }); await deleteDefaultTenantUser(user.id); }); - it('should fail if new identifier verification record is invalid', async () => { + 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 newPhone = generatePhone(); const verificationRecordId = await createVerificationRecordByPassword(api, password); + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Phone], + password: true, + verify: true, + }); - await expectRejects( - updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'), - { - code: 'verification_record.not_found', - status: 400, - } - ); + await expectRejects(deletePrimaryPhone(api, verificationRecordId), { + code: 'user.phone_required', + status: 400, + }); + await enableAllPasswordSignInMethods(); await deleteDefaultTenantUser(user.id); }); - it('should be able to update primary phone by verifying password', async () => { + 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 newPhone = generatePhone(); 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); + }); + + describe('POST /account/primary-phone', () => { + it('should fail if scope is missing', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + const newPhone = generatePhone(); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + + await expectRejects( + updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'), + { + 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], + }); + const newPhone = generatePhone(); + + await expectRejects( + updatePrimaryPhone( + api, + newPhone, + 'invalid-verification-record-id', + 'new-verification-record-id' + ), + { + code: 'verification_record.permission_denied', + status: 401, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail if new identifier verification record is invalid', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + + await expectRejects( + updatePrimaryPhone(api, newPhone, verificationRecordId, 'new-verification-record-id'), + { + code: 'verification_record.not_found', + status: 400, + } + ); + + await deleteDefaultTenantUser(user.id); + }); + + it('should be able to update primary phone by verifying password', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createVerificationRecordByPassword(api, password); + 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 deleteDefaultTenantUser(user.id); + }); + + it('should be able to update primary phone by verifying existing phone', async () => { + const primaryPhone = generatePhone(); + const { user, username, password } = await createDefaultTenantUserWithPassword({ + primaryPhone, + }); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Profile, UserScope.Phone], + }); + const newPhone = generatePhone(); + const verificationRecordId = await createAndVerifyVerificationCode(api, { + type: SignInIdentifier.Phone, + value: primaryPhone, + }); + 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 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, @@ -314,37 +528,16 @@ describe('profile (email and phone)', () => { const userInfo = await getUserInfo(api); expect(userInfo).toHaveProperty('primaryPhone', newPhone); - await deleteDefaultTenantUser(user.id); - }); + await deletePrimaryPhone(api, verificationRecordId); - it('should be able to update primary phone by verifying existing phone', async () => { - const primaryPhone = generatePhone(); - const { user, username, password } = await createDefaultTenantUserWithPassword({ - primaryPhone, - }); - const api = await signInAndGetUserApi(username, password, { - scopes: [UserScope.Profile, UserScope.Phone], - }); - const newPhone = generatePhone(); - const verificationRecordId = await createAndVerifyVerificationCode(api, { - type: SignInIdentifier.Phone, - value: primaryPhone, - }); - 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); + const userInfoAfterDelete = await getUserInfo(api); + expect(userInfoAfterDelete).toHaveProperty('primaryPhone', null); await deleteDefaultTenantUser(user.id); }); }); - describe('DELETE /profile/primary-phone', () => { + describe('DELETE /account/primary-phone', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -440,3 +633,4 @@ describe('profile (email and phone)', () => { }); }); }); +/* eslint-enable max-lines */ diff --git a/packages/integration-tests/src/tests/api/profile/index.test.ts b/packages/integration-tests/src/tests/api/account/index.test.ts similarity index 97% rename from packages/integration-tests/src/tests/api/profile/index.test.ts rename to packages/integration-tests/src/tests/api/account/index.test.ts index 79e09e0f3..857dfe3e4 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/account/index.test.ts @@ -21,10 +21,10 @@ import { assertHookLogResult } from '../hook/utils.js'; const { describe, it } = devFeatureTest; -describe('profile', () => { +describe('account', () => { const webHookMockServer = new WebhookMockServer(9999); const webHookApi = new WebHookApiTest(); - const hookName = 'profileApiHookEventListener'; + const hookName = 'accountApiHookEventListener'; beforeAll(async () => { await webHookMockServer.listen(); @@ -48,11 +48,11 @@ describe('profile', () => { await webHookApi.cleanUp(); }); - describe('GET /profile', () => { + describe('GET /account', () => { it('should allow all origins', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); - const response = await api.get('api/profile'); + const response = await api.get('api/account'); expect(response.status).toBe(200); expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); @@ -121,7 +121,7 @@ describe('profile', () => { }); }); - describe('PATCH /profile', () => { + describe('PATCH /account', () => { it('should be able to update name', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -211,7 +211,7 @@ describe('profile', () => { }); }); - describe('PATCH /profile/profile', () => { + describe('PATCH /account/profile', () => { it('should be able to update other profile', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -258,7 +258,7 @@ describe('profile', () => { }); }); - describe('POST /profile/password', () => { + describe('POST /account/password', () => { it('should fail if verification record is invalid', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); diff --git a/packages/integration-tests/src/tests/api/profile/social.test.ts b/packages/integration-tests/src/tests/api/account/social.test.ts similarity index 98% rename from packages/integration-tests/src/tests/api/profile/social.test.ts rename to packages/integration-tests/src/tests/api/account/social.test.ts index 2e90df665..f7c9d14a5 100644 --- a/packages/integration-tests/src/tests/api/profile/social.test.ts +++ b/packages/integration-tests/src/tests/api/account/social.test.ts @@ -29,7 +29,7 @@ import { devFeatureTest } from '#src/utils.js'; const { describe, it } = devFeatureTest; -describe('profile (social)', () => { +describe('account (social)', () => { const state = 'fake_state'; const redirectUri = 'http://localhost:3000/redirect'; const authorizationCode = 'fake_code'; @@ -50,7 +50,7 @@ describe('profile (social)', () => { await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email]); }); - describe('POST /profile/identities', () => { + describe('POST /account/identities', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); @@ -169,7 +169,7 @@ describe('profile (social)', () => { }); }); - describe('DELETE /profile/identities/:target', () => { + describe('DELETE /account/identities/:target', () => { it('should fail if scope is missing', async () => { const { user, username, password } = await createDefaultTenantUserWithPassword(); const api = await signInAndGetUserApi(username, password); diff --git a/packages/schemas/src/foundations/jsonb-types/account-center.ts b/packages/schemas/src/foundations/jsonb-types/account-center.ts deleted file mode 100644 index 99b1d3c3f..000000000 --- a/packages/schemas/src/foundations/jsonb-types/account-center.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from 'zod'; - -export enum AccountCenterControlValue { - Off = 'Off', - ReadOnly = 'ReadOnly', - Edit = 'Edit', -} - -// Control list of each field in the account center (profile API) -// all fields are optional, if not set, the default value is `Off` -// this can make the alteration of the field control easier -export const accountCenterFieldControlGuard = z - .object({ - name: z.nativeEnum(AccountCenterControlValue), - avatar: z.nativeEnum(AccountCenterControlValue), - profile: z.nativeEnum(AccountCenterControlValue), - email: z.nativeEnum(AccountCenterControlValue), - phone: z.nativeEnum(AccountCenterControlValue), - password: z.nativeEnum(AccountCenterControlValue), - username: z.nativeEnum(AccountCenterControlValue), - social: z.nativeEnum(AccountCenterControlValue), - customData: z.nativeEnum(AccountCenterControlValue), - }) - .partial(); - -export type AccountCenterFieldControl = z.infer;