From 3996ac41075fbad3f16dc47962a4cd0a81921498 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Mon, 6 Mar 2023 11:21:24 +0800 Subject: [PATCH] feat(console): link social account in ac profile page (#3288) --- packages/console/package.json | 1 + packages/console/src/assets/images/mail.svg | 3 + .../components/UserAvatar/index.module.scss | 18 +++ .../src/components/UserAvatar/index.tsx | 10 +- .../components/UserInfoCard/index.module.scss | 30 ++++ .../src/components/UserInfoCard/index.tsx | 33 ++++ packages/console/src/consts/index.ts | 1 + .../components/UserInfo/index.module.scss | 26 --- .../AppLayout/components/UserInfo/index.tsx | 13 +- packages/console/src/pages/Main/index.tsx | 2 + .../BasicUserInfoSection/index.module.scss | 5 - .../components/BasicUserInfoSection/index.tsx | 42 +++-- .../components/CardContent/index.module.scss | 25 +++ .../Profile/components/CardContent/index.tsx | 74 ++++++--- .../LinkAccountSection/index.module.scss | 10 ++ .../components/LinkAccountSection/index.tsx | 151 ++++++++++++++++++ .../components/NotSet/index.module.scss | 4 + .../pages/Profile/components/NotSet/index.tsx | 11 ++ .../components/Section/index.module.scss | 8 +- .../containers/HandleSocialCallback/index.tsx | 29 ++++ packages/console/src/pages/Profile/index.tsx | 53 +++--- packages/console/src/pages/Profile/utils.ts | 28 ++++ packages/console/src/types/profile.ts | 17 ++ packages/core/src/routes-me/social.ts | 24 ++- packages/core/src/routes-me/user.ts | 25 ++- packages/core/src/routes/connector.ts | 28 +--- packages/core/src/utils/connectors/index.ts | 23 ++- packages/ui/src/App.tsx | 2 + packages/ui/src/pages/Springboard/index.tsx | 32 ++++ packages/ui/src/utils/social-connectors.ts | 4 +- pnpm-lock.yaml | 2 + 31 files changed, 593 insertions(+), 141 deletions(-) create mode 100644 packages/console/src/assets/images/mail.svg create mode 100644 packages/console/src/components/UserAvatar/index.module.scss create mode 100644 packages/console/src/components/UserInfoCard/index.module.scss create mode 100644 packages/console/src/components/UserInfoCard/index.tsx delete mode 100644 packages/console/src/pages/Profile/components/BasicUserInfoSection/index.module.scss create mode 100644 packages/console/src/pages/Profile/components/LinkAccountSection/index.module.scss create mode 100644 packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx create mode 100644 packages/console/src/pages/Profile/components/NotSet/index.module.scss create mode 100644 packages/console/src/pages/Profile/components/NotSet/index.tsx create mode 100644 packages/console/src/pages/Profile/containers/HandleSocialCallback/index.tsx create mode 100644 packages/console/src/types/profile.ts create mode 100644 packages/ui/src/pages/Springboard/index.tsx diff --git a/packages/console/package.json b/packages/console/package.json index a591ef1b8..0ee362c8e 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -87,6 +87,7 @@ "recharts": "^2.1.13", "remark-gfm": "^3.0.1", "stylelint": "^15.0.0", + "superstruct": "^0.16.0", "swr": "^1.3.0", "typescript": "^4.9.4", "zod": "^3.20.2" diff --git a/packages/console/src/assets/images/mail.svg b/packages/console/src/assets/images/mail.svg new file mode 100644 index 000000000..1517d1224 --- /dev/null +++ b/packages/console/src/assets/images/mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/console/src/components/UserAvatar/index.module.scss b/packages/console/src/components/UserAvatar/index.module.scss new file mode 100644 index 000000000..6f98471bd --- /dev/null +++ b/packages/console/src/components/UserAvatar/index.module.scss @@ -0,0 +1,18 @@ +.avatar { + border-radius: 6px; + + &.small { + width: 32px; + height: 32px; + } + + &.medium { + width: 36px; + height: 36px; + } + + &.large { + width: 40px; + height: 40px; + } +} diff --git a/packages/console/src/components/UserAvatar/index.tsx b/packages/console/src/components/UserAvatar/index.tsx index 6fca40b7e..6db06093c 100644 --- a/packages/console/src/components/UserAvatar/index.tsx +++ b/packages/console/src/components/UserAvatar/index.tsx @@ -1,25 +1,29 @@ import { AppearanceMode } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; +import classNames from 'classnames'; import DarkAvatar from '@/assets/images/default-avatar-dark.svg'; import LightAvatar from '@/assets/images/default-avatar-light.svg'; import { useTheme } from '@/hooks/use-theme'; import ImageWithErrorFallback from '../ImageWithErrorFallback'; +import * as styles from './index.module.scss'; type Props = { className?: string; url?: Nullable; + size?: 'small' | 'medium' | 'large'; }; -const UserAvatar = ({ className, url }: Props) => { +const UserAvatar = ({ className, url, size = 'medium' }: Props) => { const theme = useTheme(); const DefaultAvatar = theme === AppearanceMode.LightMode ? LightAvatar : DarkAvatar; + const avatarClassName = classNames(styles.avatar, styles[size], className); if (url) { return ( { ); } - return ; + return ; }; export default UserAvatar; diff --git a/packages/console/src/components/UserInfoCard/index.module.scss b/packages/console/src/components/UserInfoCard/index.module.scss new file mode 100644 index 000000000..1bac1dc23 --- /dev/null +++ b/packages/console/src/components/UserInfoCard/index.module.scss @@ -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); + } +} diff --git a/packages/console/src/components/UserInfoCard/index.tsx b/packages/console/src/components/UserInfoCard/index.tsx new file mode 100644 index 000000000..53f55aa02 --- /dev/null +++ b/packages/console/src/components/UserInfoCard/index.tsx @@ -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 & + Pick + >; + 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 ( +
+ +
+
{name ?? username}
+ {emailToDisplay &&
{emailToDisplay}
} +
+
+ ); +}; + +export default UserInfoCard; diff --git a/packages/console/src/consts/index.ts b/packages/console/src/consts/index.ts index f74ef2119..370139ff0 100644 --- a/packages/console/src/consts/index.ts +++ b/packages/console/src/consts/index.ts @@ -7,5 +7,6 @@ export * from './page-tabs'; export * from './external-links'; export const themeStorageKey = 'logto:admin_console:theme'; +export const profileSocialLinkingKeyPrefix = 'logto:admin_console:linking_social_connector'; export const requestTimeout = 20_000; export const defaultPageSize = 20; diff --git a/packages/console/src/containers/AppLayout/components/UserInfo/index.module.scss b/packages/console/src/containers/AppLayout/components/UserInfo/index.module.scss index 5eb1d434d..3cb0c06f3 100644 --- a/packages/console/src/containers/AppLayout/components/UserInfo/index.module.scss +++ b/packages/console/src/containers/AppLayout/components/UserInfo/index.module.scss @@ -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 { min-width: 320px; .userInfo { - display: flex; - align-items: center; padding: _.unit(4) _.unit(5); - user-select: none; - cursor: default; } } diff --git a/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx b/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx index 66d91a85d..697a5a493 100644 --- a/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx +++ b/packages/console/src/containers/AppLayout/components/UserInfo/index.tsx @@ -16,6 +16,7 @@ import Dropdown, { DropdownItem } from '@/components/Dropdown'; import Spacer from '@/components/Spacer'; import { Ring as Spinner } from '@/components/Spinner'; import UserAvatar from '@/components/UserAvatar'; +import UserInfoCard from '@/components/UserInfoCard'; import { getSignOutRedirectPathname } from '@/consts'; import useUserPreferences from '@/hooks/use-user-preferences'; import { onKeyDownHandler } from '@/utils/a11y'; @@ -53,8 +54,6 @@ const UserInfo = () => { return ; } - const { username, name, picture, email } = user; - return ( <>
{ setShowDropdown(true); }} > - +
{ setShowDropdown(false); }} > -
- -
-
{name ?? username}
- {email &&
{email}
} -
-
+ { } /> } /> + } /> }> }> } /> diff --git a/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.module.scss b/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.module.scss deleted file mode 100644 index fc5343250..000000000 --- a/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -.avatar { - width: 40px; - height: 40px; - border-radius: 6px; -} diff --git a/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.tsx b/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.tsx index fe723a608..654b52896 100644 --- a/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.tsx +++ b/packages/console/src/pages/Profile/components/BasicUserInfoSection/index.tsx @@ -1,4 +1,4 @@ -import type { UserInfoResponse } from '@logto/react'; +import type { User } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; import { useState } from 'react'; @@ -10,10 +10,9 @@ import BasicUserInfoUpdateModal from '../../containers/BasicUserInfoUpdateModal' import type { Row } from '../CardContent'; import CardContent from '../CardContent'; import Section from '../Section'; -import * as styles from './index.module.scss'; type Props = { - user: UserInfoResponse; + user: User; onUpdate?: () => void; }; @@ -21,18 +20,21 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => { const [editingField, setEditingField] = useState(); const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); - const { name, username, picture: avatar } = user; + const { name, username, avatar } = user; const conditionalUsername: Array | undefined>> = isCloud ? [] : [ { + key: 'username', label: 'profile.settings.username', value: username, - actionName: 'profile.change', - action: () => { - setEditingField('username'); - setIsUpdateModalOpen(true); + action: { + name: 'profile.change', + handler: () => { + setEditingField('username'); + setIsUpdateModalOpen(true); + }, }, }, ]; @@ -50,22 +52,28 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => { title="profile.settings.profile_information" data={[ { + key: 'avatar', label: 'profile.settings.avatar', value: avatar, - renderer: (value) => , - actionName: 'profile.change', - action: () => { - setEditingField('avatar'); - setIsUpdateModalOpen(true); + renderer: (value) => , + action: { + name: 'profile.change', + handler: () => { + setEditingField('avatar'); + setIsUpdateModalOpen(true); + }, }, }, { + key: 'name', label: 'profile.settings.name', value: name, - actionName: name ? 'profile.change' : 'profile.set_name', - action: () => { - setEditingField('name'); - setIsUpdateModalOpen(true); + action: { + name: name ? 'profile.change' : 'profile.set_name', + handler: () => { + setEditingField('name'); + setIsUpdateModalOpen(true); + }, }, }, ...conditionalUsername, diff --git a/packages/console/src/pages/Profile/components/CardContent/index.module.scss b/packages/console/src/pages/Profile/components/CardContent/index.module.scss index 7fafb44c1..2c80acc76 100644 --- a/packages/console/src/pages/Profile/components/CardContent/index.module.scss +++ b/packages/console/src/pages/Profile/components/CardContent/index.module.scss @@ -8,6 +8,25 @@ 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 { width: 100%; border-spacing: 0; @@ -15,17 +34,23 @@ border-radius: 8px; td { + font: var(--font-body-2); height: 64px; padding: 0 _.unit(6); border-bottom: 1px solid var(--color-neutral-variant-90); &:first-child { width: 35%; + font: var(--font-label-2); } &:last-child { text-align: right; width: 30%; + + .wrapper { + justify-content: flex-end; + } } } diff --git a/packages/console/src/pages/Profile/components/CardContent/index.tsx b/packages/console/src/pages/Profile/components/CardContent/index.tsx index a23704af6..b595d1973 100644 --- a/packages/console/src/pages/Profile/components/CardContent/index.tsx +++ b/packages/console/src/pages/Profile/components/CardContent/index.tsx @@ -1,18 +1,26 @@ import type { AdminConsoleKey } from '@logto/phrases'; 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 TextLink from '@/components/TextLink'; +import Button from '@/components/Button'; +import NotSet from '../NotSet'; import * as styles from './index.module.scss'; +export type Action = { + name: AdminConsoleKey; + handler: () => void; +}; + export type Row = { - label: AdminConsoleKey; + key: string; + icon?: ReactElement; + label: AdminConsoleKey | ReactElement; value: T; - renderer?: (value: T) => ReactNode; - action: () => void; - actionName: AdminConsoleKey; + renderer?: (value: T) => ReactElement; + action: Action | Action[]; }; type Props = { @@ -20,9 +28,12 @@ type Props = { data: Array>; }; -const CardContent = | undefined>({ title, data }: Props) => { +const CardContent = > | undefined>({ + title, + data, +}: Props) => { 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)} : ); if (data.length === 0) { return null; @@ -33,21 +44,38 @@ const CardContent = | undefined>({ title, data }: Pr
{t(title)}
- {data.map(({ label, value, renderer = defaultRenderer, actionName, action }) => ( - - - - - - ))} + {data.map(({ key, icon, label, value, renderer = defaultRenderer, action }) => { + const actions = Array.isArray(action) ? action : [action]; + + return ( + + + + + + ); + })}
{t(label)}{renderer(value)} - { - action(); - }} - > - {t(actionName)} - -
+
+ {icon && + cloneElement(icon, { + className: styles.icon, + })} + {typeof label === 'string' ? t(label) : label} +
+
{renderer(value)} +
+ {actions.map(({ name, handler }) => ( +
+
diff --git a/packages/console/src/pages/Profile/components/LinkAccountSection/index.module.scss b/packages/console/src/pages/Profile/components/LinkAccountSection/index.module.scss new file mode 100644 index 000000000..5e8ace024 --- /dev/null +++ b/packages/console/src/pages/Profile/components/LinkAccountSection/index.module.scss @@ -0,0 +1,10 @@ +@use '@/scss/underscore' as _; + +.wrapper { + display: flex; + align-items: center; + + svg { + margin-right: _.unit(2); + } +} diff --git a/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx b/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx new file mode 100644 index 000000000..14161e85b --- /dev/null +++ b/packages/console/src/pages/Profile/components/LinkAccountSection/index.tsx @@ -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(); + + useEffect(() => { + (async () => { + const connectors = await api.get('me/social/connectors').json(); + + 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>> => { + 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: , + label: , + value: conditional(hasLinked && relatedUserDetails), + renderer: (user) => (user ? : ), + 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 ( +
+ ( +
+ + {email} +
+ ), + action: { + name: 'profile.change', + handler: () => { + navigate('link-email', { + state: { email: user.primaryEmail, action: 'changeEmail' }, + }); + }, + }, + }, + ]} + /> + +
+ ); +}; + +export default LinkAccountSection; diff --git a/packages/console/src/pages/Profile/components/NotSet/index.module.scss b/packages/console/src/pages/Profile/components/NotSet/index.module.scss new file mode 100644 index 000000000..847bd4936 --- /dev/null +++ b/packages/console/src/pages/Profile/components/NotSet/index.module.scss @@ -0,0 +1,4 @@ +.text { + font: var(--font-label-2); + color: var(--color-text-secondary); +} diff --git a/packages/console/src/pages/Profile/components/NotSet/index.tsx b/packages/console/src/pages/Profile/components/NotSet/index.tsx new file mode 100644 index 000000000..98ccd6025 --- /dev/null +++ b/packages/console/src/pages/Profile/components/NotSet/index.tsx @@ -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 {t('profile.not_set')}; +}; + +export default NotSet; diff --git a/packages/console/src/pages/Profile/components/Section/index.module.scss b/packages/console/src/pages/Profile/components/Section/index.module.scss index b9618d187..076733e49 100644 --- a/packages/console/src/pages/Profile/components/Section/index.module.scss +++ b/packages/console/src/pages/Profile/components/Section/index.module.scss @@ -16,7 +16,13 @@ } .content { - flex-grow: 1; + flex: 1; + display: flex; + flex-direction: column; + + > div + div { + margin-top: _.unit(6); + } } @media screen and (max-width: 1080px) { diff --git a/packages/console/src/pages/Profile/containers/HandleSocialCallback/index.tsx b/packages/console/src/pages/Profile/containers/HandleSocialCallback/index.tsx new file mode 100644 index 000000000..ab6d8eecc --- /dev/null +++ b/packages/console/src/pages/Profile/containers/HandleSocialCallback/index.tsx @@ -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 ; +}; + +export default HandleSocialCallback; diff --git a/packages/console/src/pages/Profile/index.tsx b/packages/console/src/pages/Profile/index.tsx index 561cf6b8e..32b9d7324 100644 --- a/packages/console/src/pages/Profile/index.tsx +++ b/packages/console/src/pages/Profile/index.tsx @@ -1,15 +1,22 @@ +import type { User } from '@logto/schemas'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import useSWR from 'swr'; import Button from '@/components/Button'; import CardTitle from '@/components/CardTitle'; +import { adminTenantEndpoint, meApi } from '@/consts'; 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 BasicUserInfoSection from './components/BasicUserInfoSection'; import CardContent from './components/CardContent'; +import LinkAccountSection from './components/LinkAccountSection'; +import NotSet from './components/NotSet'; import Section from './components/Section'; import DeleteAccountModal from './containers/DeleteAccountModal'; import * as styles from './index.module.scss'; @@ -17,48 +24,42 @@ import * as styles from './index.module.scss'; const Profile = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const navigate = useNavigate(); - const [user, fetchUser] = useLogtoUserInfo(); + const userId = useLogtoUserId(); + const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator }); + + const fetcher = useSwrFetcher(api); + const { data: user, mutate } = useSWR(userId && `me/users/${userId}`, fetcher); const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); if (!user) { return null; } + const { primaryEmail, passwordEncrypted } = user; + return (
- - {isCloud && ( -
- { - navigate('link-email', { state: { email: user.email, action: 'changeEmail' } }); - }, - }, - ]} - /> -
- )} + +
{ - navigate('verify-password', { - state: { email: user.email, action: 'changePassword' }, - }); + value: passwordEncrypted, + renderer: (value) => (value ? ******** : ), + action: { + name: 'profile.change', + handler: () => { + navigate('verify-password', { + state: { email: primaryEmail, action: 'changePassword' }, + }); + }, }, }, ]} diff --git a/packages/console/src/pages/Profile/utils.ts b/packages/console/src/pages/Profile/utils.ts index c65846081..1d5f90594 100644 --- a/packages/console/src/pages/Profile/utils.ts +++ b/packages/console/src/pages/Profile/utils.ts @@ -11,3 +11,31 @@ export const checkLocationState = (state: unknown): state is LocationState => typeof state.email === 'string' && typeof state.action === 'string' && ['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(',') + ); +}; diff --git a/packages/console/src/types/profile.ts b/packages/console/src/types/profile.ts new file mode 100644 index 000000000..f7a8fa3a1 --- /dev/null +++ b/packages/console/src/types/profile.ts @@ -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; + +export const socialUserInfoGuard = s.object({ + id: s.string(), + name: s.string(), + email: s.string(), + avatar: s.string(), +}); + +export type SocialUserInfo = s.Infer; diff --git a/packages/core/src/routes-me/social.ts b/packages/core/src/routes-me/social.ts index 85c84c47f..9996004ef 100644 --- a/packages/core/src/routes-me/social.ts +++ b/packages/core/src/routes-me/social.ts @@ -6,6 +6,7 @@ import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.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 { AuthedMeRouter } from './types.js'; @@ -20,15 +21,30 @@ export default function socialRoutes( ) { const { libraries: { - connectors: { getLogtoConnectorById }, + connectors: { getLogtoConnectors, getLogtoConnectorById }, }, queries: { users: { findUserById, updateUserById, deleteUserIdentity, hasUserWithIdentity }, + signInExperiences: { findDefaultSignInExperience }, }, } = 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( - '/social-authorization-uri', + '/social/authorization-uri', koaGuard({ body: object({ connectorId: string(), state: string(), redirectUri: string() }), }), @@ -70,7 +86,7 @@ export default function socialRoutes( ); router.post( - '/link-social-identity', + '/social/link-identity', koaGuard({ body: object({ connectorId: string(), @@ -139,7 +155,7 @@ export default function socialRoutes( ); router.delete( - '/social-identity/:connectorId', + '/social/identity/:connectorId', koaGuard({ params: object({ connectorId: string(), diff --git a/packages/core/src/routes-me/user.ts b/packages/core/src/routes-me/user.ts index 3bcafb2f8..e7a9dd81c 100644 --- a/packages/core/src/routes-me/user.ts +++ b/packages/core/src/routes-me/user.ts @@ -1,6 +1,11 @@ import { emailRegEx, passwordRegEx, usernameRegEx } from '@logto/core-kit'; 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 RequestError from '#src/errors/RequestError/index.js'; @@ -24,6 +29,24 @@ export default function userRoutes( }, } = 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( '/user', koaGuard({ diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index 7522522d4..a22cd37b9 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -1,8 +1,6 @@ import { VerificationCodeType, validateConfig } from '@logto/connector-kit'; import { emailRegEx, phoneRegEx, buildIdGenerator } from '@logto/core-kit'; -import type { ConnectorResponse, ConnectorFactoryResponse } from '@logto/schemas'; import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas'; -import { pick } from '@silverhand/essentials'; import cleanDeep from 'clean-deep'; 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 assertThat from '#src/utils/assert-that.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 type { ConnectorFactory, LogtoConnector } from '#src/utils/connectors/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); export default function connectorRoutes( diff --git a/packages/core/src/utils/connectors/index.ts b/packages/core/src/utils/connectors/index.ts index 9947a64a9..4113f65c7 100644 --- a/packages/core/src/utils/connectors/index.ts +++ b/packages/core/src/utils/connectors/index.ts @@ -4,9 +4,11 @@ import path from 'path'; import type { AllConnector, BaseConnector, GetConnectorConfig } 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 type { ConnectorFactory } from './types.js'; +import type { ConnectorFactory, LogtoConnector } from './types.js'; export function validateConnectorModule( connector: Partial> @@ -77,3 +79,22 @@ export const buildRawConnector = async ( 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 }; +}; diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3009c9802..d94149f65 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -21,6 +21,7 @@ import SignInPassword from './pages/SignInPassword'; import SocialLanding from './pages/SocialLanding'; import SocialLinkAccount from './pages/SocialLinkAccount'; import SocialSignIn from './pages/SocialSignInCallback'; +import Springboard from './pages/Springboard'; import VerificationCode from './pages/VerificationCode'; import { getSignInExperienceSettings } from './utils/sign-in-experience'; @@ -71,6 +72,7 @@ const App = () => { path="unknown-session" element={} /> + } /> } /> diff --git a/packages/ui/src/pages/Springboard/index.tsx b/packages/ui/src/pages/Springboard/index.tsx new file mode 100644 index 000000000..019e8cf05 --- /dev/null +++ b/packages/ui/src/pages/Springboard/index.tsx @@ -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 ; +}; + +export default Springboard; diff --git a/packages/ui/src/utils/social-connectors.ts b/packages/ui/src/utils/social-connectors.ts index ec668ec20..9673f59aa 100644 --- a/packages/ui/src/utils/social-connectors.ts +++ b/packages/ui/src/utils/social-connectors.ts @@ -22,7 +22,9 @@ export const storeState = (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; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 227cf91fa..51605bba3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,7 @@ importers: recharts: ^2.1.13 remark-gfm: ^3.0.1 stylelint: ^15.0.0 + superstruct: ^0.16.0 swr: ^1.3.0 typescript: ^4.9.4 zod: ^3.20.2 @@ -305,6 +306,7 @@ importers: recharts: 2.1.13_v2m5e27vhdewzwhryxwfaorcca remark-gfm: 3.0.1 stylelint: 15.0.0 + superstruct: 0.16.0 swr: 1.3.0_react@18.2.0 typescript: 4.9.4 zod: 3.20.2