mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
Merge pull request #2399 from logto-io/charles-log-4082-session-api-change-username
feat(core): add change username session api
This commit is contained in:
commit
4fa7a9e421
5 changed files with 176 additions and 6 deletions
|
@ -23,7 +23,7 @@ import {
|
||||||
} from '@/queries/user';
|
} from '@/queries/user';
|
||||||
import assertThat from '@/utils/assert-that';
|
import assertThat from '@/utils/assert-that';
|
||||||
|
|
||||||
import { checkExistingSignUpIdentifiers } from './session/utils';
|
import { checkSignUpIdentifierCollision } from './session/utils';
|
||||||
import type { AuthedRouter } from './types';
|
import type { AuthedRouter } from './types';
|
||||||
|
|
||||||
export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
||||||
|
@ -187,7 +187,7 @@ export default function adminUserRoutes<T extends AuthedRouter>(router: T) {
|
||||||
} = ctx.guard;
|
} = ctx.guard;
|
||||||
|
|
||||||
await findUserById(userId);
|
await findUserById(userId);
|
||||||
await checkExistingSignUpIdentifiers(body, userId);
|
await checkSignUpIdentifierCollision(body, userId);
|
||||||
|
|
||||||
// Temp solution to validate the existence of input roleNames
|
// Temp solution to validate the existence of input roleNames
|
||||||
if (body.roleNames?.length) {
|
if (body.roleNames?.length) {
|
||||||
|
|
|
@ -18,6 +18,7 @@ import forgotPasswordRoutes from './forgot-password';
|
||||||
import koaGuardSessionAction from './middleware/koa-guard-session-action';
|
import koaGuardSessionAction from './middleware/koa-guard-session-action';
|
||||||
import passwordRoutes from './password';
|
import passwordRoutes from './password';
|
||||||
import passwordlessRoutes from './passwordless';
|
import passwordlessRoutes from './passwordless';
|
||||||
|
import profileRoutes from './profile';
|
||||||
import socialRoutes from './social';
|
import socialRoutes from './social';
|
||||||
import { getRoutePrefix } from './utils';
|
import { getRoutePrefix } from './utils';
|
||||||
|
|
||||||
|
@ -51,8 +52,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
||||||
|
|
||||||
const { accountId } = session;
|
const { accountId } = session;
|
||||||
|
|
||||||
// Temp solution before migrating to RBAC. Block non-admin user from consent to admin console
|
// Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console
|
||||||
|
|
||||||
if (String(client_id) === adminConsoleApplicationId) {
|
if (String(client_id) === adminConsoleApplicationId) {
|
||||||
const { roleNames } = await findUserById(accountId);
|
const { roleNames } = await findUserById(accountId);
|
||||||
|
|
||||||
|
@ -105,6 +105,6 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
||||||
passwordlessRoutes(router, provider);
|
passwordlessRoutes(router, provider);
|
||||||
socialRoutes(router, provider);
|
socialRoutes(router, provider);
|
||||||
continueRoutes(router, provider);
|
continueRoutes(router, provider);
|
||||||
|
|
||||||
forgotPasswordRoutes(router, provider);
|
forgotPasswordRoutes(router, provider);
|
||||||
|
profileRoutes(router, provider);
|
||||||
}
|
}
|
||||||
|
|
93
packages/core/src/routes/session/profile.test.ts
Normal file
93
packages/core/src/routes/session/profile.test.ts
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import type { CreateUser, User } from '@logto/schemas';
|
||||||
|
import { SignUpIdentifier } from '@logto/schemas';
|
||||||
|
import { getUnixTime } from 'date-fns';
|
||||||
|
import { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import { mockUser, mockUserResponse } from '@/__mocks__';
|
||||||
|
import { createRequester } from '@/utils/test-utils';
|
||||||
|
|
||||||
|
import profileRoutes, { profileRoute } from './profile';
|
||||||
|
|
||||||
|
const mockFindUserById = jest.fn(async (): Promise<User> => mockUser);
|
||||||
|
const mockHasUser = jest.fn(async () => false);
|
||||||
|
const mockHasUserWithEmail = jest.fn(async () => false);
|
||||||
|
const mockHasUserWithPhone = jest.fn(async () => false);
|
||||||
|
const mockUpdateUserById = jest.fn(
|
||||||
|
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||||
|
...mockUser,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('oidc-provider', () => ({
|
||||||
|
Provider: jest.fn(() => ({
|
||||||
|
Session: {
|
||||||
|
get: jest.fn(async () => ({ accountId: 'id', loginTs: getUnixTime(new Date()) - 60 })),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/queries/user', () => ({
|
||||||
|
...jest.requireActual('@/queries/user'),
|
||||||
|
findUserById: async () => mockFindUserById(),
|
||||||
|
hasUser: async () => mockHasUser(),
|
||||||
|
hasUserWithEmail: async () => mockHasUserWithEmail(),
|
||||||
|
hasUserWithPhone: async () => mockHasUserWithPhone(),
|
||||||
|
updateUserById: async (id: string, data: Partial<CreateUser>) => mockUpdateUserById(id, data),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFindDefaultSignInExperience = jest.fn(async () => ({
|
||||||
|
signUp: {
|
||||||
|
identifier: SignUpIdentifier.None,
|
||||||
|
password: false,
|
||||||
|
verify: false,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@/queries/sign-in-experience', () => ({
|
||||||
|
findDefaultSignInExperience: jest.fn(async () => mockFindDefaultSignInExperience()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('session -> profileRoutes', () => {
|
||||||
|
const sessionRequest = createRequester({
|
||||||
|
anonymousRoutes: profileRoutes,
|
||||||
|
provider: new Provider(''),
|
||||||
|
middlewares: [
|
||||||
|
async (ctx, next) => {
|
||||||
|
ctx.addLogContext = jest.fn();
|
||||||
|
ctx.log = jest.fn();
|
||||||
|
|
||||||
|
return next();
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /session/profile should return current user data', async () => {
|
||||||
|
const response = await sessionRequest.get(profileRoute);
|
||||||
|
expect(response.statusCode).toEqual(200);
|
||||||
|
expect(response.body).toEqual(mockUserResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /session/profile/username', () => {
|
||||||
|
it('should update username with the new value', async () => {
|
||||||
|
const newUsername = 'charles';
|
||||||
|
|
||||||
|
const response = await sessionRequest
|
||||||
|
.patch(`${profileRoute}/username`)
|
||||||
|
.send({ username: newUsername });
|
||||||
|
|
||||||
|
expect(response.statusCode).toEqual(200);
|
||||||
|
expect(response.body).toEqual({ ...mockUserResponse, username: newUsername });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when username is already in use', async () => {
|
||||||
|
mockHasUser.mockImplementationOnce(async () => true);
|
||||||
|
|
||||||
|
const response = await sessionRequest
|
||||||
|
.patch(`${profileRoute}/username`)
|
||||||
|
.send({ username: 'test' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toEqual(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
55
packages/core/src/routes/session/profile.ts
Normal file
55
packages/core/src/routes/session/profile.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { usernameRegEx } from '@logto/core-kit';
|
||||||
|
import { userInfoSelectFields } from '@logto/schemas';
|
||||||
|
import pick from 'lodash.pick';
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
import { object, string } from 'zod';
|
||||||
|
|
||||||
|
import RequestError from '@/errors/RequestError';
|
||||||
|
import { checkSessionHealth } from '@/lib/session';
|
||||||
|
import koaGuard from '@/middleware/koa-guard';
|
||||||
|
import { findUserById, updateUserById } from '@/queries/user';
|
||||||
|
import assertThat from '@/utils/assert-that';
|
||||||
|
|
||||||
|
import type { AnonymousRouter } from '../types';
|
||||||
|
import { verificationTimeout } from './consts';
|
||||||
|
import { checkSignUpIdentifierCollision } from './utils';
|
||||||
|
|
||||||
|
export const profileRoute = '/session/profile';
|
||||||
|
|
||||||
|
export default function profileRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
|
||||||
|
router.get(profileRoute, async (ctx, next) => {
|
||||||
|
const { accountId } = await provider.Session.get(ctx);
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
throw new RequestError('auth.unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserById(accountId);
|
||||||
|
|
||||||
|
ctx.body = pick(user, ...userInfoSelectFields);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
`${profileRoute}/username`,
|
||||||
|
koaGuard({
|
||||||
|
body: object({ username: string().regex(usernameRegEx) }),
|
||||||
|
}),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const userId = await checkSessionHealth(ctx, provider, verificationTimeout);
|
||||||
|
|
||||||
|
assertThat(userId, new RequestError('auth.unauthorized'));
|
||||||
|
|
||||||
|
const { username } = ctx.guard.body;
|
||||||
|
|
||||||
|
await checkSignUpIdentifierCollision({ username }, userId);
|
||||||
|
|
||||||
|
const user = await updateUserById(userId, { username }, 'replace');
|
||||||
|
|
||||||
|
ctx.body = pick(user, ...userInfoSelectFields);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -196,9 +196,31 @@ export const checkRequiredProfile = async (
|
||||||
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
|
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const checkMissingRequiredSignUpIdentifiers = async (identifiers: {
|
||||||
|
primaryEmail?: Nullable<string>;
|
||||||
|
primaryPhone?: Nullable<string>;
|
||||||
|
}) => {
|
||||||
|
// We do not check username as we decided to prohibit the removal of username from user profile.
|
||||||
|
const { primaryEmail, primaryPhone } = identifiers;
|
||||||
|
|
||||||
|
const { signUp } = await getSignInExperienceForApplication();
|
||||||
|
|
||||||
|
if (signUp.identifier === SignUpIdentifier.Email && !primaryEmail) {
|
||||||
|
throw new RequestError({ code: 'user.require_email', status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signUp.identifier === SignUpIdentifier.Sms && !primaryPhone) {
|
||||||
|
throw new RequestError({ code: 'user.require_sms', status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signUp.identifier === SignUpIdentifier.EmailOrSms && !primaryEmail && !primaryPhone) {
|
||||||
|
throw new RequestError({ code: 'user.require_email_or_sms', status: 422 });
|
||||||
|
}
|
||||||
|
};
|
||||||
/* eslint-enable complexity */
|
/* eslint-enable complexity */
|
||||||
|
|
||||||
export const checkExistingSignUpIdentifiers = async (
|
export const checkSignUpIdentifierCollision = async (
|
||||||
identifiers: {
|
identifiers: {
|
||||||
username?: Nullable<string>;
|
username?: Nullable<string>;
|
||||||
primaryEmail?: Nullable<string>;
|
primaryEmail?: Nullable<string>;
|
||||||
|
|
Loading…
Reference in a new issue