mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
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
This commit is contained in:
parent
43a4a980d0
commit
3cd14ba75d
22 changed files with 791 additions and 84 deletions
|
@ -50,7 +50,6 @@ const App = () => {
|
|||
<AppBoundary>
|
||||
<AppInsightsBoundary cloudRole="ui">
|
||||
<Routes>
|
||||
<Route path="consent" element={<Consent />} />
|
||||
<Route element={<AppLayout />}>
|
||||
<Route
|
||||
path="unknown-session"
|
||||
|
@ -116,6 +115,9 @@ const App = () => {
|
|||
<Route path="connectors" element={<SingleSignOnConnectors />} />
|
||||
</Route>
|
||||
|
||||
{/* Consent */}
|
||||
<Route path="consent" element={<Consent />} />
|
||||
|
||||
<Route path="*" element={<ErrorPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
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 (
|
||||
<>
|
||||
<PageMeta titleKey={title} />
|
||||
<PageMeta titleKey={title} titleKeyInterpolation={titleInterpolation} />
|
||||
{platform === 'web' && <div className={styles.placeholderTop} />}
|
||||
<div className={classNames(styles.wrapper, className)}>
|
||||
<BrandingHeader
|
||||
className={classNames(styles.header, layoutClassNames.brandingHeader)}
|
||||
headline={title}
|
||||
headlineInterpolation={titleInterpolation}
|
||||
logo={getBrandingLogoUrl({ theme, branding, isDarkModeEnabled })}
|
||||
thirdPartyLogo={
|
||||
thirdPartyBranding &&
|
||||
getBrandingLogoUrl({ theme, branding: thirdPartyBranding, isDarkModeEnabled })
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
@ -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<Response>();
|
||||
return api
|
||||
.post('/api/interaction/consent', {
|
||||
json: {
|
||||
organizationIds: organizationId && [organizationId],
|
||||
},
|
||||
})
|
||||
.json<Response>();
|
||||
};
|
||||
|
||||
export const getConsentInfo = async () => {
|
||||
return api.get('/api/interaction/consent').json<ConsentInfoResponse>();
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
6
packages/experience/src/assets/icons/connect-icon.svg
Normal file
6
packages/experience/src/assets/icons/connect-icon.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg width="21" height="4" viewBox="0 0 21 4" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="2.5" cy="2" r="2" fill="currentcolor"/>
|
||||
<circle cx="10.5" cy="2" r="2" fill="currentcolor"/>
|
||||
<circle cx="18.5" cy="2" r="2" fill="currentcolor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 267 B |
4
packages/experience/src/assets/icons/expandable-icon.svg
Normal file
4
packages/experience/src/assets/icons/expandable-icon.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.575 11.9083L9.99999 15.4916L6.42499 11.9083C6.26807 11.7514 6.05524 11.6632 5.83333 11.6632C5.61141 11.6632 5.39858 11.7514 5.24166 11.9083C5.08474 12.0652 4.99658 12.278 4.99658 12.4999C4.99658 12.7219 5.08474 12.9347 5.24166 13.0916L9.40833 17.2583C9.48579 17.3364 9.57796 17.3984 9.67951 17.4407C9.78106 17.483 9.88998 17.5048 9.99999 17.5048C10.11 17.5048 10.2189 17.483 10.3205 17.4407C10.422 17.3984 10.5142 17.3364 10.5917 17.2583L14.7583 13.0916C14.836 13.0139 14.8977 12.9217 14.9397 12.8202C14.9818 12.7186 15.0034 12.6098 15.0034 12.4999C15.0034 12.3901 14.9818 12.2813 14.9397 12.1797C14.8977 12.0782 14.836 11.986 14.7583 11.9083C14.6806 11.8306 14.5884 11.7689 14.4869 11.7269C14.3853 11.6848 14.2765 11.6632 14.1667 11.6632C14.0568 11.6632 13.948 11.6848 13.8465 11.7269C13.7449 11.7689 13.6527 11.8306 13.575 11.9083ZM6.42499 8.09162L9.99999 4.50828L13.575 8.09162C13.6525 8.16972 13.7446 8.23172 13.8462 8.27402C13.9477 8.31633 14.0566 8.33811 14.1667 8.33811C14.2767 8.33811 14.3856 8.31633 14.4871 8.27402C14.5887 8.23172 14.6809 8.16972 14.7583 8.09162C14.8364 8.01415 14.8984 7.92198 14.9407 7.82043C14.983 7.71888 15.0048 7.60996 15.0048 7.49995C15.0048 7.38994 14.983 7.28102 14.9407 7.17947C14.8984 7.07792 14.8364 6.98575 14.7583 6.90828L10.5917 2.74162C10.5142 2.66351 10.422 2.60151 10.3205 2.55921C10.2189 2.5169 10.11 2.49512 9.99999 2.49512C9.88998 2.49512 9.78106 2.5169 9.67951 2.55921C9.57796 2.60151 9.48579 2.66351 9.40833 2.74162L5.24166 6.90828C5.08474 7.0652 4.99658 7.27803 4.99658 7.49995C4.99658 7.72187 5.08474 7.9347 5.24166 8.09162C5.39858 8.24854 5.61141 8.33669 5.83333 8.33669C6.05524 8.33669 6.26807 8.24854 6.42499 8.09162Z" fill="currentcolor" fill-opacity="0.35"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
|
@ -0,0 +1,4 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6665 6.66699H12.4998C12.7209 6.66699 12.9328 6.5792 13.0891 6.42291C13.2454 6.26663 13.3332 6.05467 13.3332 5.83366C13.3332 5.61265 13.2454 5.40068 13.0891 5.2444C12.9328 5.08812 12.7209 5.00033 12.4998 5.00033H11.6665C11.4455 5.00033 11.2335 5.08812 11.0772 5.2444C10.921 5.40068 10.8332 5.61265 10.8332 5.83366C10.8332 6.05467 10.921 6.26663 11.0772 6.42291C11.2335 6.5792 11.4455 6.66699 11.6665 6.66699ZM11.6665 10.0003H12.4998C12.7209 10.0003 12.9328 9.91253 13.0891 9.75625C13.2454 9.59997 13.3332 9.38801 13.3332 9.16699C13.3332 8.94598 13.2454 8.73402 13.0891 8.57774C12.9328 8.42146 12.7209 8.33366 12.4998 8.33366H11.6665C11.4455 8.33366 11.2335 8.42146 11.0772 8.57774C10.921 8.73402 10.8332 8.94598 10.8332 9.16699C10.8332 9.38801 10.921 9.59997 11.0772 9.75625C11.2335 9.91253 11.4455 10.0003 11.6665 10.0003ZM7.49984 6.66699H8.33317C8.55418 6.66699 8.76615 6.5792 8.92243 6.42291C9.07871 6.26663 9.1665 6.05467 9.1665 5.83366C9.1665 5.61265 9.07871 5.40068 8.92243 5.2444C8.76615 5.08812 8.55418 5.00033 8.33317 5.00033H7.49984C7.27882 5.00033 7.06686 5.08812 6.91058 5.2444C6.7543 5.40068 6.6665 5.61265 6.6665 5.83366C6.6665 6.05467 6.7543 6.26663 6.91058 6.42291C7.06686 6.5792 7.27882 6.66699 7.49984 6.66699ZM7.49984 10.0003H8.33317C8.55418 10.0003 8.76615 9.91253 8.92243 9.75625C9.07871 9.59997 9.1665 9.38801 9.1665 9.16699C9.1665 8.94598 9.07871 8.73402 8.92243 8.57774C8.76615 8.42146 8.55418 8.33366 8.33317 8.33366H7.49984C7.27882 8.33366 7.06686 8.42146 6.91058 8.57774C6.7543 8.73402 6.6665 8.94598 6.6665 9.16699C6.6665 9.38801 6.7543 9.59997 6.91058 9.75625C7.06686 9.91253 7.27882 10.0003 7.49984 10.0003ZM17.4998 16.667H16.6665V2.50033C16.6665 2.27931 16.5787 2.06735 16.4224 1.91107C16.2661 1.75479 16.0542 1.66699 15.8332 1.66699H4.1665C3.94549 1.66699 3.73353 1.75479 3.57725 1.91107C3.42097 2.06735 3.33317 2.27931 3.33317 2.50033V16.667H2.49984C2.27882 16.667 2.06686 16.7548 1.91058 16.9111C1.7543 17.0674 1.6665 17.2793 1.6665 17.5003C1.6665 17.7213 1.7543 17.9333 1.91058 18.0896C2.06686 18.2459 2.27882 18.3337 2.49984 18.3337H17.4998C17.7209 18.3337 17.9328 18.2459 18.0891 18.0896C18.2454 17.9333 18.3332 17.7213 18.3332 17.5003C18.3332 17.2793 18.2454 17.0674 18.0891 16.9111C17.9328 16.7548 17.7209 16.667 17.4998 16.667ZM10.8332 16.667H9.1665V13.3337H10.8332V16.667ZM14.9998 16.667H12.4998V12.5003C12.4998 12.2793 12.412 12.0674 12.2558 11.9111C12.0995 11.7548 11.8875 11.667 11.6665 11.667H8.33317C8.11216 11.667 7.9002 11.7548 7.74392 11.9111C7.58764 12.0674 7.49984 12.2793 7.49984 12.5003V16.667H4.99984V3.33366H14.9998V16.667Z" fill="currentcolor"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string>;
|
||||
thirdPartyLogo?: Nullable<string>;
|
||||
headline?: TFuncKey;
|
||||
headlineInterpolation?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
{logo && <img className={styles.logo} alt="app logo" src={logo} crossOrigin="anonymous" />}
|
||||
{shouldShowLogo && (
|
||||
<div className={styles.logoWrapper}>
|
||||
{thirdPartyLogo && (
|
||||
<img
|
||||
className={styles.logo}
|
||||
alt="third party logo"
|
||||
src={thirdPartyLogo}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
{shouldConnectSvg && <ConnectIcon className={styles.connectIcon} />}
|
||||
{logo && (
|
||||
<img className={styles.logo} alt="app logo" src={logo} crossOrigin="anonymous" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{headline && (
|
||||
<div className={styles.headline}>
|
||||
<DynamicT forKey={headline} />
|
||||
<DynamicT forKey={headline} interpolation={headlineInterpolation} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -7,28 +7,29 @@ import { useTranslation } from 'react-i18next';
|
|||
import { shouldTrack } from '@/utils/cookies';
|
||||
|
||||
type Props = {
|
||||
titleKey: TFuncKey | TFuncKey[];
|
||||
titleKey: TFuncKey;
|
||||
titleKeyInterpolation?: Record<string, unknown>;
|
||||
// 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 <Helmet title={title} />;
|
||||
return <Helmet title={String(title)} />;
|
||||
};
|
||||
|
||||
export default PageMeta;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ConsentInfoResponse['organizations'], undefined>[number];
|
||||
|
||||
type OrganizationItemProps = {
|
||||
organization: Organization;
|
||||
onSelect?: (organization: Organization) => void;
|
||||
isSelected?: boolean;
|
||||
suffixElement?: ReactNode;
|
||||
};
|
||||
|
||||
const OrganizationItem = ({
|
||||
organization,
|
||||
onSelect,
|
||||
isSelected,
|
||||
suffixElement,
|
||||
}: OrganizationItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className={styles.organizationItem}
|
||||
data-selected={isSelected}
|
||||
{...(onSelect && {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
onClick: () => {
|
||||
onSelect(organization);
|
||||
},
|
||||
onKeyDown: () => {
|
||||
onKeyDownHandler({
|
||||
Enter: () => {
|
||||
onSelect(organization);
|
||||
},
|
||||
' ': () => {
|
||||
onSelect(organization);
|
||||
},
|
||||
});
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckMark className={styles.icon} />
|
||||
) : (
|
||||
<OrganizationIcon className={styles.icon} />
|
||||
)}
|
||||
<div className={styles.organizationName}>{organization.name}</div>
|
||||
{suffixElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationItem;
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<HTMLDivElement>;
|
||||
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 (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
overlayClassName={styles.dropdownOverlay}
|
||||
className={styles.dropdownModal}
|
||||
style={{
|
||||
content: {
|
||||
...position,
|
||||
},
|
||||
}}
|
||||
closeTimeoutMS={isMobile ? 300 : 0}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<OrganizationItem
|
||||
key={organization.id}
|
||||
organization={organization}
|
||||
isSelected={organization.id === selectedOrganization.id}
|
||||
onSelect={() => {
|
||||
onClose();
|
||||
onSelect(organization);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationSelectorModal;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
if (organizations.length === 0 || !selectedOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={styles.title}>{t('description.grant_organization_access')}</div>
|
||||
<div ref={parentElementRef} className={styles.cardWrapper}>
|
||||
<OrganizationItem
|
||||
organization={selectedOrganization}
|
||||
suffixElement={
|
||||
<IconButton
|
||||
className={styles.expandButton}
|
||||
onClick={() => {
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
>
|
||||
<ExpandableIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<OrganizationSelectorModal
|
||||
isOpen={showDropdown}
|
||||
parentElementRef={parentElementRef}
|
||||
organizations={organizations}
|
||||
selectedOrganization={selectedOrganization}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationSelector;
|
|
@ -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);
|
||||
}
|
120
packages/experience/src/pages/Consent/ScopesListCard/index.tsx
Normal file
120
packages/experience/src/pages/Consent/ScopesListCard/index.tsx
Normal file
|
@ -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 (
|
||||
<div className={classNames(styles.scopeGroup)}>
|
||||
<div className={styles.scopeGroupHeader}>
|
||||
<CheckMark className={styles.check} />
|
||||
<div className={styles.scopeGroupName}>{groupName}</div>
|
||||
<IconButton className={styles.toggleButton} data-expanded={expanded} onClick={toggle}>
|
||||
<DownArrowIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
{expanded && (
|
||||
<ul className={styles.scopesList}>
|
||||
{scopes.map(({ id, name, description }) => (
|
||||
<li key={id} className={styles.scopeItem}>
|
||||
{description ?? name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={className}>
|
||||
<div className={styles.title}>{t('description.request_permission', { name: appName })}</div>
|
||||
<div className={styles.cardWrapper}>
|
||||
{userScopesData && userScopesData.length > 0 && (
|
||||
<ScopeGroup groupName="User Scopes" scopes={userScopesData} />
|
||||
)}
|
||||
{resourceScopes?.map(({ resource, scopes }) => (
|
||||
<ScopeGroup
|
||||
key={resource.id}
|
||||
groupName={
|
||||
resource.name === ReservedResource.Organization
|
||||
? t('description.organization_scopes')
|
||||
: resource.name
|
||||
}
|
||||
scopes={scopes}
|
||||
/>
|
||||
))}
|
||||
{showTerms && (
|
||||
<div className={styles.terms}>
|
||||
<Trans
|
||||
components={{
|
||||
link: <TermsLinks inline termsOfUseUrl={termsUrl} privacyPolicyUrl={privacyUrl} />,
|
||||
}}
|
||||
>
|
||||
{t('description.authorize_agreement', { name: appName })}
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScopesListCard;
|
|
@ -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);
|
||||
}
|
26
packages/experience/src/pages/Consent/UserProfile/index.tsx
Normal file
26
packages/experience/src/pages/Consent/UserProfile/index.tsx
Normal file
|
@ -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 (
|
||||
<div className={classNames(styles.wrapper, className)}>
|
||||
{avatar && <img src={avatar} alt="avatar" className={styles.avatar} />}
|
||||
<div>
|
||||
<div className={styles.name}>{name ?? id}</div>
|
||||
<div className={styles.identifier}>{primaryEmail ?? primaryPhone ?? username}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ConsentInfoResponse>();
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<Organization>();
|
||||
|
||||
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 (
|
||||
<div className={styles.viewBox}>
|
||||
<div className={styles.container}>
|
||||
{brandingLogo && (
|
||||
<img alt="logo" className={styles.img} src={brandingLogo} crossOrigin="anonymous" />
|
||||
)}
|
||||
<div className={styles.loadingWrapper}>{loading && <LoadingIcon />}</div>
|
||||
<LandingPageLayout
|
||||
title="description.authorize_title"
|
||||
titleInterpolation={{
|
||||
name: applicationName,
|
||||
}}
|
||||
thirdPartyBranding={consentData.application.branding}
|
||||
>
|
||||
<UserProfile user={consentData.user} />
|
||||
<ScopesListCard
|
||||
userScopes={consentData.missingOIDCScope}
|
||||
resourceScopes={consentData.missingResourceScopes}
|
||||
appName={applicationName}
|
||||
className={styles.scopesCard}
|
||||
termsUrl={consentData.application.termsOfUseUrl ?? undefined}
|
||||
privacyUrl={consentData.application.privacyPolicyUrl ?? undefined}
|
||||
/>
|
||||
{consentData.organizations && (
|
||||
<OrganizationSelector
|
||||
className={styles.organizationSelector}
|
||||
organizations={consentData.organizations}
|
||||
selectedOrganization={selectedOrganization}
|
||||
onSelect={setSelectedOrganization}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.footerButton}>
|
||||
<Button
|
||||
title="action.cancel"
|
||||
type="secondary"
|
||||
onClick={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
/>
|
||||
<Button title="action.authorize" onClick={consentHandler} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footerLink}>
|
||||
{t('description.not_you')}{' '}
|
||||
<TextLink replace to="/sign-in" text="action.use_another_account" />
|
||||
</div>
|
||||
</LandingPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue