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:
parent
0d1bad9978
commit
3996ac4107
31 changed files with 593 additions and 141 deletions
|
@ -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"
|
||||||
|
|
3
packages/console/src/assets/images/mail.svg
Normal file
3
packages/console/src/assets/images/mail.svg
Normal 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 |
18
packages/console/src/components/UserAvatar/index.module.scss
Normal file
18
packages/console/src/components/UserAvatar/index.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
.avatar {
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.medium {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
33
packages/console/src/components/UserInfoCard/index.tsx
Normal file
33
packages/console/src/components/UserInfoCard/index.tsx
Normal 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;
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)}
|
||||||
|
|
|
@ -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 />} />
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
.avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-right: _.unit(2);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
||||||
|
.text {
|
||||||
|
font: var(--font-label-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
|
@ -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;
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
@ -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(',')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
17
packages/console/src/types/profile.ts
Normal file
17
packages/console/src/types/profile.ts
Normal 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>;
|
|
@ -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(),
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
|
@ -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 />} />
|
||||||
|
|
||||||
|
|
32
packages/ui/src/pages/Springboard/index.tsx
Normal file
32
packages/ui/src/pages/Springboard/index.tsx
Normal 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;
|
|
@ -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
2
pnpm-lock.yaml
generated
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue