0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor: add desktop and mobile views to render profile on different platforms (#2642)

This commit is contained in:
Charles Zhao 2022-12-16 22:50:13 +08:00 committed by GitHub
parent 858e1190e6
commit c2bf25bd0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 332 additions and 63 deletions

View file

@ -15,6 +15,7 @@ import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const mockUserProfileResponse = { ...mockUserResponse, hasPasswordSet: true };
const getLogtoConnectorById = jest.fn(async () => ({
dbEntry: { enabled: true },
metadata: { id: 'connectorId', target: 'mock_social' },
@ -105,7 +106,7 @@ describe('session -> profileRoutes', () => {
it('should return current user data', async () => {
const response = await sessionRequest.get(profileRoute);
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual(mockUserResponse);
expect(response.body).toEqual(mockUserProfileResponse);
});
it('should throw when the user is not authenticated', async () => {
@ -170,8 +171,7 @@ describe('session -> profileRoutes', () => {
.patch(`${profileRoute}/username`)
.send({ username: newUsername });
expect(response.statusCode).toEqual(200);
expect(response.body).toEqual({ ...mockUserResponse, username: newUsername });
expect(response.statusCode).toEqual(204);
});
it('should throw when username is already in use', async () => {

View file

@ -28,7 +28,11 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
const user = await findUserById(userId);
ctx.body = pick(user, ...userInfoSelectFields);
ctx.body = {
...pick(user, ...userInfoSelectFields),
hasPasswordSet: Boolean(user.passwordEncrypted),
};
ctx.status = 200;
return next();
@ -69,9 +73,9 @@ export default function profileRoutes<T extends AnonymousRouter>(router: T, prov
const { username } = ctx.guard.body;
await checkIdentifierCollision({ username }, userId);
await updateUserById(userId, { username }, 'replace');
const user = await updateUserById(userId, { username }, 'replace');
ctx.body = pick(user, ...userInfoSelectFields);
ctx.status = 204;
return next();
}

View file

@ -99,14 +99,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -95,14 +95,18 @@ const translation = {
password: {
title: 'PASSWORD',
reset_password: 'Reset Password',
reset_password_sc: 'Reset password',
},
link_account: {
title: 'LINK ACCOUNT',
email_phone_sign_in: 'Email / Phone Sign-In',
email: 'Email',
phone: 'Phone',
social_sign_in: 'Social Sign-In',
phone_sc: 'Phone number',
social: 'Social Sign-In',
social_sc: 'Social accounts',
},
not_set: 'Not set',
edit: 'Edit',
change: 'Change',
link: 'Link',

View file

@ -99,14 +99,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -95,14 +95,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -97,14 +97,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -95,14 +95,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -96,14 +96,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -91,14 +91,18 @@ const translation = {
password: {
title: 'PASSWORD', // UNTRANSLATED
reset_password: 'Reset Password', // UNTRANSLATED
reset_password_sc: 'Reset password', // UNTRANSLATED
},
link_account: {
title: 'LINK ACCOUNT', // UNTRANSLATED
email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED
email: 'Email', // UNTRANSLATED
phone: 'Phone', // UNTRANSLATED
social_sign_in: 'Social Sign-In', // UNTRANSLATED
phone_sc: 'Phone number', // UNTRANSLATED
social: 'Social Sign-In', // UNTRANSLATED
social_sc: 'Social accounts', // UNTRANSLATED
},
not_set: 'Not set', // UNTRANSLATED
edit: 'Edit', // UNTRANSLATED
change: 'Change', // UNTRANSLATED
link: 'Link', // UNTRANSLATED

View file

@ -21,6 +21,8 @@ export type UserInfo<Keys extends keyof CreateUser = typeof userInfoSelectFields
Keys
>;
export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean };
export enum UserRole {
Admin = 'admin',
}

View file

@ -1,8 +1,8 @@
import type { UserInfo } from '@logto/schemas';
import type { UserProfileResponse } from '@logto/schemas';
import api from './api';
const profileApiPrefix = '/api/profile';
export const getUserProfile = async (): Promise<UserInfo> =>
api.get(profileApiPrefix).json<UserInfo>();
export const getUserProfile = async (): Promise<UserProfileResponse> =>
api.get(profileApiPrefix).json<UserProfileResponse>();

View file

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.2448 10.3488L9.05651 5.16959C8.97129 5.08367 8.86991 5.01547 8.7582 4.96894C8.6465 4.9224 8.52669 4.89844 8.40567 4.89844C8.28466 4.89844 8.16485 4.9224 8.05315 4.96894C7.94144 5.01547 7.84006 5.08367 7.75484 5.16959C7.58411 5.34133 7.48828 5.57367 7.48828 5.81584C7.48828 6.05801 7.58411 6.29034 7.75484 6.46209L12.2923 11.0454L7.75484 15.5829C7.58411 15.7547 7.48828 15.987 7.48828 16.2292C7.48828 16.4713 7.58411 16.7037 7.75484 16.8754C7.83974 16.962 7.94098 17.0309 8.0527 17.0781C8.16442 17.1253 8.28439 17.1499 8.40567 17.1504C8.52696 17.1499 8.64693 17.1253 8.75865 17.0781C8.87037 17.0309 8.97161 16.962 9.05651 16.8754L14.2448 11.6963C14.3379 11.6104 14.4121 11.5062 14.4629 11.3903C14.5137 11.2743 14.5399 11.1491 14.5399 11.0225C14.5399 10.8959 14.5137 10.7707 14.4629 10.6547C14.4121 10.5388 14.3379 10.4346 14.2448 10.3488Z" fill="currentcolor"/>
</svg>

After

Width:  |  Height:  |  Size: 977 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.4099 12.0002L17.7099 7.71019C17.8982 7.52188 18.004 7.26649 18.004 7.00019C18.004 6.73388 17.8982 6.47849 17.7099 6.29019C17.5216 6.10188 17.2662 5.99609 16.9999 5.99609C16.7336 5.99609 16.4782 6.10188 16.2899 6.29019L11.9999 10.5902L7.70994 6.29019C7.52164 6.10188 7.26624 5.99609 6.99994 5.99609C6.73364 5.99609 6.47824 6.10188 6.28994 6.29019C6.10164 6.47849 5.99585 6.73388 5.99585 7.00019C5.99585 7.26649 6.10164 7.52188 6.28994 7.71019L10.5899 12.0002L6.28994 16.2902C6.19621 16.3831 6.12182 16.4937 6.07105 16.6156C6.02028 16.7375 5.99414 16.8682 5.99414 17.0002C5.99414 17.1322 6.02028 17.2629 6.07105 17.3848C6.12182 17.5066 6.19621 17.6172 6.28994 17.7102C6.3829 17.8039 6.4935 17.8783 6.61536 17.9291C6.73722 17.9798 6.86793 18.006 6.99994 18.006C7.13195 18.006 7.26266 17.9798 7.38452 17.9291C7.50638 17.8783 7.61698 17.8039 7.70994 17.7102L11.9999 13.4102L16.2899 17.7102C16.3829 17.8039 16.4935 17.8783 16.6154 17.9291C16.7372 17.9798 16.8679 18.006 16.9999 18.006C17.132 18.006 17.2627 17.9798 17.3845 17.9291C17.5064 17.8783 17.617 17.8039 17.7099 17.7102C17.8037 17.6172 17.8781 17.5066 17.9288 17.3848C17.9796 17.2629 18.0057 17.1322 18.0057 17.0002C18.0057 16.8682 17.9796 16.7375 17.9288 16.6156C17.8781 16.4937 17.8037 16.3831 17.7099 16.2902L13.4099 12.0002Z" fill="currentcolor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -18,9 +18,9 @@
}
}
.backButton {
.navButton {
position: absolute;
left: _.unit(-2);
left: 0;
top: 50%;
transform: translateY(-50%);
font: var(--font-label-2);
@ -29,13 +29,13 @@
}
:global(body.mobile) {
.backButton > span {
.navButton > span {
display: none;
}
}
:global(body.desktop) {
.backButton {
.navButton {
&:hover {
text-decoration: underline;
}

View file

@ -2,35 +2,41 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ArrowPrev from '@/assets/icons/arrow-prev.svg';
import NavClose from '@/assets/icons/nav-close.svg';
import { onKeyDownHandler } from '@/utils/a11y';
import * as styles from './index.module.scss';
type Props = {
title?: string;
type?: 'back' | 'close';
};
const NavBar = ({ title }: Props) => {
const NavBar = ({ title, type = 'back' }: Props) => {
const navigate = useNavigate();
const { t } = useTranslation();
const isClosable = type === 'close';
const clickHandler = () => {
if (isClosable) {
window.close();
}
navigate(-1);
};
return (
<div className={styles.navBar}>
<div
role="button"
tabIndex={0}
className={styles.backButton}
onKeyDown={onKeyDownHandler(() => {
navigate(-1);
})}
onClick={() => {
navigate(-1);
}}
className={styles.navButton}
onKeyDown={onKeyDownHandler(clickHandler)}
onClick={clickHandler}
>
<ArrowPrev />
<span>{t('action.nav_back')}</span>
{isClosable ? <NavClose /> : <ArrowPrev />}
{!isClosable && <span>{t('action.nav_back')}</span>}
</div>
{title && <div className={styles.title}>{title}</div>}
</div>
);

View file

@ -0,0 +1,53 @@
@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

@ -0,0 +1,51 @@
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

@ -0,0 +1,51 @@
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

@ -0,0 +1,71 @@
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

@ -32,6 +32,16 @@
: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);
}
}
}
}

View file

@ -3,15 +3,19 @@ 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 FormCard from './components/FormCard';
import Table from './components/Table';
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();
@ -21,40 +25,19 @@ const Profile = () => {
return <LoadingLayer />;
}
const { avatar, name, username, primaryEmail, primaryPhone } = profile;
return (
<div className={styles.container}>
<div className={styles.wrapper}>
<div className={styles.header}>
<div className={styles.title}>{t('profile.title')}</div>
<div className={styles.subtitle}>{t('profile.description')}</div>
{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>
<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: '******' }]}
/>
</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>
<ContainerView profile={profile} />
</div>
</div>
);