0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(console): link social account in ac profile page (#3288)

This commit is contained in:
Charles Zhao 2023-03-06 11:21:24 +08:00 committed by GitHub
parent 0d1bad9978
commit 3996ac4107
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 593 additions and 141 deletions

View file

@ -87,6 +87,7 @@
"recharts": "^2.1.13", "recharts": "^2.1.13",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"stylelint": "^15.0.0", "stylelint": "^15.0.0",
"superstruct": "^0.16.0",
"swr": "^1.3.0", "swr": "^1.3.0",
"typescript": "^4.9.4", "typescript": "^4.9.4",
"zod": "^3.20.2" "zod": "^3.20.2"

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8337 3.3335H4.16699C3.50395 3.3335 2.86807 3.59689 2.39923 4.06573C1.93038 4.53457 1.66699 5.17045 1.66699 5.8335V14.1668C1.66699 14.8299 1.93038 15.4658 2.39923 15.9346C2.86807 16.4034 3.50395 16.6668 4.16699 16.6668H15.8337C16.4967 16.6668 17.1326 16.4034 17.6014 15.9346C18.0703 15.4658 18.3337 14.8299 18.3337 14.1668V5.8335C18.3337 5.17045 18.0703 4.53457 17.6014 4.06573C17.1326 3.59689 16.4967 3.3335 15.8337 3.3335ZM4.16699 5.00016H15.8337C16.0547 5.00016 16.2666 5.08796 16.4229 5.24424C16.5792 5.40052 16.667 5.61248 16.667 5.8335L10.0003 9.90016L3.33366 5.8335C3.33366 5.61248 3.42146 5.40052 3.57774 5.24424C3.73402 5.08796 3.94598 5.00016 4.16699 5.00016ZM16.667 14.1668C16.667 14.3878 16.5792 14.5998 16.4229 14.7561C16.2666 14.9124 16.0547 15.0002 15.8337 15.0002H4.16699C3.94598 15.0002 3.73402 14.9124 3.57774 14.7561C3.42146 14.5998 3.33366 14.3878 3.33366 14.1668V7.7335L9.56699 11.5418C9.69368 11.615 9.83738 11.6535 9.98366 11.6535C10.1299 11.6535 10.2736 11.615 10.4003 11.5418L16.667 7.7335V14.1668Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,18 @@
.avatar {
border-radius: 6px;
&.small {
width: 32px;
height: 32px;
}
&.medium {
width: 36px;
height: 36px;
}
&.large {
width: 40px;
height: 40px;
}
}

View file

@ -1,25 +1,29 @@
import { AppearanceMode } from '@logto/schemas'; import { AppearanceMode } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials'; import type { Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import DarkAvatar from '@/assets/images/default-avatar-dark.svg'; import DarkAvatar from '@/assets/images/default-avatar-dark.svg';
import LightAvatar from '@/assets/images/default-avatar-light.svg'; import LightAvatar from '@/assets/images/default-avatar-light.svg';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import ImageWithErrorFallback from '../ImageWithErrorFallback'; import ImageWithErrorFallback from '../ImageWithErrorFallback';
import * as styles from './index.module.scss';
type Props = { type Props = {
className?: string; className?: string;
url?: Nullable<string>; url?: Nullable<string>;
size?: 'small' | 'medium' | 'large';
}; };
const UserAvatar = ({ className, url }: Props) => { const UserAvatar = ({ className, url, size = 'medium' }: Props) => {
const theme = useTheme(); const theme = useTheme();
const DefaultAvatar = theme === AppearanceMode.LightMode ? LightAvatar : DarkAvatar; const DefaultAvatar = theme === AppearanceMode.LightMode ? LightAvatar : DarkAvatar;
const avatarClassName = classNames(styles.avatar, styles[size], className);
if (url) { if (url) {
return ( return (
<ImageWithErrorFallback <ImageWithErrorFallback
className={className} className={avatarClassName}
src={url} src={url}
alt="avatar" alt="avatar"
/** /**
@ -32,7 +36,7 @@ const UserAvatar = ({ className, url }: Props) => {
); );
} }
return <DefaultAvatar className={className} />; return <DefaultAvatar className={avatarClassName} />;
}; };
export default UserAvatar; export default UserAvatar;

View file

@ -0,0 +1,30 @@
@use '@/scss/underscore' as _;
.userInfo {
display: flex;
align-items: center;
user-select: none;
cursor: default;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 6px;
}
.nameWrapper {
display: flex;
flex-direction: column;
margin-left: _.unit(3);
.name {
font: var(--font-label-2);
color: var(--color-text);
}
.email {
font: var(--font-body-3);
color: var(--color-text-secondary);
}
}

View file

@ -0,0 +1,33 @@
import type { IdTokenClaims } from '@logto/react';
import type { User } from '@logto/schemas';
import classNames from 'classnames';
import UserAvatar from '../UserAvatar';
import * as styles from './index.module.scss';
type Props = {
className?: string;
user: Partial<
Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail'> &
Pick<IdTokenClaims, 'picture' | 'email'>
>;
avatarSize?: 'small' | 'medium' | 'large';
};
const UserInfoCard = ({ className, user, avatarSize = 'medium' }: Props) => {
const { name, username, avatar, picture, primaryEmail, email } = user;
const avatarToDisplay = avatar ?? picture;
const emailToDisplay = primaryEmail ?? email;
return (
<div className={classNames(styles.userInfo, className)}>
<UserAvatar className={styles.avatar} url={avatarToDisplay} size={avatarSize} />
<div className={styles.nameWrapper}>
<div className={styles.name}>{name ?? username}</div>
{emailToDisplay && <div className={styles.email}>{emailToDisplay}</div>}
</div>
</div>
);
};
export default UserInfoCard;

View file

@ -7,5 +7,6 @@ export * from './page-tabs';
export * from './external-links'; export * from './external-links';
export const themeStorageKey = 'logto:admin_console:theme'; export const themeStorageKey = 'logto:admin_console:theme';
export const profileSocialLinkingKeyPrefix = 'logto:admin_console:linking_social_connector';
export const requestTimeout = 20_000; export const requestTimeout = 20_000;
export const defaultPageSize = 20; export const defaultPageSize = 20;

View file

@ -19,37 +19,11 @@
} }
} }
.avatar {
width: 36px;
height: 36px;
border-radius: 6px;
}
.nameWrapper {
display: flex;
flex-direction: column;
margin-left: _.unit(3);
.name {
font: var(--font-label-2);
color: var(--color-text);
}
.email {
font: var(--font-body-3);
color: var(--color-text-secondary);
}
}
.dropdown { .dropdown {
min-width: 320px; min-width: 320px;
.userInfo { .userInfo {
display: flex;
align-items: center;
padding: _.unit(4) _.unit(5); padding: _.unit(4) _.unit(5);
user-select: none;
cursor: default;
} }
} }

View file

@ -16,6 +16,7 @@ import Dropdown, { DropdownItem } from '@/components/Dropdown';
import Spacer from '@/components/Spacer'; import Spacer from '@/components/Spacer';
import { Ring as Spinner } from '@/components/Spinner'; import { Ring as Spinner } from '@/components/Spinner';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import UserInfoCard from '@/components/UserInfoCard';
import { getSignOutRedirectPathname } from '@/consts'; import { getSignOutRedirectPathname } from '@/consts';
import useUserPreferences from '@/hooks/use-user-preferences'; import useUserPreferences from '@/hooks/use-user-preferences';
import { onKeyDownHandler } from '@/utils/a11y'; import { onKeyDownHandler } from '@/utils/a11y';
@ -53,8 +54,6 @@ const UserInfo = () => {
return <UserInfoSkeleton />; return <UserInfoSkeleton />;
} }
const { username, name, picture, email } = user;
return ( return (
<> <>
<div <div
@ -69,7 +68,7 @@ const UserInfo = () => {
setShowDropdown(true); setShowDropdown(true);
}} }}
> >
<UserAvatar className={styles.avatar} url={picture} /> <UserAvatar url={user.picture} />
</div> </div>
<Dropdown <Dropdown
hasOverflowContent hasOverflowContent
@ -81,13 +80,7 @@ const UserInfo = () => {
setShowDropdown(false); setShowDropdown(false);
}} }}
> >
<div className={styles.userInfo}> <UserInfoCard className={styles.userInfo} user={user} />
<UserAvatar className={styles.avatar} url={picture} />
<div className={styles.nameWrapper}>
<div className={styles.name}>{name ?? username}</div>
{email && <div className={styles.email}>{email}</div>}
</div>
</div>
<Divider /> <Divider />
<DropdownItem <DropdownItem
className={classNames(styles.dropdownItem, isLoading && styles.loading)} className={classNames(styles.dropdownItem, isLoading && styles.loading)}

View file

@ -46,6 +46,7 @@ import Users from '@/pages/Users';
import Welcome from '@/pages/Welcome'; import Welcome from '@/pages/Welcome';
import ChangePasswordModal from '../Profile/containers/ChangePasswordModal'; import ChangePasswordModal from '../Profile/containers/ChangePasswordModal';
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
import LinkEmailModal from '../Profile/containers/LinkEmailModal'; import LinkEmailModal from '../Profile/containers/LinkEmailModal';
import VerificationCodeModal from '../Profile/containers/VerificationCodeModal'; import VerificationCodeModal from '../Profile/containers/VerificationCodeModal';
import VerifyPasswordModal from '../Profile/containers/VerifyPasswordModal'; import VerifyPasswordModal from '../Profile/containers/VerifyPasswordModal';
@ -66,6 +67,7 @@ const Main = () => {
<Routes> <Routes>
<Route path="callback" element={<Callback />} /> <Route path="callback" element={<Callback />} />
<Route path="welcome" element={<Welcome />} /> <Route path="welcome" element={<Welcome />} />
<Route path="handle-social" element={<HandleSocialCallback />} />
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route element={<AppContent />}> <Route element={<AppContent />}>
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />

View file

@ -1,5 +0,0 @@
.avatar {
width: 40px;
height: 40px;
border-radius: 6px;
}

View file

@ -1,4 +1,4 @@
import type { UserInfoResponse } from '@logto/react'; import type { User } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials'; import type { Nullable } from '@silverhand/essentials';
import { useState } from 'react'; import { useState } from 'react';
@ -10,10 +10,9 @@ import BasicUserInfoUpdateModal from '../../containers/BasicUserInfoUpdateModal'
import type { Row } from '../CardContent'; import type { Row } from '../CardContent';
import CardContent from '../CardContent'; import CardContent from '../CardContent';
import Section from '../Section'; import Section from '../Section';
import * as styles from './index.module.scss';
type Props = { type Props = {
user: UserInfoResponse; user: User;
onUpdate?: () => void; onUpdate?: () => void;
}; };
@ -21,18 +20,21 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => {
const [editingField, setEditingField] = useState<BasicUserField>(); const [editingField, setEditingField] = useState<BasicUserField>();
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const { name, username, picture: avatar } = user; const { name, username, avatar } = user;
const conditionalUsername: Array<Row<Nullable<string> | undefined>> = isCloud const conditionalUsername: Array<Row<Nullable<string> | undefined>> = isCloud
? [] ? []
: [ : [
{ {
key: 'username',
label: 'profile.settings.username', label: 'profile.settings.username',
value: username, value: username,
actionName: 'profile.change', action: {
action: () => { name: 'profile.change',
setEditingField('username'); handler: () => {
setIsUpdateModalOpen(true); setEditingField('username');
setIsUpdateModalOpen(true);
},
}, },
}, },
]; ];
@ -50,22 +52,28 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => {
title="profile.settings.profile_information" title="profile.settings.profile_information"
data={[ data={[
{ {
key: 'avatar',
label: 'profile.settings.avatar', label: 'profile.settings.avatar',
value: avatar, value: avatar,
renderer: (value) => <UserAvatar className={styles.avatar} url={value} />, renderer: (value) => <UserAvatar url={value} size="large" />,
actionName: 'profile.change', action: {
action: () => { name: 'profile.change',
setEditingField('avatar'); handler: () => {
setIsUpdateModalOpen(true); setEditingField('avatar');
setIsUpdateModalOpen(true);
},
}, },
}, },
{ {
key: 'name',
label: 'profile.settings.name', label: 'profile.settings.name',
value: name, value: name,
actionName: name ? 'profile.change' : 'profile.set_name', action: {
action: () => { name: name ? 'profile.change' : 'profile.set_name',
setEditingField('name'); handler: () => {
setIsUpdateModalOpen(true); setEditingField('name');
setIsUpdateModalOpen(true);
},
}, },
}, },
...conditionalUsername, ...conditionalUsername,

View file

@ -8,6 +8,25 @@
margin-bottom: _.unit(1); margin-bottom: _.unit(1);
} }
.actionButton {
display: inline-flex;
+ .actionButton {
margin-left: _.unit(4);
}
}
.wrapper {
display: flex;
align-items: center;
}
.icon {
width: 32px;
height: 32px;
margin-right: _.unit(4);
}
table { table {
width: 100%; width: 100%;
border-spacing: 0; border-spacing: 0;
@ -15,17 +34,23 @@
border-radius: 8px; border-radius: 8px;
td { td {
font: var(--font-body-2);
height: 64px; height: 64px;
padding: 0 _.unit(6); padding: 0 _.unit(6);
border-bottom: 1px solid var(--color-neutral-variant-90); border-bottom: 1px solid var(--color-neutral-variant-90);
&:first-child { &:first-child {
width: 35%; width: 35%;
font: var(--font-label-2);
} }
&:last-child { &:last-child {
text-align: right; text-align: right;
width: 30%; width: 30%;
.wrapper {
justify-content: flex-end;
}
} }
} }

View file

@ -1,18 +1,26 @@
import type { AdminConsoleKey } from '@logto/phrases'; import type { AdminConsoleKey } from '@logto/phrases';
import type { Nullable } from '@silverhand/essentials'; import type { Nullable } from '@silverhand/essentials';
import type { ReactNode } from 'react'; import type { ReactElement } from 'react';
import { cloneElement } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import TextLink from '@/components/TextLink'; import Button from '@/components/Button';
import NotSet from '../NotSet';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
export type Action = {
name: AdminConsoleKey;
handler: () => void;
};
export type Row<T> = { export type Row<T> = {
label: AdminConsoleKey; key: string;
icon?: ReactElement;
label: AdminConsoleKey | ReactElement;
value: T; value: T;
renderer?: (value: T) => ReactNode; renderer?: (value: T) => ReactElement;
action: () => void; action: Action | Action[];
actionName: AdminConsoleKey;
}; };
type Props<T> = { type Props<T> = {
@ -20,9 +28,12 @@ type Props<T> = {
data: Array<Row<T>>; data: Array<Row<T>>;
}; };
const CardContent = <T extends Nullable<string> | undefined>({ title, data }: Props<T>) => { const CardContent = <T extends Nullable<string | Record<string, unknown>> | undefined>({
title,
data,
}: Props<T>) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const defaultRenderer = (value: unknown) => (value ? String(value) : t('profile.not_set')); const defaultRenderer = (value: unknown) => (value ? <>{String(value)}</> : <NotSet />);
if (data.length === 0) { if (data.length === 0) {
return null; return null;
@ -33,21 +44,38 @@ const CardContent = <T extends Nullable<string> | undefined>({ title, data }: Pr
<div className={styles.title}>{t(title)}</div> <div className={styles.title}>{t(title)}</div>
<table> <table>
<tbody> <tbody>
{data.map(({ label, value, renderer = defaultRenderer, actionName, action }) => ( {data.map(({ key, icon, label, value, renderer = defaultRenderer, action }) => {
<tr key={label}> const actions = Array.isArray(action) ? action : [action];
<td>{t(label)}</td>
<td>{renderer(value)}</td> return (
<td> <tr key={key}>
<TextLink <td>
onClick={() => { <div className={styles.wrapper}>
action(); {icon &&
}} cloneElement(icon, {
> className: styles.icon,
{t(actionName)} })}
</TextLink> {typeof label === 'string' ? t(label) : label}
</td> </div>
</tr> </td>
))} <td>{renderer(value)}</td>
<td>
<div className={styles.wrapper}>
{actions.map(({ name, handler }) => (
<Button
key={name}
className={styles.actionButton}
type="text"
size="small"
title={name}
onClick={handler}
/>
))}
</div>
</td>
</tr>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -0,0 +1,10 @@
@use '@/scss/underscore' as _;
.wrapper {
display: flex;
align-items: center;
svg {
margin-right: _.unit(2);
}
}

View file

@ -0,0 +1,151 @@
import { buildIdGenerator } from '@logto/core-kit';
import type { ConnectorResponse, User } from '@logto/schemas';
import { AppearanceMode } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { is } from 'superstruct';
import MailIcon from '@/assets/images/mail.svg';
import ImageWithErrorFallback from '@/components/ImageWithErrorFallback';
import UnnamedTrans from '@/components/UnnamedTrans';
import UserInfoCard from '@/components/UserInfoCard';
import { adminTenantEndpoint, getBasename, meApi, profileSocialLinkingKeyPrefix } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
import { useTheme } from '@/hooks/use-theme';
import type { SocialUserInfo } from '@/types/profile';
import { socialUserInfoGuard } from '@/types/profile';
import { popupWindow } from '../../utils';
import type { Action, Row } from '../CardContent';
import CardContent from '../CardContent';
import NotSet from '../NotSet';
import Section from '../Section';
import * as styles from './index.module.scss';
type Props = {
user: User;
onUpdate: () => void;
};
const LinkAccountSection = ({ user, onUpdate }: Props) => {
const navigate = useNavigate();
const theme = useTheme();
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const [connectors, setConnectors] = useState<ConnectorResponse[]>();
useEffect(() => {
(async () => {
const connectors = await api.get('me/social/connectors').json<ConnectorResponse[]>();
setConnectors(connectors);
})();
}, [api]);
const getSocialAuthorizationUri = useCallback(
async (connectorId: string) => {
const state = buildIdGenerator(8)();
const redirectUri = `${adminTenantEndpoint}/callback/${connectorId}`;
const { redirectTo } = await api
.post('me/social/authorization-uri', { json: { connectorId, state, redirectUri } })
.json<{ redirectTo: string }>();
sessionStorage.setItem(profileSocialLinkingKeyPrefix, connectorId);
return redirectTo;
},
[api]
);
const tableInfo = useMemo((): Array<Row<Optional<SocialUserInfo>>> => {
if (!connectors) {
return [];
}
return connectors.map(({ id, name, logo, logoDark, target }) => {
const logoSrc = theme === AppearanceMode.DarkMode && logoDark ? logoDark : logo;
const relatedUserDetails = user.identities[target]?.details;
const hasLinked = is(relatedUserDetails, socialUserInfoGuard);
const conditionalUnlinkAction: Action[] = hasLinked
? [
{
name: 'profile.unlink',
handler: async () => {
await api.delete(`me/social/identity/${id}`);
onUpdate();
},
},
]
: [];
return {
key: target,
icon: <ImageWithErrorFallback src={logoSrc} />,
label: <UnnamedTrans resource={name} />,
value: conditional(hasLinked && relatedUserDetails),
renderer: (user) => (user ? <UserInfoCard user={user} avatarSize="small" /> : <NotSet />),
action: [
...conditionalUnlinkAction,
{
name: hasLinked ? 'profile.change' : 'profile.link',
handler: async () => {
const authUri = await getSocialAuthorizationUri(id);
const callback = new URL(
`${getBasename()}/handle-social`,
`${adminTenantEndpoint}`
).toString();
const queries = new URLSearchParams({
redirectTo: authUri,
connectorId: id,
callback,
});
const newWindow = popupWindow(
`${adminTenantEndpoint}/springboard?${queries.toString()}`,
'Link social account with Logto',
600,
640
);
newWindow?.focus();
},
},
],
};
});
}, [connectors, theme, user.identities, api, onUpdate, getSocialAuthorizationUri]);
return (
<Section title="profile.link_account.title">
<CardContent
title="profile.link_account.email_sign_in"
data={[
{
key: 'email',
label: 'profile.link_account.email',
value: user.primaryEmail,
renderer: (email) => (
<div className={styles.wrapper}>
<MailIcon />
{email}
</div>
),
action: {
name: 'profile.change',
handler: () => {
navigate('link-email', {
state: { email: user.primaryEmail, action: 'changeEmail' },
});
},
},
},
]}
/>
<CardContent title="profile.link_account.social_sign_in" data={tableInfo} />
</Section>
);
};
export default LinkAccountSection;

View file

@ -0,0 +1,4 @@
.text {
font: var(--font-label-2);
color: var(--color-text-secondary);
}

View file

@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next';
import * as styles from './index.module.scss';
const NotSet = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return <span className={styles.text}>{t('profile.not_set')}</span>;
};
export default NotSet;

View file

@ -16,7 +16,13 @@
} }
.content { .content {
flex-grow: 1; flex: 1;
display: flex;
flex-direction: column;
> div + div {
margin-top: _.unit(6);
}
} }
@media screen and (max-width: 1080px) { @media screen and (max-width: 1080px) {

View file

@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import AppLoading from '@/components/AppLoading';
import { adminTenantEndpoint, meApi, profileSocialLinkingKeyPrefix } from '@/consts';
import { useStaticApi } from '@/hooks/use-api';
const HandleSocialCallback = () => {
const { search } = useLocation();
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
useEffect(() => {
(async () => {
const connectorId = sessionStorage.getItem(profileSocialLinkingKeyPrefix);
const connectorData = Object.fromEntries(new URLSearchParams(search));
if (connectorId) {
sessionStorage.removeItem(profileSocialLinkingKeyPrefix);
await api.post('me/social/link-identity', { json: { connectorId, connectorData } });
window.close();
}
})();
}, [api, search]);
return <AppLoading />;
};
export default HandleSocialCallback;

View file

@ -1,15 +1,22 @@
import type { User } from '@logto/schemas';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CardTitle from '@/components/CardTitle'; import CardTitle from '@/components/CardTitle';
import { adminTenantEndpoint, meApi } from '@/consts';
import { isCloud } from '@/consts/cloud'; import { isCloud } from '@/consts/cloud';
import useLogtoUserInfo from '@/hooks/use-logto-userinfo'; import { useStaticApi } from '@/hooks/use-api';
import useLogtoUserId from '@/hooks/use-logto-user-id';
import useSwrFetcher from '@/hooks/use-swr-fetcher';
import * as resourcesStyles from '@/scss/resources.module.scss'; import * as resourcesStyles from '@/scss/resources.module.scss';
import BasicUserInfoSection from './components/BasicUserInfoSection'; import BasicUserInfoSection from './components/BasicUserInfoSection';
import CardContent from './components/CardContent'; import CardContent from './components/CardContent';
import LinkAccountSection from './components/LinkAccountSection';
import NotSet from './components/NotSet';
import Section from './components/Section'; import Section from './components/Section';
import DeleteAccountModal from './containers/DeleteAccountModal'; import DeleteAccountModal from './containers/DeleteAccountModal';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -17,48 +24,42 @@ import * as styles from './index.module.scss';
const Profile = () => { const Profile = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate(); const navigate = useNavigate();
const [user, fetchUser] = useLogtoUserInfo(); const userId = useLogtoUserId();
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const fetcher = useSwrFetcher<User>(api);
const { data: user, mutate } = useSWR(userId && `me/users/${userId}`, fetcher);
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
if (!user) { if (!user) {
return null; return null;
} }
const { primaryEmail, passwordEncrypted } = user;
return ( return (
<div className={resourcesStyles.container}> <div className={resourcesStyles.container}>
<div className={resourcesStyles.headline}> <div className={resourcesStyles.headline}>
<CardTitle title="profile.title" subtitle="profile.description" /> <CardTitle title="profile.title" subtitle="profile.description" />
</div> </div>
<BasicUserInfoSection user={user} onUpdate={fetchUser} /> <BasicUserInfoSection user={user} onUpdate={mutate} />
{isCloud && ( <LinkAccountSection user={user} onUpdate={mutate} />
<Section title="profile.link_account.title">
<CardContent
title="profile.link_account.email_sign_in"
data={[
{
label: 'profile.link_account.email',
value: user.email,
actionName: 'profile.change',
action: () => {
navigate('link-email', { state: { email: user.email, action: 'changeEmail' } });
},
},
]}
/>
</Section>
)}
<Section title="profile.password.title"> <Section title="profile.password.title">
<CardContent <CardContent
title="profile.password.password_setting" title="profile.password.password_setting"
data={[ data={[
{ {
key: 'password',
label: 'profile.password.password', label: 'profile.password.password',
value: '******', value: passwordEncrypted,
actionName: 'profile.change', renderer: (value) => (value ? <span>********</span> : <NotSet />),
action: () => { action: {
navigate('verify-password', { name: 'profile.change',
state: { email: user.email, action: 'changePassword' }, handler: () => {
}); navigate('verify-password', {
state: { email: primaryEmail, action: 'changePassword' },
});
},
}, },
}, },
]} ]}

View file

@ -11,3 +11,31 @@ export const checkLocationState = (state: unknown): state is LocationState =>
typeof state.email === 'string' && typeof state.email === 'string' &&
typeof state.action === 'string' && typeof state.action === 'string' &&
['changePassword', 'changeEmail'].includes(state.action); ['changePassword', 'changeEmail'].includes(state.action);
export const popupWindow = (url: string, windowName: string, width: number, height: number) => {
const outerHeight = window.top?.outerHeight ?? 0;
const outerWidth = window.top?.outerWidth ?? 0;
const screenX = window.top?.screenX ?? 0;
const screenY = window.top?.screenY ?? 0;
const yAxis = outerHeight / 2 + screenY - height / 2;
const xAxis = outerWidth / 2 + screenX - width / 2;
return window.open(
url,
windowName,
[
`toolbar=no`,
`location=no`,
`directories=no`,
`status=no`,
`menubar=no`,
`scrollbars=no`,
`resizable=no`,
`copyhistory=no`,
`width=${width}`,
`height=${height}`,
`top=${yAxis}`,
`left=${xAxis}`,
].join(',')
);
};

View file

@ -0,0 +1,17 @@
import * as s from 'superstruct';
export const locationStateGuard = s.object({
email: s.string(),
action: s.union([s.literal('changeEmail'), s.literal('changePassword')]),
});
export type LocationState = s.Infer<typeof locationStateGuard>;
export const socialUserInfoGuard = s.object({
id: s.string(),
name: s.string(),
email: s.string(),
avatar: s.string(),
});
export type SocialUserInfo = s.Infer<typeof socialUserInfoGuard>;

View file

@ -6,6 +6,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { notImplemented } from '#src/utils/connectors/consts.js'; import { notImplemented } from '#src/utils/connectors/consts.js';
import { transpileLogtoConnector } from '#src/utils/connectors/index.js';
import type { RouterInitArgs } from '../routes/types.js'; import type { RouterInitArgs } from '../routes/types.js';
import type { AuthedMeRouter } from './types.js'; import type { AuthedMeRouter } from './types.js';
@ -20,15 +21,30 @@ export default function socialRoutes<T extends AuthedMeRouter>(
) { ) {
const { const {
libraries: { libraries: {
connectors: { getLogtoConnectorById }, connectors: { getLogtoConnectors, getLogtoConnectorById },
}, },
queries: { queries: {
users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity }, users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity },
signInExperiences: { findDefaultSignInExperience },
}, },
} = tenant; } = tenant;
router.get('/social/connectors', async (ctx, next) => {
const connectors = await getLogtoConnectors();
const { socialSignInConnectorTargets } = await findDefaultSignInExperience();
ctx.body = connectors
.filter(
({ type, metadata: { target } }) =>
type === ConnectorType.Social && socialSignInConnectorTargets.includes(target)
)
.map((connector) => transpileLogtoConnector(connector));
return next();
});
router.post( router.post(
'/social-authorization-uri', '/social/authorization-uri',
koaGuard({ koaGuard({
body: object({ connectorId: string(), state: string(), redirectUri: string() }), body: object({ connectorId: string(), state: string(), redirectUri: string() }),
}), }),
@ -70,7 +86,7 @@ export default function socialRoutes<T extends AuthedMeRouter>(
); );
router.post( router.post(
'/link-social-identity', '/social/link-identity',
koaGuard({ koaGuard({
body: object({ body: object({
connectorId: string(), connectorId: string(),
@ -139,7 +155,7 @@ export default function socialRoutes<T extends AuthedMeRouter>(
); );
router.delete( router.delete(
'/social-identity/:connectorId', '/social/identity/:connectorId',
koaGuard({ koaGuard({
params: object({ params: object({
connectorId: string(), connectorId: string(),

View file

@ -1,6 +1,11 @@
import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit'; import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit';
import type { PasswordVerificationData } from '@logto/schemas'; import type { PasswordVerificationData } from '@logto/schemas';
import { passwordVerificationGuard, arbitraryObjectGuard } from '@logto/schemas'; import {
userInfoSelectFields,
passwordVerificationGuard,
arbitraryObjectGuard,
} from '@logto/schemas';
import { pick } from '@silverhand/essentials';
import { literal, object, string } from 'zod'; import { literal, object, string } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -24,6 +29,24 @@ export default function userRoutes<T extends AuthedMeRouter>(
}, },
} = tenant; } = tenant;
router.get(
'/users/:userId',
koaGuard({
params: object({ userId: string() }),
}),
async (ctx, next) => {
const {
params: { userId },
} = ctx.guard;
const user = await findUserById(userId);
ctx.body = pick(user, ...userInfoSelectFields, 'passwordEncrypted');
return next();
}
);
router.patch( router.patch(
'/user', '/user',
koaGuard({ koaGuard({

View file

@ -1,8 +1,6 @@
import { VerificationCodeType, validateConfig } from '@logto/connector-kit'; import { VerificationCodeType, validateConfig } from '@logto/connector-kit';
import { emailRegEx, phoneRegEx, buildIdGenerator } from '@logto/core-kit'; import { emailRegEx, phoneRegEx, buildIdGenerator } from '@logto/core-kit';
import type { ConnectorResponse, ConnectorFactoryResponse } from '@logto/schemas';
import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas'; import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas';
import { pick } from '@silverhand/essentials';
import cleanDeep from 'clean-deep'; import cleanDeep from 'clean-deep';
import { string, object } from 'zod'; import { string, object } from 'zod';
@ -10,31 +8,15 @@ import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js'; import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
import { loadConnectorFactories } from '#src/utils/connectors/factories.js'; import { loadConnectorFactories } from '#src/utils/connectors/factories.js';
import { buildRawConnector } from '#src/utils/connectors/index.js'; import {
buildRawConnector,
transpileConnectorFactory,
transpileLogtoConnector,
} from '#src/utils/connectors/index.js';
import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js'; import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js';
import type { ConnectorFactory, LogtoConnector } from '#src/utils/connectors/types.js';
import type { AuthedRouter, RouterInitArgs } from './types.js'; import type { AuthedRouter, RouterInitArgs } from './types.js';
const transpileLogtoConnector = ({
dbEntry,
metadata,
type,
}: LogtoConnector): ConnectorResponse => {
return {
type,
...metadata,
...pick(dbEntry, 'id', 'connectorId', 'syncProfile', 'config', 'metadata'),
};
};
const transpileConnectorFactory = ({
metadata,
type,
}: ConnectorFactory): ConnectorFactoryResponse => {
return { type, ...metadata };
};
const generateConnectorId = buildIdGenerator(12); const generateConnectorId = buildIdGenerator(12);
export default function connectorRoutes<T extends AuthedRouter>( export default function connectorRoutes<T extends AuthedRouter>(

View file

@ -4,9 +4,11 @@ import path from 'path';
import type { AllConnector, BaseConnector, GetConnectorConfig } from '@logto/connector-kit'; import type { AllConnector, BaseConnector, GetConnectorConfig } from '@logto/connector-kit';
import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit';
import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas';
import { pick } from '@silverhand/essentials';
import { notImplemented } from './consts.js'; import { notImplemented } from './consts.js';
import type { ConnectorFactory } from './types.js'; import type { ConnectorFactory, LogtoConnector } from './types.js';
export function validateConnectorModule( export function validateConnectorModule(
connector: Partial<BaseConnector<ConnectorType>> connector: Partial<BaseConnector<ConnectorType>>
@ -77,3 +79,22 @@ export const buildRawConnector = async (
return { rawConnector, rawMetadata }; return { rawConnector, rawMetadata };
}; };
export const transpileLogtoConnector = ({
dbEntry,
metadata,
type,
}: LogtoConnector): ConnectorResponse => {
return {
type,
...metadata,
...pick(dbEntry, 'id', 'connectorId', 'syncProfile', 'config', 'metadata'),
};
};
export const transpileConnectorFactory = ({
metadata,
type,
}: ConnectorFactory): ConnectorFactoryResponse => {
return { type, ...metadata };
};

View file

@ -21,6 +21,7 @@ import SignInPassword from './pages/SignInPassword';
import SocialLanding from './pages/SocialLanding'; import SocialLanding from './pages/SocialLanding';
import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialLinkAccount from './pages/SocialLinkAccount';
import SocialSignIn from './pages/SocialSignInCallback'; import SocialSignIn from './pages/SocialSignInCallback';
import Springboard from './pages/Springboard';
import VerificationCode from './pages/VerificationCode'; import VerificationCode from './pages/VerificationCode';
import { getSignInExperienceSettings } from './utils/sign-in-experience'; import { getSignInExperienceSettings } from './utils/sign-in-experience';
@ -71,6 +72,7 @@ const App = () => {
path="unknown-session" path="unknown-session"
element={<ErrorPage message="error.invalid_session" />} element={<ErrorPage message="error.invalid_session" />}
/> />
<Route path="springboard" element={<Springboard />} />
<Route path="sign-in/consent" element={<Consent />} /> <Route path="sign-in/consent" element={<Consent />} />

View file

@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import LoadingLayer from '@/components/LoadingLayer';
import { storeCallbackLink, storeState } from '@/utils/social-connectors';
const Springboard = () => {
const [searchParameters] = useSearchParams();
useEffect(() => {
const state = searchParameters.get('state');
const connectorId = searchParameters.get('connectorId');
const callback = searchParameters.get('callback');
const redirectTo = searchParameters.get('redirectTo');
if (callback && connectorId) {
storeCallbackLink(connectorId, callback);
}
if (state && connectorId) {
storeState(state, connectorId);
}
if (redirectTo) {
window.location.assign(redirectTo);
}
}, [searchParameters]);
return <LoadingLayer />;
};
export default Springboard;

View file

@ -22,7 +22,9 @@ export const storeState = (state: string, connectorId: string) => {
}; };
export const stateValidation = (state: string, connectorId: string) => { export const stateValidation = (state: string, connectorId: string) => {
const stateStorage = sessionStorage.getItem(`${storageStateKeyPrefix}:${connectorId}`); const storageKey = `${storageStateKeyPrefix}:${connectorId}`;
const stateStorage = sessionStorage.getItem(storageKey);
sessionStorage.removeItem(storageKey);
return stateStorage === state; return stateStorage === state;
}; };

2
pnpm-lock.yaml generated
View file

@ -232,6 +232,7 @@ importers:
recharts: ^2.1.13 recharts: ^2.1.13
remark-gfm: ^3.0.1 remark-gfm: ^3.0.1
stylelint: ^15.0.0 stylelint: ^15.0.0
superstruct: ^0.16.0
swr: ^1.3.0 swr: ^1.3.0
typescript: ^4.9.4 typescript: ^4.9.4
zod: ^3.20.2 zod: ^3.20.2
@ -305,6 +306,7 @@ importers:
recharts: 2.1.13_v2m5e27vhdewzwhryxwfaorcca recharts: 2.1.13_v2m5e27vhdewzwhryxwfaorcca
remark-gfm: 3.0.1 remark-gfm: 3.0.1
stylelint: 15.0.0 stylelint: 15.0.0
superstruct: 0.16.0
swr: 1.3.0_react@18.2.0 swr: 1.3.0_react@18.2.0
typescript: 4.9.4 typescript: 4.9.4
zod: 3.20.2 zod: 3.20.2