0
Fork 0
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:
Charles Zhao 2023-01-10 17:41:28 +08:00 committed by GitHub
parent 5d862f617f
commit 1b998b7e62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2 additions and 1239 deletions

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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 />} />

View file

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

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

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

View file

@ -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;