mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core,ui): remove identity profile related apis and pages (#2897)
This commit is contained in:
parent
5d862f617f
commit
1b998b7e62
16 changed files with 2 additions and 1239 deletions
|
@ -1,13 +1,9 @@
|
|||
import { getUnixTime } from 'date-fns';
|
||||
import type { Context } from 'koa';
|
||||
import type { InteractionResults } from 'oidc-provider';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { findUserById, updateUserById } from '#src/queries/user.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
export const assignInteractionResults = async (
|
||||
ctx: Context,
|
||||
|
@ -36,31 +32,6 @@ export const assignInteractionResults = async (
|
|||
ctx.body = { redirectTo };
|
||||
};
|
||||
|
||||
export const checkSessionHealth = async (
|
||||
ctx: Context,
|
||||
{ provider, queries: { users } }: TenantContext,
|
||||
tolerance = 10 * 60 // 10 mins
|
||||
) => {
|
||||
const { accountId, loginTs } = await provider.Session.get(ctx);
|
||||
|
||||
assertThat(
|
||||
accountId,
|
||||
new RequestError({ code: 'auth.unauthorized', id: accountId, status: 401 })
|
||||
);
|
||||
|
||||
if (!loginTs || loginTs < getUnixTime(new Date()) - tolerance) {
|
||||
const { passwordEncrypted, primaryPhone, primaryEmail } = await users.findUserById(accountId);
|
||||
|
||||
// No authenticated method configured for this user. Pass!
|
||||
if (!passwordEncrypted && !primaryPhone && !primaryEmail) {
|
||||
return;
|
||||
}
|
||||
throw new RequestError({ code: 'auth.require_re_authentication', status: 422 });
|
||||
}
|
||||
|
||||
return accountId;
|
||||
};
|
||||
|
||||
export const saveUserFirstConsentedAppId = async (userId: string, applicationId: string) => {
|
||||
const { applicationId: firstConsentedAppId } = await findUserById(userId);
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ import hookRoutes from './hook.js';
|
|||
import interactionRoutes from './interaction/index.js';
|
||||
import logRoutes from './log.js';
|
||||
import phraseRoutes from './phrase.js';
|
||||
import profileRoutes from './profile.js';
|
||||
import resourceRoutes from './resource.js';
|
||||
import roleRoutes from './role.js';
|
||||
import settingRoutes from './setting.js';
|
||||
|
@ -45,23 +44,15 @@ const createRouters = (tenant: TenantContext) => {
|
|||
customPhraseRoutes(managementRouter, tenant);
|
||||
hookRoutes(managementRouter, tenant);
|
||||
|
||||
const profileRouter: AnonymousRouter = new Router();
|
||||
profileRoutes(profileRouter, tenant);
|
||||
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
phraseRoutes(anonymousRouter, tenant);
|
||||
wellKnownRoutes(anonymousRouter, tenant);
|
||||
statusRoutes(anonymousRouter, tenant);
|
||||
authnRoutes(anonymousRouter, tenant);
|
||||
// The swagger.json should contain all API routers.
|
||||
swaggerRoutes(anonymousRouter, [
|
||||
interactionRouter,
|
||||
profileRouter,
|
||||
managementRouter,
|
||||
anonymousRouter,
|
||||
]);
|
||||
swaggerRoutes(anonymousRouter, [interactionRouter, managementRouter, anonymousRouter]);
|
||||
|
||||
return [interactionRouter, profileRouter, managementRouter, anonymousRouter];
|
||||
return [interactionRouter, managementRouter, anonymousRouter];
|
||||
};
|
||||
|
||||
export default function initRouter(tenant: TenantContext): Koa {
|
||||
|
|
|
@ -1,486 +0,0 @@
|
|||
import type { SocialUserInfo } from '@logto/connector-kit';
|
||||
import type { CreateUser, User } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
|
||||
import {
|
||||
mockLogtoConnectorList,
|
||||
mockPasswordEncrypted,
|
||||
mockUser,
|
||||
mockUserResponse,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import Queries from '#src/tenants/Queries.js';
|
||||
import { createMockProvider } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
import { createRequester } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
|
||||
|
||||
const mockUserProfileResponse = { ...mockUserResponse, hasPasswordSet: true };
|
||||
const getLogtoConnectorById = jest.fn(async () => ({
|
||||
dbEntry: { enabled: true },
|
||||
metadata: { id: 'connectorId', target: 'mock_social' },
|
||||
type: ConnectorType.Social,
|
||||
getAuthorizationUri: jest.fn(async () => ''),
|
||||
}));
|
||||
|
||||
mockEsm('#src/libraries/connector.js', () => ({
|
||||
getLogtoConnectors: mockLogtoConnectorList,
|
||||
getLogtoConnectorById,
|
||||
}));
|
||||
|
||||
const { getUserInfoByAuthCode } = await mockEsmWithActual('#src/libraries/social.js', () => ({
|
||||
findSocialRelatedUser: jest.fn(async () => [{ id: 'user1', identities: {}, isSuspended: false }]),
|
||||
getUserInfoByAuthCode: jest.fn(),
|
||||
}));
|
||||
|
||||
const usersQueries = {
|
||||
findUserById: jest.fn(async () => mockUser),
|
||||
hasUser: jest.fn(async (): Promise<boolean> => false),
|
||||
hasUserWithEmail: jest.fn(async (): Promise<boolean> => false),
|
||||
hasUserWithPhone: jest.fn(async (): Promise<boolean> => false),
|
||||
updateUserById: jest.fn(
|
||||
async (_, data: Partial<CreateUser>): Promise<User> => ({
|
||||
...mockUser,
|
||||
...data,
|
||||
})
|
||||
),
|
||||
deleteUserIdentity: jest.fn(),
|
||||
} satisfies Partial<Queries['users']>;
|
||||
const {
|
||||
findUserById,
|
||||
hasUser,
|
||||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
updateUserById,
|
||||
deleteUserIdentity,
|
||||
} = usersQueries;
|
||||
|
||||
const { encryptUserPassword } = await mockEsmWithActual('#src/libraries/user.js', () => ({
|
||||
encryptUserPassword: jest.fn(async (password: string) => ({
|
||||
passwordEncrypted: password + '_user1',
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
})),
|
||||
}));
|
||||
|
||||
mockEsm('#src/queries/sign-in-experience.js', () => ({
|
||||
findDefaultSignInExperience: async () => ({
|
||||
signUp: {
|
||||
identifier: [],
|
||||
password: false,
|
||||
verify: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { argon2Verify } = mockEsm('hash-wasm', () => ({
|
||||
argon2Verify: jest.fn(async (password: string) => password === mockPasswordEncrypted),
|
||||
}));
|
||||
|
||||
const { default: profileRoutes, profileRoute } = await import('./profile.js');
|
||||
|
||||
describe('session -> profileRoutes', () => {
|
||||
const provider = createMockProvider();
|
||||
// @ts-expect-error for testing
|
||||
const mockGetSession: jest.Mock = jest.spyOn(provider.Session, 'get');
|
||||
const sessionRequest = createRequester({
|
||||
anonymousRoutes: profileRoutes,
|
||||
tenantContext: new MockTenant(provider, { users: usersQueries }),
|
||||
middlewares: [
|
||||
async (ctx, next) => {
|
||||
ctx.addLogContext = jest.fn();
|
||||
ctx.log = jest.fn();
|
||||
|
||||
return next();
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetSession.mockImplementation(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 60,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('GET /profile', () => {
|
||||
it('should return current user data', async () => {
|
||||
const response = await sessionRequest.get(profileRoute);
|
||||
expect(response.statusCode).toEqual(200);
|
||||
expect(response.body).toEqual(mockUserProfileResponse);
|
||||
});
|
||||
|
||||
it('should throw when the user is not authenticated', async () => {
|
||||
mockGetSession.mockImplementationOnce(
|
||||
jest.fn(async () => ({
|
||||
accountId: undefined,
|
||||
loginTs: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await sessionRequest.get(profileRoute);
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /profile', () => {
|
||||
it('should update current user with display name, avatar and custom data', async () => {
|
||||
const updatedUserInfo = {
|
||||
name: 'John Doe',
|
||||
avatar: 'https://new-avatar.cdn.com',
|
||||
customData: { gender: 'male', age: '30' },
|
||||
};
|
||||
|
||||
const response = await sessionRequest.patch(profileRoute).send(updatedUserInfo);
|
||||
|
||||
expect(updateUserById).toBeCalledWith('id', expect.objectContaining(updatedUserInfo));
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('should throw when the user is not authenticated', async () => {
|
||||
mockGetSession.mockImplementationOnce(
|
||||
jest.fn(async () => ({
|
||||
accountId: undefined,
|
||||
loginTs: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await sessionRequest.patch(profileRoute).send({ name: 'John Doe' });
|
||||
expect(response.statusCode).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /profile/username', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/username`)
|
||||
.send({ username: 'test' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
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(204);
|
||||
});
|
||||
|
||||
it('should throw when username is already in use', async () => {
|
||||
hasUser.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/username`)
|
||||
.send({ username: 'test' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /profile/password', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: mockPasswordEncrypted });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should update password with the new value', async () => {
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: mockPasswordEncrypted });
|
||||
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
passwordEncrypted: 'a1b2c3_user1',
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
})
|
||||
);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('should throw if new password is identical to old password', async () => {
|
||||
encryptUserPassword.mockImplementationOnce(async (password: string) => ({
|
||||
passwordEncrypted: password,
|
||||
passwordEncryptionMethod: 'Argon2i',
|
||||
}));
|
||||
argon2Verify.mockResolvedValueOnce(true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/password`)
|
||||
.send({ password: 'password' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('email related APIs', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago on linking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: 'test@logto.io' });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should link email address to the user profile', async () => {
|
||||
const mockEmailAddress = 'bar@logto.io';
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: mockEmailAddress });
|
||||
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryEmail: mockEmailAddress,
|
||||
})
|
||||
);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('should throw when email address already exists', async () => {
|
||||
hasUserWithEmail.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: mockUser.primaryEmail });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw when email address is invalid', async () => {
|
||||
hasUserWithEmail.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/email`)
|
||||
.send({ primaryEmail: 'test' });
|
||||
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw if last authentication time is over 10 mins ago on unlinking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unlink email address from user', async () => {
|
||||
const response = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryEmail: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when no email address found in user on unlinking email', async () => {
|
||||
findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryEmail: null }));
|
||||
const response = await sessionRequest.delete(`${profileRoute}/email`);
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('phone related APIs', () => {
|
||||
it('should throw if last authentication time is over 10 mins ago on linking phone number', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const updateResponse = await sessionRequest
|
||||
.patch(`${profileRoute}/phone`)
|
||||
.send({ primaryPhone: '6533333333' });
|
||||
|
||||
expect(updateResponse.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should link phone number to the user profile', async () => {
|
||||
const mockPhoneNumber = '6533333333';
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/phone`)
|
||||
.send({ primaryPhone: mockPhoneNumber });
|
||||
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryPhone: mockPhoneNumber,
|
||||
})
|
||||
);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
});
|
||||
|
||||
it('should throw when phone number already exists on linking phone number', async () => {
|
||||
hasUserWithPhone.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/phone`)
|
||||
.send({ primaryPhone: mockUser.primaryPhone });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw when phone number is invalid', async () => {
|
||||
hasUserWithPhone.mockImplementationOnce(async () => true);
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/phone`)
|
||||
.send({ primaryPhone: 'invalid' });
|
||||
|
||||
expect(response.statusCode).toEqual(400);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw if last authentication time is over 10 mins ago on unlinking phone number', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest.delete(`${profileRoute}/phone`);
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unlink phone number from user', async () => {
|
||||
const response = await sessionRequest.delete(`${profileRoute}/phone`);
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
primaryPhone: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when no phone number found in user on unlinking phone number', async () => {
|
||||
findUserById.mockImplementationOnce(async () => ({ ...mockUser, primaryPhone: null }));
|
||||
const response = await sessionRequest.delete(`${profileRoute}/phone`);
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('social identities related APIs', () => {
|
||||
it('should update social identities for current user', async () => {
|
||||
const mockSocialUserInfo: SocialUserInfo = {
|
||||
id: 'social_user_id',
|
||||
name: 'John Doe',
|
||||
avatar: 'https://avatar.social.com/johndoe',
|
||||
email: 'johndoe@social.com',
|
||||
phone: '123456789',
|
||||
};
|
||||
getUserInfoByAuthCode.mockReturnValueOnce(mockSocialUserInfo);
|
||||
|
||||
const response = await sessionRequest.patch(`${profileRoute}/identities`).send({
|
||||
connectorId: 'connectorId',
|
||||
data: { code: '123456' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(updateUserById).toBeCalledWith(
|
||||
'id',
|
||||
expect.objectContaining({
|
||||
identities: {
|
||||
...mockUser.identities,
|
||||
mock_social: { userId: mockSocialUserInfo.id, details: mockSocialUserInfo },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when the user is not authenticated', async () => {
|
||||
mockGetSession.mockImplementationOnce(
|
||||
jest.fn(async () => ({
|
||||
accountId: undefined,
|
||||
loginTs: undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await sessionRequest.patch(`${profileRoute}/identities`).send({
|
||||
connectorId: 'connectorId',
|
||||
data: { code: '123456' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(401);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should throw if last authentication time is over 10 mins ago on linking email', async () => {
|
||||
mockGetSession.mockImplementationOnce(async () => ({
|
||||
accountId: 'id',
|
||||
loginTs: getUnixTime(new Date()) - 601,
|
||||
}));
|
||||
|
||||
const response = await sessionRequest
|
||||
.patch(`${profileRoute}/identities`)
|
||||
.send({ connectorId: 'connectorId', data: { code: '123456' } });
|
||||
|
||||
expect(response.statusCode).toEqual(422);
|
||||
expect(updateUserById).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unlink social identities from user', async () => {
|
||||
findUserById.mockImplementationOnce(async () => ({
|
||||
...mockUser,
|
||||
identities: {
|
||||
mock_social: {
|
||||
userId: 'social_user_id',
|
||||
details: {
|
||||
id: 'social_user_id',
|
||||
name: 'John Doe',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await sessionRequest.delete(`${profileRoute}/identities/mock_social`);
|
||||
|
||||
expect(response.statusCode).toEqual(204);
|
||||
expect(deleteUserIdentity).toBeCalledWith('id', 'mock_social');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,251 +0,0 @@
|
|||
import { emailRegEx, passwordRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { arbitraryObjectGuard, userInfoSelectFields } from '@logto/schemas';
|
||||
import { has, pick } from '@silverhand/essentials';
|
||||
import { argon2Verify } from 'hash-wasm';
|
||||
import { object, string, unknown } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { getLogtoConnectorById } from '#src/libraries/connector.js';
|
||||
import { checkSessionHealth } from '#src/libraries/session.js';
|
||||
import { getUserInfoByAuthCode } from '#src/libraries/social.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { verificationTimeout } from './consts.js';
|
||||
import type { AnonymousRouter, RouterInitArgs } from './types.js';
|
||||
|
||||
export const profileRoute = '/profile';
|
||||
|
||||
export default function profileRoutes<T extends AnonymousRouter>(
|
||||
...[router, tenant]: RouterInitArgs<T>
|
||||
) {
|
||||
const { provider, libraries, queries } = tenant;
|
||||
const { deleteUserIdentity, findUserById, updateUserById } = queries.users;
|
||||
const {
|
||||
users: { checkIdentifierCollision },
|
||||
} = libraries;
|
||||
|
||||
router.get(profileRoute, async (ctx, next) => {
|
||||
const { accountId: userId } = await provider.Session.get(ctx);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const user = await findUserById(userId);
|
||||
|
||||
ctx.body = {
|
||||
...pick(user, ...userInfoSelectFields),
|
||||
hasPasswordSet: Boolean(user.passwordEncrypted),
|
||||
};
|
||||
|
||||
ctx.status = 200;
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.patch(
|
||||
profileRoute,
|
||||
koaGuard({
|
||||
body: object({
|
||||
name: string().nullable().optional(),
|
||||
avatar: string().nullable().optional(),
|
||||
customData: arbitraryObjectGuard.optional(),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { accountId: userId } = await provider.Session.get(ctx);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { name, avatar, customData } = ctx.guard.body;
|
||||
|
||||
await updateUserById(userId, { name, avatar, customData });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/username`,
|
||||
koaGuard({
|
||||
body: object({ username: string().regex(usernameRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { username } = ctx.guard.body;
|
||||
await checkIdentifierCollision({ username }, userId);
|
||||
await updateUserById(userId, { username }, 'replace');
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/password`,
|
||||
koaGuard({
|
||||
body: object({ password: string().regex(passwordRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { password } = ctx.guard.body;
|
||||
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
|
||||
new RequestError({ code: 'user.same_password', status: 422 })
|
||||
);
|
||||
|
||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
||||
|
||||
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/email`,
|
||||
koaGuard({
|
||||
body: object({ primaryEmail: string().regex(emailRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { primaryEmail } = ctx.guard.body;
|
||||
|
||||
await checkIdentifierCollision({ primaryEmail });
|
||||
await updateUserById(userId, { primaryEmail });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(`${profileRoute}/email`, async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { primaryEmail } = await findUserById(userId);
|
||||
|
||||
assertThat(primaryEmail, new RequestError({ code: 'user.email_not_exist', status: 422 }));
|
||||
|
||||
await updateUserById(userId, { primaryEmail: null });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/phone`,
|
||||
koaGuard({
|
||||
body: object({ primaryPhone: string().regex(phoneRegEx) }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { primaryPhone } = ctx.guard.body;
|
||||
|
||||
await checkIdentifierCollision({ primaryPhone });
|
||||
await updateUserById(userId, { primaryPhone });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(`${profileRoute}/phone`, async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { primaryPhone } = await findUserById(userId);
|
||||
|
||||
assertThat(primaryPhone, new RequestError({ code: 'user.phone_not_exist', status: 422 }));
|
||||
|
||||
await updateUserById(userId, { primaryPhone: null });
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
router.patch(
|
||||
`${profileRoute}/identities`,
|
||||
koaGuard({
|
||||
body: object({
|
||||
connectorId: string(),
|
||||
data: unknown(),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const userId = await checkSessionHealth(ctx, tenant, verificationTimeout);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { connectorId, data } = ctx.guard.body;
|
||||
|
||||
const {
|
||||
metadata: { target },
|
||||
} = await getLogtoConnectorById(connectorId);
|
||||
|
||||
const socialUserInfo = await getUserInfoByAuthCode(connectorId, data);
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
await updateUserById(userId, {
|
||||
identities: {
|
||||
...identities,
|
||||
[target]: { userId: socialUserInfo.id, details: socialUserInfo },
|
||||
},
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
`${profileRoute}/identities/:target`,
|
||||
koaGuard({
|
||||
params: object({ target: string() }),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { accountId: userId } = await provider.Session.get(ctx);
|
||||
|
||||
assertThat(userId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { target } = ctx.guard.params;
|
||||
const { identities } = await findUserById(userId);
|
||||
|
||||
assertThat(
|
||||
has(identities, target),
|
||||
new RequestError({ code: 'user.identity_not_exist', status: 404 })
|
||||
);
|
||||
|
||||
await deleteUserIdentity(userId, target);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -15,7 +15,6 @@ import ContinueWithEmailOrPhone from './pages/Continue/EmailOrPhone';
|
|||
import ErrorPage from './pages/ErrorPage';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername';
|
||||
import Profile from './pages/Profile';
|
||||
import Register from './pages/Register';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import SecondaryRegister from './pages/SecondaryRegister';
|
||||
|
@ -63,7 +62,6 @@ const App = () => {
|
|||
<Provider value={context}>
|
||||
<AppBoundary>
|
||||
<Routes>
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route element={<AppContent />}>
|
||||
<Route path="/" element={<Navigate replace to="/sign-in" />} />
|
||||
<Route path="/sign-in/consent" element={<Consent />} />
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import type { UserProfileResponse } from '@logto/schemas';
|
||||
|
||||
import api from './api';
|
||||
|
||||
const profileApiPrefix = '/api/profile';
|
||||
|
||||
export const getUserProfile = async (): Promise<UserProfileResponse> =>
|
||||
api.get(profileApiPrefix).json<UserProfileResponse>();
|
|
@ -1,31 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
padding: _.unit(6) _.unit(8);
|
||||
display: flex;
|
||||
margin-top: _.unit(4);
|
||||
background: var(--color-bg-layer-1);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 405px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-neutral-variant-60);
|
||||
font: var(--font-subhead-cap);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
|
||||
.content {
|
||||
margin-top: _.unit(4);
|
||||
flex-grow: unset;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import type { I18nKey } from '@logto/phrases-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
title: I18nKey;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const FormCard = ({ title, children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>{t(title)}</div>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormCard;
|
|
@ -1,53 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-top: _.unit(3);
|
||||
background: var(--color-bg-layer-1);
|
||||
|
||||
.item {
|
||||
padding-left: _.unit(5);
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: _.unit(3) 0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--color-overlay-neutral-pressed);
|
||||
}
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
.wrapper {
|
||||
border-top: 1px solid var(--color-line-divider);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label {
|
||||
font: var(--font-body-1);
|
||||
}
|
||||
|
||||
.value {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-secondary);
|
||||
margin-top: _.unit(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 _.unit(4) 0 _.unit(3);
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
import type { I18nKey } from '@logto/phrases-ui';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ArrowNext from '@/assets/icons/arrow-next.svg';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Item = {
|
||||
label: I18nKey;
|
||||
value?: Nullable<string>;
|
||||
onTap: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: Item[];
|
||||
};
|
||||
|
||||
const NavItem = ({ data }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{data.map(({ label, value, onTap }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={styles.item}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onTap}
|
||||
onKeyDown={onKeyDownHandler({
|
||||
Enter: onTap,
|
||||
})}
|
||||
>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.label}>{t(label)}</div>
|
||||
{value && <div className={styles.value}>{value}</div>}
|
||||
</div>
|
||||
<div className={styles.action}>
|
||||
<ArrowNext />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavItem;
|
|
@ -1,30 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font: var(--font-label-2);
|
||||
margin-bottom: _.unit(1);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border: 1px solid var(--color-neutral-variant-90);
|
||||
border-radius: 8px;
|
||||
|
||||
td {
|
||||
padding: _.unit(6);
|
||||
border-bottom: 1px solid var(--color-neutral-variant-90);
|
||||
|
||||
&:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import type { I18nKey } from '@logto/phrases-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Row = {
|
||||
label: I18nKey;
|
||||
value: unknown;
|
||||
renderer?: (value: unknown) => ReactNode;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
title: I18nKey;
|
||||
data: Row[];
|
||||
};
|
||||
|
||||
const defaultRenderer = (value: unknown) => (value ? String(value) : '-');
|
||||
|
||||
const Table = ({ title, data }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>{t(title)}</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{data.map(({ label, value, renderer = defaultRenderer }) => (
|
||||
<tr key={label}>
|
||||
<td>{t(label)}</td>
|
||||
<td>{renderer(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
|
@ -1,51 +0,0 @@
|
|||
import type { UserProfileResponse } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import FormCard from '../../components/FormCard';
|
||||
import Table from '../../components/Table';
|
||||
|
||||
type Props = {
|
||||
profile: UserProfileResponse;
|
||||
};
|
||||
|
||||
const DesktopView = ({ profile }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { avatar, name, username, primaryEmail, primaryPhone, hasPasswordSet } = profile;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormCard title="profile.settings.title">
|
||||
<Table
|
||||
title="profile.settings.profile_information"
|
||||
data={[
|
||||
{ label: 'profile.settings.avatar', value: avatar },
|
||||
{ label: 'profile.settings.name', value: name },
|
||||
{ label: 'profile.settings.username', value: username },
|
||||
]}
|
||||
/>
|
||||
</FormCard>
|
||||
<FormCard title="profile.password.title">
|
||||
<Table
|
||||
title="profile.password.reset_password"
|
||||
data={[
|
||||
{
|
||||
label: 'profile.password.reset_password',
|
||||
value: hasPasswordSet ? '******' : t('profile.not_set'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormCard>
|
||||
<FormCard title="profile.link_account.title">
|
||||
<Table
|
||||
title="profile.link_account.email_phone_sign_in"
|
||||
data={[
|
||||
{ label: 'profile.link_account.email', value: primaryEmail },
|
||||
{ label: 'profile.link_account.phone', value: primaryPhone },
|
||||
]}
|
||||
/>
|
||||
</FormCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesktopView;
|
|
@ -1,71 +0,0 @@
|
|||
import type { UserProfileResponse } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import NavItem from '../../components/NavItem';
|
||||
|
||||
type Props = {
|
||||
profile: UserProfileResponse;
|
||||
};
|
||||
|
||||
const MobileView = ({ profile }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { username, primaryEmail, primaryPhone, hasPasswordSet, identities } = profile;
|
||||
const socialConnectorNames = identities?.length
|
||||
? Object.keys(identities).join(', ')
|
||||
: t('profile.not_set');
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavItem
|
||||
data={[
|
||||
{
|
||||
label: 'profile.settings.username',
|
||||
value: username ?? t('profile.not_set'),
|
||||
onTap: () => {
|
||||
console.log('username');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<NavItem
|
||||
data={[
|
||||
{
|
||||
label: 'profile.password.reset_password_sc',
|
||||
value: hasPasswordSet ? '******' : t('profile.not_set'),
|
||||
onTap: () => {
|
||||
console.log('password');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<NavItem
|
||||
data={[
|
||||
{
|
||||
label: 'profile.link_account.email',
|
||||
value: primaryEmail ?? t('profile.not_set'),
|
||||
onTap: () => {
|
||||
console.log('email');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'profile.link_account.phone_sc',
|
||||
value: primaryPhone ?? t('profile.not_set'),
|
||||
onTap: () => {
|
||||
console.log('phone');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'profile.link_account.social_sc',
|
||||
value: socialConnectorNames,
|
||||
onTap: () => {
|
||||
console.log('social accounts');
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileView;
|
|
@ -1,52 +0,0 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.container {
|
||||
@include _.flex-column(center, normal);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.wrapper {
|
||||
@include _.flex-column(normal, normal);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
flex: 1;
|
||||
padding: _.unit(4);
|
||||
|
||||
.header {
|
||||
margin-top: _.unit(2);
|
||||
|
||||
.title {
|
||||
font: var(--font-title-1);
|
||||
margin-bottom: _.unit(1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-type-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.mobile) {
|
||||
.container {
|
||||
background: var(--color-bg-body-base);
|
||||
|
||||
.wrapper {
|
||||
padding: 0;
|
||||
|
||||
.header {
|
||||
margin: 0;
|
||||
padding: 0 _.unit(4);
|
||||
background: var(--color-bg-layer-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.container {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { getUserProfile } from '@/apis/profile';
|
||||
import LoadingLayer from '@/components/LoadingLayer';
|
||||
import NavBar from '@/components/NavBar';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import usePlatform from '@/hooks/use-platform';
|
||||
|
||||
import DesktopView from './containers/DesktopView';
|
||||
import MobileView from './containers/MobileView';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const Profile = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = usePlatform();
|
||||
const { run: asyncGetProfile, result: profile } = useApi(getUserProfile);
|
||||
const ContainerView = isMobile ? MobileView : DesktopView;
|
||||
|
||||
useEffect(() => {
|
||||
void asyncGetProfile();
|
||||
}, [asyncGetProfile]);
|
||||
|
||||
if (!profile) {
|
||||
return <LoadingLayer />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.header}>
|
||||
{isMobile && <NavBar type="close" title={t('profile.title')} />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className={styles.title}>{t('profile.title')}</div>
|
||||
<div className={styles.subtitle}>{t('profile.description')}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ContainerView profile={profile} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
Loading…
Reference in a new issue