0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(ui): add social landing page (#972)

* refactor(ui): add social landing page

add social landing page

* fix(ui): add ui coverage

add ui converage job

* fix(ui): fix ci

fix ci

* test(ui): add ut cases for parseQueryParameters

add unit test case for util parseQueryParameter

* refactor(ui): pass whole connector into the invokeSocialSignInHandler

pass whole connector object into the invokeSocialSignInHandler

* fix(ui): cr fix

cr fix
This commit is contained in:
simeng-li 2022-05-30 09:42:59 +08:00 committed by GitHub
parent e75c9de488
commit d0d507ab79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 343 additions and 131 deletions

View file

@ -81,12 +81,12 @@ const translation = {
username_valid_charset: 'Username should only contain letters, numbers, or underscore.',
invalid_email: 'The email is invalid',
invalid_phone: 'The phone number is invalid',
invalid_connector_auth: 'The authorization is invalid',
missing_auth_data: 'The authorization code or state is missing',
password_min_length: 'Password requires a minimum of {{min}} characters.',
passwords_do_not_match: 'Passwords do not match.',
agree_terms_required: 'You must agree to the Terms of Use before continuing.',
invalid_passcode: 'The passcode is invalid.',
invalid_connector_auth: 'The authorization is invalid.',
invalid_connector_request: 'The connector data is invalid.',
request: 'Request error {{message}}',
unknown: 'Unknown error, please try again later.',
invalid_session: 'Session not found. Please go back and sign in again.',

View file

@ -81,13 +81,13 @@ const translation = {
username_valid_charset: '用户名只能包含英文字母、数字或下划线。',
invalid_email: '无效的邮箱。',
invalid_phone: '无效的手机号。',
invalid_connector_auth: '登录失败',
missing_auth_data: '未找到有效的登录信息',
password_min_length: '密码最少需要{{min}}个字符。',
passwords_do_not_match: '密码不匹配。',
agree_terms_required: '你需要同意使用条款以继续。',
invalid_passcode: '无效的验证码。',
request: '请求错误:{{ message }}',
invalid_connector_auth: '登录失败。',
invalid_connector_request: '无效的登录请求。',
request: '请求错误:{{ message }}。',
unknown: '未知错误,请稍后重试。',
invalid_session: '未找到有效的会话,请重新登录。',
},

View file

@ -13,6 +13,7 @@
"lint": "eslint --ext .ts --ext .tsx src",
"lint:report": "pnpm lint --format json --output-file report.json",
"stylelint": "stylelint \"src/**/*.scss\"",
"test:coverage": "jest --coverage --silent",
"test": "jest"
},
"devDependencies": {

View file

@ -14,6 +14,7 @@ import Passcode from './pages/Passcode';
import Register from './pages/Register';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SocialLanding from './pages/SocialLanding';
import SocialRegister from './pages/SocialRegister';
import SocialSignInCallback from './pages/SocialSignInCallback';
import getSignInExperienceSettings from './utils/sign-in-experience';
@ -55,12 +56,16 @@ const App = () => {
<Route path="/" element={<Navigate replace to="/sign-in" />} />
<Route path="/sign-in" element={<SignIn />} />
<Route path="/sign-in/consent" element={<Consent />} />
<Route path="/sign-in/callback/:connector" element={<SocialSignInCallback />} />
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/register" element={<Register />} />
<Route path="/register/:method" element={<Register />} />
{/* social sign-in pages */}
<Route path="/sign-in/callback/:connector" element={<SocialSignInCallback />} />
<Route path="/callback/:connector" element={<Callback />} />
<Route path="/social-register/:connector" element={<SocialRegister />} />
<Route path="/social-landing/:connector" element={<SocialLanding />} />
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
<Route
path="/unknown-session"

View file

@ -0,0 +1,22 @@
@use '@/scss/underscore' as _;
.connector > img {
width: 96px;
height: 96px;
@include _.image-align-center;
}
.message {
margin-top: _.unit(2);
}
.container {
@include _.flex-column;
}
:global(body.desktop) {
.message {
margin-top: _.unit(1);
}
}

View file

@ -0,0 +1,28 @@
import classNames from 'classnames';
import React, { useContext } from 'react';
import { PageContext } from '@/hooks/use-page-context';
import * as styles from './index.module.scss';
type Props = {
className?: string;
connectorId: string;
message?: string;
};
const SocialLanding = ({ className, connectorId, message }: Props) => {
const { experienceSettings } = useContext(PageContext);
const connector = experienceSettings?.socialConnectors.find(({ id }) => id === connectorId);
return (
<div className={classNames(styles.container, className)}>
<div className={styles.connector}>
{connector?.logo ? <img src={connector.logo} /> : connectorId}
</div>
<div className={styles.message}>{message}</div>
</div>
);
};
export default SocialLanding;

View file

@ -45,7 +45,8 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props)
setContentStyle(undefined);
}}
>
{connectors.map(({ id, name, logo, target }) => {
{connectors.map((connector) => {
const { id, name, logo } = connector;
const languageKey = Object.keys(name).find((key) => key === language) ?? 'en';
const localName = name[languageKey as Language];
@ -53,7 +54,7 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props)
<DropdownItem
key={id}
onClick={() => {
void invokeSocialSignIn(id, target, onClose);
void invokeSocialSignIn(connector, onClose);
}}
>
<img src={logo} alt={id} className={styles.socialLogo} />

View file

@ -34,7 +34,7 @@ const SocialSignInIconList = ({
className={styles.socialButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id, connector.target);
void invokeSocialSignIn(connector);
}}
/>
))}

View file

@ -42,7 +42,7 @@ const SocialSignInList = ({
className={styles.socialLinkButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id, connector.target, onSocialSignInCallback);
void invokeSocialSignIn(connector, onSocialSignInCallback);
}}
/>
))}

View file

@ -1,48 +1,46 @@
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { parseQueryParameters } from '@/utils';
import { PageContext } from './use-page-context';
import { decodeState } from './utils';
import { getCallbackLinkFromStorage } from './utils';
const useSocialCallbackHandler = () => {
const { setToast } = useContext(PageContext);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const parameters = useParams();
const navigate = useNavigate();
const socialCallbackHandler = useCallback(() => {
const data = window.location.search || '?' + window.location.hash.slice(1);
const { state, error, error_description } = parseQueryParameters(data);
const connectorId = parameters.connector;
const socialCallbackHandler = useCallback(
(connectorId: string) => {
const data = window.location.search || '?' + window.location.hash.slice(1);
const { state, error, error_description } = parseQueryParameters(data);
// Connector auth error
if (error) {
setToast(`${error}${error_description ? `: ${error_description}` : ''}`);
}
// Connector auth error
if (error) {
setToast(`${error}${error_description ? `: ${error_description}` : ''}`);
// Connector auth missing state
if (!state || !connectorId) {
setToast(t('error.missing_auth_data'));
return;
}
return;
}
// Connector auth missing state
if (!state || !connectorId) {
setToast(t('error.invalid_connector_auth'));
const decodedState = decodeState(state);
return;
}
// Invalid state
if (!decodedState) {
setToast(t('error.missing_auth_data'));
// Get native callback link from storage
const callbackLink = getCallbackLinkFromStorage(connectorId);
return;
}
if (callbackLink) {
window.location.replace(new URL(`${callbackLink}${window.location.search}`));
const { platform, callbackLink } = decodedState;
return;
}
// Web/Mobile-Web redirect to sign-in/callback page to login
if (platform === 'web') {
// Web flow
navigate(
{
pathname: `/sign-in/callback/${connectorId}`,
@ -52,17 +50,9 @@ const useSocialCallbackHandler = () => {
replace: true,
}
);
return;
}
// Native Webview redirect to native app
if (!callbackLink) {
throw new Error('CallbackLink is empty');
}
window.location.assign(new URL(`${callbackLink}${data}`));
}, [navigate, parameters.connector, setToast, t]);
},
[navigate, setToast, t]
);
return socialCallbackHandler;
};

View file

@ -0,0 +1,34 @@
import { useEffect, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { PageContext } from './use-page-context';
import { storeCallbackLink } from './utils';
const useSocialLandingHandler = (connectorId?: string) => {
const { setToast } = useContext(PageContext);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { search } = window.location;
useEffect(() => {
const redirectUri = getSearchParameters(search, SearchParameters.redirectTo);
if (!redirectUri || !connectorId) {
setToast(t('error.invalid_connector_request'));
return;
}
const nativeCallbackLink = getSearchParameters(search, SearchParameters.nativeCallbackLink);
if (nativeCallbackLink) {
storeCallbackLink(connectorId, nativeCallbackLink);
}
window.location.replace(redirectUri);
}, [connectorId, search, setToast, t]);
};
export default useSocialLandingHandler;

View file

@ -1,11 +1,18 @@
import { useCallback, useContext } from 'react';
import { invokeSocialSignIn } from '@/apis/social';
import { ConnectorData } from '@/types';
import useApi from './use-api';
import { PageContext } from './use-page-context';
import useTerms from './use-terms';
import { getLogtoNativeSdk, isNativeWebview, generateState, storeState } from './utils';
import {
getLogtoNativeSdk,
isNativeWebview,
generateState,
storeState,
buildSocialLandingUri,
} from './utils';
const useSocial = () => {
const { experienceSettings } = useContext(PageContext);
@ -13,21 +20,35 @@ const useSocial = () => {
const { run: asyncInvokeSocialSignIn } = useApi(invokeSocialSignIn);
const nativeSignInHandler = useCallback((redirectTo: string, connector: ConnectorData) => {
const { id: connectorId, platform } = connector;
const redirectUri =
platform === 'Universal'
? buildSocialLandingUri(`/social-landing/${connectorId}`, redirectTo).toString()
: redirectTo;
getLogtoNativeSdk()?.getPostMessage()({
callbackUri: `${window.location.origin}/sign-in/callback/${connectorId}`,
redirectTo: redirectUri,
});
}, []);
const invokeSocialSignInHandler = useCallback(
async (connectorId: string, target: string, callback?: () => void) => {
async (connector: ConnectorData, callback?: () => void) => {
if (!(await termsValidation())) {
return;
}
const { id: connectorId } = connector;
const state = generateState();
storeState(state, connectorId);
const { origin } = window.location;
const result = await asyncInvokeSocialSignIn(
connectorId,
state,
`${origin}/callback/${connectorId}`
`${window.location.origin}/callback/${connectorId}`
);
if (!result?.redirectTo) {
@ -39,10 +60,7 @@ const useSocial = () => {
// Invoke Native Social Sign In flow
if (isNativeWebview()) {
getLogtoNativeSdk()?.getPostMessage()({
callbackUri: `${origin}/sign-in/callback/${connectorId}`,
redirectTo: result.redirectTo,
});
nativeSignInHandler(result.redirectTo, connector);
return;
}
@ -50,7 +68,7 @@ const useSocial = () => {
// Invoke Web Social Sign In flow
window.location.assign(result.redirectTo);
},
[asyncInvokeSocialSignIn, termsValidation]
[asyncInvokeSocialSignIn, nativeSignInHandler, termsValidation]
);
return {

View file

@ -1,6 +1,10 @@
import { ConnectorData } from '@/types';
import { ConnectorData, SearchParameters } from '@/types';
import { filterSocialConnectors, filterPreviewSocialConnectors } from './utils';
import {
filterSocialConnectors,
filterPreviewSocialConnectors,
buildSocialLandingUri,
} from './utils';
const mockConnectors = [
{ platform: 'Web', target: 'facebook' },
@ -90,3 +94,25 @@ describe('filterPreviewSocialConnectors', () => {
]);
});
});
describe('buildSocialLandingUri', () => {
it('buildSocialLandingUri', () => {
/* eslint-disable @silverhand/fp/no-mutation */
// @ts-expect-error mock global object
globalThis.logtoNativeSdk = {
platform: 'ios',
callbackLink: 'logto://callback',
};
/* eslint-enable @silverhand/fp/no-mutation */
const redirectUri = 'https://www.example.com/callback';
const socialLandingPath = '/social-landing';
const callbackUri = buildSocialLandingUri(socialLandingPath, redirectUri);
expect(callbackUri.pathname).toEqual(socialLandingPath);
expect(callbackUri.searchParams.get(SearchParameters.redirectTo)).toEqual(redirectUri);
expect(callbackUri.searchParams.get(SearchParameters.nativeCallbackLink)).toEqual(
'logto://callback'
);
});
});

View file

@ -1,4 +1,4 @@
import { ConnectorData, Platform } from '@/types';
import { ConnectorData, Platform, SearchParameters } from '@/types';
import { generateRandomString } from '@/utils';
/**
@ -18,44 +18,51 @@ export const isNativeWebview = () => {
/**
* Social Connector State Utility Methods
* @param state
* @param state.uuid - unique id
* @param state.platform - platform
* @param state.callbackLink - callback uri scheme
*/
type State = {
uuid: string;
platform: 'web' | 'ios' | 'android';
callbackLink?: string;
};
const storageKeyPrefix = 'social_auth_state';
const storageStateKeyPrefix = 'social_auth_state';
export const generateState = () => {
const uuid = generateRandomString();
const platform = getLogtoNativeSdk()?.platform ?? 'web';
const callbackLink = getLogtoNativeSdk()?.callbackLink;
const state: State = { uuid, platform, callbackLink };
return btoa(JSON.stringify(state));
return uuid;
};
export const decodeState = (state: string) => {
try {
return JSON.parse(atob(state)) as State;
} catch {}
export const storeState = (state: string, connectorId: string) => {
sessionStorage.setItem(`${storageStateKeyPrefix}:${connectorId}`, state);
};
export const stateValidation = (state: string, connectorId: string) => {
const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`);
const stateStorage = sessionStorage.getItem(`${storageStateKeyPrefix}:${connectorId}`);
return stateStorage === state;
};
export const storeState = (state: string, connectorId: string) => {
sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state);
/**
* Native Social Redirect Utility Methods
*/
export const storageCallbackLinkKeyPrefix = 'social_callback_data';
export const buildSocialLandingUri = (path: string, redirectTo: string) => {
const { origin } = window.location;
const url = new URL(`${origin}${path}`);
url.searchParams.set(SearchParameters.redirectTo, redirectTo);
const callbackLink = getLogtoNativeSdk()?.callbackLink;
if (callbackLink) {
url.searchParams.set(SearchParameters.nativeCallbackLink, callbackLink);
}
return url;
};
export const storeCallbackLink = (connectorId: string, callbackLink: string) => {
sessionStorage.setItem(`${storageCallbackLinkKeyPrefix}:${connectorId}`, callbackLink);
};
export const getCallbackLinkFromStorage = (connectorId: string) => {
return sessionStorage.getItem(`${storageCallbackLinkKeyPrefix}:${connectorId}`);
};
/**

View file

@ -6,34 +6,12 @@
@include _.flex-column;
}
.connector > img {
width: 96px;
height: 96px;
@include _.image-align-center;
}
.loadingLabel {
margin-bottom: _.unit(6);
}
.container {
.connectorContainer {
flex: 1;
@include _.flex-column;
}
.button {
@include _.full-width;
margin-bottom: _.unit(4);
}
:global(body.mobile) {
.connector {
margin-bottom: _.unit(6);
}
}
:global(body.desktop) {
.connector {
margin-bottom: _.unit(1);
}
}

View file

@ -1,50 +1,48 @@
import React, { useEffect, useContext, useMemo } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import Button from '@/components/Button';
import { PageContext } from '@/hooks/use-page-context';
import SocialLanding from '@/containers/SocialLanding';
import useSocialCallbackHandler from '@/hooks/use-social-callback-handler';
import * as styles from './index.module.scss';
type Props = {
connector?: string;
connector: string;
};
const Callback = () => {
const { connector: connectorId } = useParams<Props>();
const { experienceSettings } = useContext(PageContext);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const socialCallbackHandler = useSocialCallbackHandler();
const connectorLabel = useMemo(() => {
const connector = experienceSettings?.socialConnectors.find(({ id }) => id === connectorId);
if (connector) {
return (
<div className={styles.connector}>
<img src={connector.logo} />
</div>
);
}
return <div className={styles.connector}>{connectorId}</div>;
}, [connectorId, experienceSettings?.socialConnectors]);
// SocialSignIn Callback Handler
useEffect(() => {
socialCallbackHandler();
}, [socialCallbackHandler]);
if (!connectorId) {
return;
}
socialCallbackHandler(connectorId);
}, [socialCallbackHandler, connectorId]);
if (!connectorId) {
return null;
}
return (
<div className={styles.wrapper}>
<div className={styles.container}>
{connectorLabel}
<div className={styles.loadingLabel}>loading...</div>
</div>
<Button className={styles.button} onClick={socialCallbackHandler}>
<SocialLanding
className={styles.connectorContainer}
connectorId={connectorId}
message={t('description.redirecting')}
/>
<Button
className={styles.button}
onClick={() => {
socialCallbackHandler(connectorId);
}}
>
{t('action.continue')}
</Button>
</div>

View file

@ -0,0 +1,12 @@
@use '@/scss/underscore' as _;
.wrapper {
@include _.full-page;
@include _.flex-column;
}
.connectorContainer {
flex: 1;
}

View file

@ -0,0 +1,49 @@
import { waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { getCallbackLinkFromStorage } from '@/hooks/utils';
import { SearchParameters } from '@/types';
import { queryStringify } from '@/utils';
import SocialLanding from '.';
describe(`SocialLanding Page`, () => {
const replace = jest.fn();
it('Should set session storage and redirect', async () => {
const callbackLink = 'logto:logto.android.com';
const redirectUri = 'www.github.com';
/* eslint-disable @silverhand/fp/no-mutating-methods */
Object.defineProperty(window, 'location', {
value: {
origin,
href: `/social-landing?`,
search: queryStringify({
[SearchParameters.redirectTo]: redirectUri,
[SearchParameters.nativeCallbackLink]: callbackLink,
}),
replace,
},
});
/* eslint-enable @silverhand/fp/no-mutating-methods */
renderWithPageContext(
<SettingsProvider>
<MemoryRouter initialEntries={['/social-landing/github']}>
<Routes>
<Route path="/social-landing/:connector" element={<SocialLanding />} />
</Routes>
</MemoryRouter>
</SettingsProvider>
);
await waitFor(() => {
expect(replace).toBeCalledWith(redirectUri);
});
expect(getCallbackLinkFromStorage('github')).toBe(callbackLink);
});
});

View file

@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import SocialLandingContainer from '@/containers/SocialLanding';
import useSocialLandingHandler from '@/hooks/use-social-landing-handler';
import * as styles from './index.module.scss';
type Parameters = {
connector: string;
};
const SocialLanding = () => {
const { connector: connectorId } = useParams<Parameters>();
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
useSocialLandingHandler(connectorId);
if (!connectorId) {
return null;
}
return (
<div className={styles.wrapper}>
<SocialLandingContainer
className={styles.connectorContainer}
connectorId={connectorId}
message={t('description.redirecting')}
/>
</div>
);
};
export default SocialLanding;

View file

@ -11,7 +11,9 @@ export type SignInMethod = 'username' | 'email' | 'sms' | 'social';
export type LocalSignInMethod = 'username' | 'email' | 'sms';
export enum SearchParameters {
bindWithSocial = 'bw',
bindWithSocial = 'bind_with',
nativeCallbackLink = 'native_callback',
redirectTo = 'redirect_to',
}
export type Platform = 'web' | 'mobile';

View file

@ -11,6 +11,12 @@ describe('util methods', () => {
expect(parameters).toEqual({ foo: 'test', bar: 'test2' });
});
it('parseQueryParameters with encoded url', () => {
const url = 'http://logto.io';
const parameters = parseQueryParameters(`?callback=${encodeURIComponent(url)}`);
expect(parameters).toEqual({ callback: url });
});
it('queryStringify', () => {
expect(queryStringify(new URLSearchParams({ foo: 'test' }))).toEqual('foo=test');
});