From 3cd14ba75d604033e55dbcf75af347c8af7b5513 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 22 Jan 2024 11:22:44 +0800 Subject: [PATCH] feat(experience): implement the consent page (#5265) * feat(experience): update the consent page update the consent page * fix(experience): fix the style * fix(experience): remove legacy ut remove legacy api ut * fix(experience): add the missing link add the missing link * fix(experience): reorg the code to address CR comments reorg the code to address CR comments --- packages/experience/src/App.tsx | 4 +- .../src/Layout/LandingPageLayout/index.tsx | 20 ++- packages/experience/src/apis/consent.ts | 16 ++- packages/experience/src/apis/index.test.ts | 23 ---- .../src/assets/icons/connect-icon.svg | 6 + .../src/assets/icons/expandable-icon.svg | 4 + .../src/assets/icons/organization-icon.svg | 4 + .../BrandingHeader/index.module.scss | 13 +- .../src/components/BrandingHeader/index.tsx | 35 ++++- .../src/components/PageMeta/index.tsx | 15 ++- .../OrganizationItem/index.module.scss | 34 +++++ .../OrganizationItem/index.tsx | 58 +++++++++ .../index.module.scss | 56 ++++++++ .../OrganizationSelectorModal/index.tsx | 83 ++++++++++++ .../OrganizationSelector/index.module.scss | 35 +++++ .../Consent/OrganizationSelector/index.tsx | 65 ++++++++++ .../Consent/ScopesListCard/index.module.scss | 78 ++++++++++++ .../pages/Consent/ScopesListCard/index.tsx | 120 ++++++++++++++++++ .../Consent/UserProfile/index.module.scss | 26 ++++ .../src/pages/Consent/UserProfile/index.tsx | 26 ++++ .../src/pages/Consent/index.module.scss | 40 +++--- .../experience/src/pages/Consent/index.tsx | 114 +++++++++++++---- 22 files changed, 791 insertions(+), 84 deletions(-) delete mode 100644 packages/experience/src/apis/index.test.ts create mode 100644 packages/experience/src/assets/icons/connect-icon.svg create mode 100644 packages/experience/src/assets/icons/expandable-icon.svg create mode 100644 packages/experience/src/assets/icons/organization-icon.svg create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.module.scss create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.tsx create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.module.scss create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.tsx create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/index.module.scss create mode 100644 packages/experience/src/pages/Consent/OrganizationSelector/index.tsx create mode 100644 packages/experience/src/pages/Consent/ScopesListCard/index.module.scss create mode 100644 packages/experience/src/pages/Consent/ScopesListCard/index.tsx create mode 100644 packages/experience/src/pages/Consent/UserProfile/index.module.scss create mode 100644 packages/experience/src/pages/Consent/UserProfile/index.tsx diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index 18774eb62..6fe19045c 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -50,7 +50,6 @@ const App = () => { - } /> }> { } /> + {/* Consent */} + } /> + } /> diff --git a/packages/experience/src/Layout/LandingPageLayout/index.tsx b/packages/experience/src/Layout/LandingPageLayout/index.tsx index 1ab8fc303..6b5c769c7 100644 --- a/packages/experience/src/Layout/LandingPageLayout/index.tsx +++ b/packages/experience/src/Layout/LandingPageLayout/index.tsx @@ -1,3 +1,4 @@ +import { type ConsentInfoResponse } from '@logto/schemas'; import classNames from 'classnames'; import type { TFuncKey } from 'i18next'; import type { ReactNode } from 'react'; @@ -11,13 +12,23 @@ import { getBrandingLogoUrl } from '@/utils/logo'; import * as styles from './index.module.scss'; +type ThirdPartyBranding = ConsentInfoResponse['application']['branding']; + type Props = { children: ReactNode; className?: string; title: TFuncKey; + titleInterpolation?: Record; + thirdPartyBranding?: ThirdPartyBranding; }; -const LandingPageLayout = ({ children, className, title }: Props) => { +const LandingPageLayout = ({ + children, + className, + title, + titleInterpolation, + thirdPartyBranding, +}: Props) => { const { experienceSettings, theme, platform } = useContext(PageContext); if (!experienceSettings) { @@ -31,13 +42,18 @@ const LandingPageLayout = ({ children, className, title }: Props) => { return ( <> - + {platform === 'web' &&
}
{children}
diff --git a/packages/experience/src/apis/consent.ts b/packages/experience/src/apis/consent.ts index 0dbec795e..4eb8b6fef 100644 --- a/packages/experience/src/apis/consent.ts +++ b/packages/experience/src/apis/consent.ts @@ -1,9 +1,21 @@ +import { type ConsentInfoResponse } from '@logto/schemas'; + import api from './api'; -export const consent = async () => { +export const consent = async (organizationId?: string) => { type Response = { redirectTo: string; }; - return api.post('/api/interaction/consent').json(); + return api + .post('/api/interaction/consent', { + json: { + organizationIds: organizationId && [organizationId], + }, + }) + .json(); +}; + +export const getConsentInfo = async () => { + return api.get('/api/interaction/consent').json(); }; diff --git a/packages/experience/src/apis/index.test.ts b/packages/experience/src/apis/index.test.ts deleted file mode 100644 index 863e6d4c5..000000000 --- a/packages/experience/src/apis/index.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import ky from 'ky'; - -import { consent } from './consent'; - -jest.mock('ky', () => ({ - extend: () => ky, - post: jest.fn(() => ({ - json: jest.fn(), - })), -})); - -describe('api', () => { - const mockKyPost = ky.post as jest.Mock; - - afterEach(() => { - mockKyPost.mockClear(); - }); - - it('consent', async () => { - await consent(); - expect(ky.post).toBeCalledWith('/api/interaction/consent'); - }); -}); diff --git a/packages/experience/src/assets/icons/connect-icon.svg b/packages/experience/src/assets/icons/connect-icon.svg new file mode 100644 index 000000000..59d45878e --- /dev/null +++ b/packages/experience/src/assets/icons/connect-icon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/packages/experience/src/assets/icons/expandable-icon.svg b/packages/experience/src/assets/icons/expandable-icon.svg new file mode 100644 index 000000000..3d0c8a91e --- /dev/null +++ b/packages/experience/src/assets/icons/expandable-icon.svg @@ -0,0 +1,4 @@ + + + diff --git a/packages/experience/src/assets/icons/organization-icon.svg b/packages/experience/src/assets/icons/organization-icon.svg new file mode 100644 index 000000000..5154718f3 --- /dev/null +++ b/packages/experience/src/assets/icons/organization-icon.svg @@ -0,0 +1,4 @@ + + + diff --git a/packages/experience/src/components/BrandingHeader/index.module.scss b/packages/experience/src/components/BrandingHeader/index.module.scss index 206836c35..0e2c4d940 100644 --- a/packages/experience/src/components/BrandingHeader/index.module.scss +++ b/packages/experience/src/components/BrandingHeader/index.module.scss @@ -7,6 +7,15 @@ $logo-height: 40px; @include _.flex-column; } +.logoWrapper { + @include _.flex-row; +} + +.connectIcon { + color: var(--color-neutral-variant-80); + margin: 0 _.unit(3); +} + .logo { height: $logo-height; width: auto; @@ -27,7 +36,7 @@ $logo-height: 40px; max-height: 148px; } - .logo:not(:last-child) { + .logoWrapper:not(:last-child) { margin-bottom: _.unit(2); } @@ -37,7 +46,7 @@ $logo-height: 40px; } :global(body.desktop) { - .logo:not(:last-child) { + .logoWrapper:not(:last-child) { margin-bottom: _.unit(3); } diff --git a/packages/experience/src/components/BrandingHeader/index.tsx b/packages/experience/src/components/BrandingHeader/index.tsx index 3e5c5ebca..ffa90bf61 100644 --- a/packages/experience/src/components/BrandingHeader/index.tsx +++ b/packages/experience/src/components/BrandingHeader/index.tsx @@ -2,6 +2,8 @@ import type { Nullable } from '@silverhand/essentials'; import classNames from 'classnames'; import type { TFuncKey } from 'i18next'; +import ConnectIcon from '@/assets/icons/connect-icon.svg'; + import DynamicT from '../DynamicT'; import * as styles from './index.module.scss'; @@ -9,16 +11,43 @@ import * as styles from './index.module.scss'; export type Props = { className?: string; logo?: Nullable; + thirdPartyLogo?: Nullable; headline?: TFuncKey; + headlineInterpolation?: Record; }; -const BrandingHeader = ({ logo, headline, className }: Props) => { +const BrandingHeader = ({ + logo, + thirdPartyLogo, + headline, + headlineInterpolation, + className, +}: Props) => { + const shouldShowLogo = Boolean(thirdPartyLogo ?? logo); + const shouldConnectSvg = Boolean(thirdPartyLogo && logo); + return (
- {logo && app logo} + {shouldShowLogo && ( +
+ {thirdPartyLogo && ( + third party logo + )} + {shouldConnectSvg && } + {logo && ( + app logo + )} +
+ )} + {headline && (
- +
)}
diff --git a/packages/experience/src/components/PageMeta/index.tsx b/packages/experience/src/components/PageMeta/index.tsx index c4bf1c281..eb3ad32cd 100644 --- a/packages/experience/src/components/PageMeta/index.tsx +++ b/packages/experience/src/components/PageMeta/index.tsx @@ -7,28 +7,29 @@ import { useTranslation } from 'react-i18next'; import { shouldTrack } from '@/utils/cookies'; type Props = { - titleKey: TFuncKey | TFuncKey[]; + titleKey: TFuncKey; + titleKeyInterpolation?: Record; // eslint-disable-next-line react/boolean-prop-naming trackPageView?: boolean; }; -const PageMeta = ({ titleKey, trackPageView = true }: Props) => { +const PageMeta = ({ titleKey, titleKeyInterpolation = {}, trackPageView = true }: Props) => { const { t } = useTranslation(); const { isSetupFinished, appInsights } = useContext(AppInsightsContext); const [pageViewTracked, setPageViewTracked] = useState(false); - const keys = typeof titleKey === 'string' ? [titleKey] : titleKey; - const rawTitle = keys.map((key) => t(key, { lng: 'en' })).join(' - '); - const title = keys.map((key) => t(key)).join(' - '); + + const rawTitle = t(titleKey, { lng: 'en', ...titleKeyInterpolation }); + const title = t(titleKey, titleKeyInterpolation); useEffect(() => { // Only track once for the same page if (shouldTrack && isSetupFinished && trackPageView && !pageViewTracked) { - appInsights.trackPageView?.({ name: `Main flow: ${rawTitle}` }); + appInsights.trackPageView?.({ name: `Main flow: ${String(rawTitle)}` }); setPageViewTracked(true); } }, [appInsights, isSetupFinished, pageViewTracked, rawTitle, trackPageView]); - return ; + return ; }; export default PageMeta; diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.module.scss b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.module.scss new file mode 100644 index 000000000..8833f8dc5 --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.module.scss @@ -0,0 +1,34 @@ +@use '@/scss/underscore' as _; + +.organizationItem { + @include _.flex-row; + padding: _.unit(2) _.unit(4); + + .icon { + width: 20px; + height: 20px; + color: var(--color-type-secondary); + margin-right: _.unit(2); + } + + .organizationName { + font: var(--font-body-2); + flex: 1; + } + + &[role='button'] { + cursor: pointer; + + &:hover { + background-color: var(--color-overlay-brand-hover); + } + } + + &[data-selected='true'] { + color: var(--color-brand-default); + + .icon { + color: var(--color-brand-default); + } + } +} diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.tsx b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.tsx new file mode 100644 index 000000000..46c10a48f --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationItem/index.tsx @@ -0,0 +1,58 @@ +import { type ConsentInfoResponse } from '@logto/schemas'; +import { type ReactNode } from 'react'; + +import CheckMark from '@/assets/icons/check-mark.svg'; +import OrganizationIcon from '@/assets/icons/organization-icon.svg'; +import { onKeyDownHandler } from '@/utils/a11y'; + +import * as styles from './index.module.scss'; + +export type Organization = Exclude[number]; + +type OrganizationItemProps = { + organization: Organization; + onSelect?: (organization: Organization) => void; + isSelected?: boolean; + suffixElement?: ReactNode; +}; + +const OrganizationItem = ({ + organization, + onSelect, + isSelected, + suffixElement, +}: OrganizationItemProps) => { + return ( +
{ + onSelect(organization); + }, + onKeyDown: () => { + onKeyDownHandler({ + Enter: () => { + onSelect(organization); + }, + ' ': () => { + onSelect(organization); + }, + }); + }, + })} + > + {isSelected ? ( + + ) : ( + + )} +
{organization.name}
+ {suffixElement} +
+ ); +}; + +export default OrganizationItem; diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.module.scss b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.module.scss new file mode 100644 index 000000000..afb5b8d3f --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.module.scss @@ -0,0 +1,56 @@ +@use '@/scss/underscore' as _; + +.dropdownOverlay { + background: transparent; + position: fixed; + inset: 0; + z-index: 40; +} + +.dropdownModal { + border: _.border(var(--color-line-divider)); + box-shadow: var(--color-shadow-2); + border-radius: _.unit(2); + background-color: var(--color-bg-float); + position: absolute; + z-index: 50; + padding: _.unit(2) 0; +} + +:global(body.mobile) { + .dropdownOverlay { + background: var(--color-bg-mask); + } + + // Use bottom drawer for mobile + .dropdownModal { + border: none; + border-top: _.border(var(--color-line-divider)); + box-shadow: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + bottom: 0; + width: 100%; + height: 300px; + padding: _.unit(4) 0; + } +} + +:global { + body.mobile { + .ReactModal__Content[class*='dropdownModal'] { + transform: translateY(100%); + transition: transform 0.2s ease-in-out; + } + + /* stylelint-disable-next-line selector-class-pattern */ + .ReactModal__Content--after-open[class*='dropdownModal'] { + transform: translateY(0); + } + + /* stylelint-disable-next-line selector-class-pattern */ + .ReactModal__Content--before-close[class*='dropdownModal'] { + transform: translateY(100%); + } + } +} diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.tsx b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.tsx new file mode 100644 index 000000000..ed7d5705e --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/OrganizationSelectorModal/index.tsx @@ -0,0 +1,83 @@ +import { useState, useCallback, useLayoutEffect } from 'react'; +import ReactModal from 'react-modal'; + +import usePlatform from '@/hooks/use-platform'; + +import OrganizationItem from '../OrganizationItem'; +import { type Organization } from '../OrganizationItem'; + +import * as styles from './index.module.scss'; + +type Props = { + isOpen: boolean; + parentElementRef: React.RefObject; + organizations: Organization[]; + selectedOrganization: Organization; + onSelect: (organization: Organization) => void; + onClose: () => void; +}; + +const OrganizationSelectorModal = ({ + isOpen, + parentElementRef, + organizations, + selectedOrganization, + onSelect, + onClose, +}: Props) => { + const { isMobile } = usePlatform(); + const [position, setPosition] = useState({}); + + const updatePosition = useCallback(() => { + const parent = parentElementRef.current; + + if (!parent || isMobile) { + setPosition({}); + return; + } + + const offset = 8; + const { top, left, height, width } = parent.getBoundingClientRect(); + setPosition({ top: top + height + offset, left, width }); + }, [isMobile, parentElementRef]); + + useLayoutEffect(() => { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + }; + }, [updatePosition]); + + return ( + + {organizations.map((organization) => ( + { + onClose(); + onSelect(organization); + }} + /> + ))} + + ); +}; + +export default OrganizationSelectorModal; diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/index.module.scss b/packages/experience/src/pages/Consent/OrganizationSelector/index.module.scss new file mode 100644 index 000000000..cfcf1ed67 --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/index.module.scss @@ -0,0 +1,35 @@ +@use '@/scss/underscore' as _; + +.title { + font: var(--font-label-2); + margin-bottom: _.unit(2); +} + +.cardWrapper { + border: _.border(var(--color-line-divider)); + border-radius: 8px; + padding: _.unit(2) 0; + position: relative; +} + +.expandButton { + position: relative; + + // increase the clickable area + &::after { + content: ''; + display: block; + position: absolute; + top: -10px; + left: -10px; + width: 40px; + height: 40px; + } + + svg { + width: 20px; + height: 20px; + color: var(--color-type-secondary); + } +} + diff --git a/packages/experience/src/pages/Consent/OrganizationSelector/index.tsx b/packages/experience/src/pages/Consent/OrganizationSelector/index.tsx new file mode 100644 index 000000000..f6b870d22 --- /dev/null +++ b/packages/experience/src/pages/Consent/OrganizationSelector/index.tsx @@ -0,0 +1,65 @@ +import { useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import ExpandableIcon from '@/assets/icons/expandable-icon.svg'; +import IconButton from '@/components/Button/IconButton'; + +import OrganizationItem, { type Organization } from './OrganizationItem'; +import OrganizationSelectorModal from './OrganizationSelectorModal'; +import * as styles from './index.module.scss'; + +export { type Organization } from './OrganizationItem'; + +type Props = { + organizations: Organization[]; + selectedOrganization: Organization | undefined; + onSelect: (organization: Organization) => void; + className?: string; +}; +const OrganizationSelector = ({ + organizations, + selectedOrganization, + onSelect, + className, +}: Props) => { + const { t } = useTranslation(); + const parentElementRef = useRef(null); + const [showDropdown, setShowDropdown] = useState(false); + + if (organizations.length === 0 || !selectedOrganization) { + return null; + } + + return ( +
+
{t('description.grant_organization_access')}
+
+ { + setShowDropdown(true); + }} + > + + + } + /> +
+ { + setShowDropdown(false); + }} + /> +
+ ); +}; + +export default OrganizationSelector; diff --git a/packages/experience/src/pages/Consent/ScopesListCard/index.module.scss b/packages/experience/src/pages/Consent/ScopesListCard/index.module.scss new file mode 100644 index 000000000..cc8d1ed9d --- /dev/null +++ b/packages/experience/src/pages/Consent/ScopesListCard/index.module.scss @@ -0,0 +1,78 @@ +@use '@/scss/underscore' as _; + +.title { + font: var(--font-label-2); + margin-bottom: _.unit(2); +} + +.cardWrapper { + border: _.border(var(--color-line-divider)); + border-radius: 8px; + padding: _.unit(2) 0; +} + +.scopeGroup { + padding: 0 _.unit(4); + + .scopeGroupHeader { + @include _.flex-row; + } + + .check { + color: var(--color-success-default); + width: 20px; + height: 20px; + margin-right: _.unit(2); + } + + .scopeGroupName { + font: var(--font-body-2); + flex: 1; + margin-right: _.unit(2); + padding: _.unit(2) 0; + } + + .toggleButton { + transition: transform 0.2s ease-in-out; + position: relative; + + svg { + width: 20px; + height: 20px; + } + + &[data-expanded='true'] { + transform: rotate(180deg); + } + + // increase the clickable area + &::after { + content: ''; + display: block; + position: absolute; + top: -10px; + left: -10px; + width: 40px; + height: 40px; + } + } + + .scopesList { + padding-inline-start: _.unit(6); + font: var(--font-body-3); + color: var(--color-type-secondary); + margin: 0; + } + + .scopeItem { + padding: _.unit(0.5) 0 _.unit(0.5) _.unit(1); + margin-bottom: _.unit(1.5); + } +} + +.terms { + padding: _.unit(4); + border-top: _.border(var(--color-line-divider)); + margin-top: _.unit(2); + color: var(--color-type-secondary); +} diff --git a/packages/experience/src/pages/Consent/ScopesListCard/index.tsx b/packages/experience/src/pages/Consent/ScopesListCard/index.tsx new file mode 100644 index 000000000..57258f0f6 --- /dev/null +++ b/packages/experience/src/pages/Consent/ScopesListCard/index.tsx @@ -0,0 +1,120 @@ +import { ReservedResource } from '@logto/core-kit'; +import { type ConsentInfoResponse } from '@logto/schemas'; +import classNames from 'classnames'; +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import DownArrowIcon from '@/assets/icons/arrow-down.svg'; +import CheckMark from '@/assets/icons/check-mark.svg'; +import IconButton from '@/components/Button/IconButton'; +import TermsLinks from '@/components/TermsLinks'; + +import * as styles from './index.module.scss'; + +type ScopeGroupProps = { + groupName: string; + scopes: Array<{ + id: string; + name: string; + description?: string; + }>; +}; + +const ScopeGroup = ({ groupName, scopes }: ScopeGroupProps) => { + const [expanded, setExpanded] = useState(false); + + const toggle = useCallback(() => { + setExpanded((previous) => !previous); + }, []); + + return ( +
+
+ +
{groupName}
+ + + +
+ {expanded && ( +
    + {scopes.map(({ id, name, description }) => ( +
  • + {description ?? name} +
  • + ))} +
+ )} +
+ ); +}; + +type Props = { + userScopes: ConsentInfoResponse['missingOIDCScope']; + resourceScopes: ConsentInfoResponse['missingResourceScopes']; + appName: string; + className?: string; + children?: React.ReactNode; + termsUrl?: string; + privacyUrl?: string; +}; + +const ScopesListCard = ({ + userScopes, + resourceScopes, + appName, + termsUrl, + privacyUrl, + className, + children, +}: Props) => { + const { t } = useTranslation(); + + // TODO: implement the userScopes description + const userScopesData = useMemo( + () => + userScopes?.map((scope) => ({ + id: scope, + name: scope, + })), + [userScopes] + ); + + const showTerms = Boolean(termsUrl ?? privacyUrl); + + return ( +
+
{t('description.request_permission', { name: appName })}
+
+ {userScopesData && userScopesData.length > 0 && ( + + )} + {resourceScopes?.map(({ resource, scopes }) => ( + + ))} + {showTerms && ( +
+ , + }} + > + {t('description.authorize_agreement', { name: appName })} + +
+ )} +
+
+ ); +}; + +export default ScopesListCard; diff --git a/packages/experience/src/pages/Consent/UserProfile/index.module.scss b/packages/experience/src/pages/Consent/UserProfile/index.module.scss new file mode 100644 index 000000000..d155b6344 --- /dev/null +++ b/packages/experience/src/pages/Consent/UserProfile/index.module.scss @@ -0,0 +1,26 @@ +@use '@/scss/underscore' as _; + +.wrapper { + border: _.border(var(--color-line-divider)); + border-radius: 8px; + padding: _.unit(4); + @include _.flex-column(normal, normal); +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: fill; + object-position: center; + margin-right: _.unit(3); +} + +.name { + font: var(--font-label-2); +} + +.identifier { + font: var(--font-body-3); + color: var(--color-type-secondary); +} diff --git a/packages/experience/src/pages/Consent/UserProfile/index.tsx b/packages/experience/src/pages/Consent/UserProfile/index.tsx new file mode 100644 index 000000000..a5e5b7540 --- /dev/null +++ b/packages/experience/src/pages/Consent/UserProfile/index.tsx @@ -0,0 +1,26 @@ +import { type ConsentInfoResponse } from '@logto/schemas'; +import classNames from 'classnames'; + +import * as styles from './index.module.scss'; + +type Props = { + user: ConsentInfoResponse['user']; + className?: string; +}; + +const UserProfile = ({ + user: { id, avatar, name, primaryEmail, primaryPhone, username }, + className, +}: Props) => { + return ( +
+ {avatar && avatar} +
+
{name ?? id}
+
{primaryEmail ?? primaryPhone ?? username}
+
+
+ ); +}; + +export default UserProfile; diff --git a/packages/experience/src/pages/Consent/index.module.scss b/packages/experience/src/pages/Consent/index.module.scss index 22e47fdd3..7e858fa5b 100644 --- a/packages/experience/src/pages/Consent/index.module.scss +++ b/packages/experience/src/pages/Consent/index.module.scss @@ -1,25 +1,33 @@ @use '@/scss/underscore' as _; -.viewBox { - position: absolute; - inset: 0; - overflow: auto; +.scopesCard, +.organizationSelector { + margin-top: _.unit(6); +} - .container { - min-height: 100%; - @include _.flex_column(center, center); - } +.footerButton { + margin-top: _.unit(7); + @include _.flex_row; + gap: _.unit(2); +} - .img { - height: _.unit(10); - @include _.image-align-center; - margin-bottom: _.unit(5); - } +.footerLink { + align-items: center; + margin-top: _.unit(4); + @include _.flex_row; + justify-content: center; + gap: _.unit(1); +} - .loadingWrapper { - height: _.unit(16); - @include _.flex_column(center, center); +:global(body.mobile) { + .scopesCard { + margin-top: _.unit(4); } } +:global(body.desktop) { + .scopesCard { + margin-top: _.unit(6); + } +} diff --git a/packages/experience/src/pages/Consent/index.tsx b/packages/experience/src/pages/Consent/index.tsx index ed90dced5..7d0e046c6 100644 --- a/packages/experience/src/pages/Consent/index.tsx +++ b/packages/experience/src/pages/Consent/index.tsx @@ -1,32 +1,46 @@ -import { conditional } from '@silverhand/essentials'; -import { useEffect, useContext, useState } from 'react'; +import { type ConsentInfoResponse } from '@logto/schemas'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; -import PageContext from '@/Providers/PageContextProvider/PageContext'; -import { consent } from '@/apis/consent'; -import { LoadingIcon } from '@/components/LoadingLayer'; +import LandingPageLayout from '@/Layout/LandingPageLayout'; +import { consent, getConsentInfo } from '@/apis/consent'; +import Button from '@/components/Button'; +import TextLink from '@/components/TextLink'; import useApi from '@/hooks/use-api'; import useErrorHandler from '@/hooks/use-error-handler'; -import { getBrandingLogoUrl } from '@/utils/logo'; +import OrganizationSelector, { type Organization } from './OrganizationSelector'; +import ScopesListCard from './ScopesListCard'; +import UserProfile from './UserProfile'; import * as styles from './index.module.scss'; const Consent = () => { - const { experienceSettings, theme } = useContext(PageContext); const handleError = useErrorHandler(); const asyncConsent = useApi(consent); - const { branding, color } = experienceSettings ?? {}; - const brandingLogo = conditional( - branding && - color && - getBrandingLogoUrl({ theme, branding, isDarkModeEnabled: color.isDarkModeEnabled }) - ); + const { t } = useTranslation(); - const [loading, setLoading] = useState(true); + const [consentData, setConsentData] = useState(); + const [selectedOrganization, setSelectedOrganization] = useState(); + + const asyncGetConsentInfo = useApi(getConsentInfo); + + const consentHandler = useCallback(async () => { + const [error, result] = await asyncConsent(selectedOrganization?.id); + + if (error) { + await handleError(error); + + return; + } + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [asyncConsent, handleError, selectedOrganization?.id]); useEffect(() => { - (async () => { - const [error, result] = await asyncConsent(); - setLoading(false); + const getConsentInfoHandler = async () => { + const [error, result] = await asyncGetConsentInfo(); if (error) { await handleError(error); @@ -34,21 +48,65 @@ const Consent = () => { return; } - if (result?.redirectTo) { - window.location.replace(result.redirectTo); + setConsentData(result); + + // Init the default organization selection + if (!result?.organizations?.length) { + return; } - })(); - }, [asyncConsent, handleError]); + + setSelectedOrganization(result.organizations[0]); + }; + + void getConsentInfoHandler(); + }, [asyncGetConsentInfo, handleError]); + + if (!consentData) { + return null; + } + + const applicationName = consentData.application.displayName ?? consentData.application.name; return ( -
-
- {brandingLogo && ( - logo - )} -
{loading && }
+ + + + {consentData.organizations && ( + + )} +
+
-
+
+ {t('description.not_you')}{' '} + +
+ ); };