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:
parent
e75c9de488
commit
d0d507ab79
21 changed files with 343 additions and 131 deletions
|
@ -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.',
|
||||
|
|
|
@ -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: '未找到有效的会话,请重新登录。',
|
||||
},
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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"
|
||||
|
|
22
packages/ui/src/containers/SocialLanding/index.module.scss
Normal file
22
packages/ui/src/containers/SocialLanding/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
28
packages/ui/src/containers/SocialLanding/index.tsx
Normal file
28
packages/ui/src/containers/SocialLanding/index.tsx
Normal 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;
|
|
@ -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} />
|
||||
|
|
|
@ -34,7 +34,7 @@ const SocialSignInIconList = ({
|
|||
className={styles.socialButton}
|
||||
connector={connector}
|
||||
onClick={() => {
|
||||
void invokeSocialSignIn(connector.id, connector.target);
|
||||
void invokeSocialSignIn(connector);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -42,7 +42,7 @@ const SocialSignInList = ({
|
|||
className={styles.socialLinkButton}
|
||||
connector={connector}
|
||||
onClick={() => {
|
||||
void invokeSocialSignIn(connector.id, connector.target, onSocialSignInCallback);
|
||||
void invokeSocialSignIn(connector, onSocialSignInCallback);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
34
packages/ui/src/hooks/use-social-landing-handler.ts
Normal file
34
packages/ui/src/hooks/use-social-landing-handler.ts
Normal 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;
|
|
@ -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 {
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
12
packages/ui/src/pages/SocialLanding/index.module.scss
Normal file
12
packages/ui/src/pages/SocialLanding/index.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
|
||||
.wrapper {
|
||||
@include _.full-page;
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
|
||||
.connectorContainer {
|
||||
flex: 1;
|
||||
}
|
49
packages/ui/src/pages/SocialLanding/index.test.tsx
Normal file
49
packages/ui/src/pages/SocialLanding/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
35
packages/ui/src/pages/SocialLanding/index.tsx
Normal file
35
packages/ui/src/pages/SocialLanding/index.tsx
Normal 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;
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue