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.',
|
username_valid_charset: 'Username should only contain letters, numbers, or underscore.',
|
||||||
invalid_email: 'The email is invalid',
|
invalid_email: 'The email is invalid',
|
||||||
invalid_phone: 'The phone number 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.',
|
password_min_length: 'Password requires a minimum of {{min}} characters.',
|
||||||
passwords_do_not_match: 'Passwords do not match.',
|
passwords_do_not_match: 'Passwords do not match.',
|
||||||
agree_terms_required: 'You must agree to the Terms of Use before continuing.',
|
agree_terms_required: 'You must agree to the Terms of Use before continuing.',
|
||||||
invalid_passcode: 'The passcode is invalid.',
|
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}}',
|
request: 'Request error {{message}}',
|
||||||
unknown: 'Unknown error, please try again later.',
|
unknown: 'Unknown error, please try again later.',
|
||||||
invalid_session: 'Session not found. Please go back and sign in again.',
|
invalid_session: 'Session not found. Please go back and sign in again.',
|
||||||
|
|
|
@ -81,13 +81,13 @@ const translation = {
|
||||||
username_valid_charset: '用户名只能包含英文字母、数字或下划线。',
|
username_valid_charset: '用户名只能包含英文字母、数字或下划线。',
|
||||||
invalid_email: '无效的邮箱。',
|
invalid_email: '无效的邮箱。',
|
||||||
invalid_phone: '无效的手机号。',
|
invalid_phone: '无效的手机号。',
|
||||||
invalid_connector_auth: '登录失败',
|
|
||||||
missing_auth_data: '未找到有效的登录信息',
|
|
||||||
password_min_length: '密码最少需要{{min}}个字符。',
|
password_min_length: '密码最少需要{{min}}个字符。',
|
||||||
passwords_do_not_match: '密码不匹配。',
|
passwords_do_not_match: '密码不匹配。',
|
||||||
agree_terms_required: '你需要同意使用条款以继续。',
|
agree_terms_required: '你需要同意使用条款以继续。',
|
||||||
invalid_passcode: '无效的验证码。',
|
invalid_passcode: '无效的验证码。',
|
||||||
request: '请求错误:{{ message }}',
|
invalid_connector_auth: '登录失败。',
|
||||||
|
invalid_connector_request: '无效的登录请求。',
|
||||||
|
request: '请求错误:{{ message }}。',
|
||||||
unknown: '未知错误,请稍后重试。',
|
unknown: '未知错误,请稍后重试。',
|
||||||
invalid_session: '未找到有效的会话,请重新登录。',
|
invalid_session: '未找到有效的会话,请重新登录。',
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"lint": "eslint --ext .ts --ext .tsx src",
|
"lint": "eslint --ext .ts --ext .tsx src",
|
||||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||||
"stylelint": "stylelint \"src/**/*.scss\"",
|
"stylelint": "stylelint \"src/**/*.scss\"",
|
||||||
|
"test:coverage": "jest --coverage --silent",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import Passcode from './pages/Passcode';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import SecondarySignIn from './pages/SecondarySignIn';
|
import SecondarySignIn from './pages/SecondarySignIn';
|
||||||
import SignIn from './pages/SignIn';
|
import SignIn from './pages/SignIn';
|
||||||
|
import SocialLanding from './pages/SocialLanding';
|
||||||
import SocialRegister from './pages/SocialRegister';
|
import SocialRegister from './pages/SocialRegister';
|
||||||
import SocialSignInCallback from './pages/SocialSignInCallback';
|
import SocialSignInCallback from './pages/SocialSignInCallback';
|
||||||
import getSignInExperienceSettings from './utils/sign-in-experience';
|
import getSignInExperienceSettings from './utils/sign-in-experience';
|
||||||
|
@ -55,12 +56,16 @@ const App = () => {
|
||||||
<Route path="/" element={<Navigate replace to="/sign-in" />} />
|
<Route path="/" element={<Navigate replace to="/sign-in" />} />
|
||||||
<Route path="/sign-in" element={<SignIn />} />
|
<Route path="/sign-in" element={<SignIn />} />
|
||||||
<Route path="/sign-in/consent" element={<Consent />} />
|
<Route path="/sign-in/consent" element={<Consent />} />
|
||||||
<Route path="/sign-in/callback/:connector" element={<SocialSignInCallback />} />
|
|
||||||
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/register/:method" 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="/callback/:connector" element={<Callback />} />
|
||||||
<Route path="/social-register/:connector" element={<SocialRegister />} />
|
<Route path="/social-register/:connector" element={<SocialRegister />} />
|
||||||
|
<Route path="/social-landing/:connector" element={<SocialLanding />} />
|
||||||
|
|
||||||
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
<Route path="/:type/:method/passcode-validation" element={<Passcode />} />
|
||||||
<Route
|
<Route
|
||||||
path="/unknown-session"
|
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);
|
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 languageKey = Object.keys(name).find((key) => key === language) ?? 'en';
|
||||||
const localName = name[languageKey as Language];
|
const localName = name[languageKey as Language];
|
||||||
|
|
||||||
|
@ -53,7 +54,7 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props)
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
key={id}
|
key={id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void invokeSocialSignIn(id, target, onClose);
|
void invokeSocialSignIn(connector, onClose);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={logo} alt={id} className={styles.socialLogo} />
|
<img src={logo} alt={id} className={styles.socialLogo} />
|
||||||
|
|
|
@ -34,7 +34,7 @@ const SocialSignInIconList = ({
|
||||||
className={styles.socialButton}
|
className={styles.socialButton}
|
||||||
connector={connector}
|
connector={connector}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void invokeSocialSignIn(connector.id, connector.target);
|
void invokeSocialSignIn(connector);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -42,7 +42,7 @@ const SocialSignInList = ({
|
||||||
className={styles.socialLinkButton}
|
className={styles.socialLinkButton}
|
||||||
connector={connector}
|
connector={connector}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void invokeSocialSignIn(connector.id, connector.target, onSocialSignInCallback);
|
void invokeSocialSignIn(connector, onSocialSignInCallback);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,48 +1,46 @@
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { parseQueryParameters } from '@/utils';
|
import { parseQueryParameters } from '@/utils';
|
||||||
|
|
||||||
import { PageContext } from './use-page-context';
|
import { PageContext } from './use-page-context';
|
||||||
import { decodeState } from './utils';
|
import { getCallbackLinkFromStorage } from './utils';
|
||||||
|
|
||||||
const useSocialCallbackHandler = () => {
|
const useSocialCallbackHandler = () => {
|
||||||
const { setToast } = useContext(PageContext);
|
const { setToast } = useContext(PageContext);
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||||
const parameters = useParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const socialCallbackHandler = useCallback(() => {
|
const socialCallbackHandler = useCallback(
|
||||||
|
(connectorId: string) => {
|
||||||
const data = window.location.search || '?' + window.location.hash.slice(1);
|
const data = window.location.search || '?' + window.location.hash.slice(1);
|
||||||
const { state, error, error_description } = parseQueryParameters(data);
|
const { state, error, error_description } = parseQueryParameters(data);
|
||||||
const connectorId = parameters.connector;
|
|
||||||
|
|
||||||
// Connector auth error
|
// Connector auth error
|
||||||
if (error) {
|
if (error) {
|
||||||
setToast(`${error}${error_description ? `: ${error_description}` : ''}`);
|
setToast(`${error}${error_description ? `: ${error_description}` : ''}`);
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connector auth missing state
|
// Connector auth missing state
|
||||||
if (!state || !connectorId) {
|
if (!state || !connectorId) {
|
||||||
setToast(t('error.missing_auth_data'));
|
setToast(t('error.invalid_connector_auth'));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedState = decodeState(state);
|
// Get native callback link from storage
|
||||||
|
const callbackLink = getCallbackLinkFromStorage(connectorId);
|
||||||
|
|
||||||
// Invalid state
|
if (callbackLink) {
|
||||||
if (!decodedState) {
|
window.location.replace(new URL(`${callbackLink}${window.location.search}`));
|
||||||
setToast(t('error.missing_auth_data'));
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { platform, callbackLink } = decodedState;
|
// Web flow
|
||||||
|
|
||||||
// Web/Mobile-Web redirect to sign-in/callback page to login
|
|
||||||
if (platform === 'web') {
|
|
||||||
navigate(
|
navigate(
|
||||||
{
|
{
|
||||||
pathname: `/sign-in/callback/${connectorId}`,
|
pathname: `/sign-in/callback/${connectorId}`,
|
||||||
|
@ -52,17 +50,9 @@ const useSocialCallbackHandler = () => {
|
||||||
replace: true,
|
replace: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
},
|
||||||
return;
|
[navigate, setToast, t]
|
||||||
}
|
);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
return socialCallbackHandler;
|
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 { useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import { invokeSocialSignIn } from '@/apis/social';
|
import { invokeSocialSignIn } from '@/apis/social';
|
||||||
|
import { ConnectorData } from '@/types';
|
||||||
|
|
||||||
import useApi from './use-api';
|
import useApi from './use-api';
|
||||||
import { PageContext } from './use-page-context';
|
import { PageContext } from './use-page-context';
|
||||||
import useTerms from './use-terms';
|
import useTerms from './use-terms';
|
||||||
import { getLogtoNativeSdk, isNativeWebview, generateState, storeState } from './utils';
|
import {
|
||||||
|
getLogtoNativeSdk,
|
||||||
|
isNativeWebview,
|
||||||
|
generateState,
|
||||||
|
storeState,
|
||||||
|
buildSocialLandingUri,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
const useSocial = () => {
|
const useSocial = () => {
|
||||||
const { experienceSettings } = useContext(PageContext);
|
const { experienceSettings } = useContext(PageContext);
|
||||||
|
@ -13,21 +20,35 @@ const useSocial = () => {
|
||||||
|
|
||||||
const { run: asyncInvokeSocialSignIn } = useApi(invokeSocialSignIn);
|
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(
|
const invokeSocialSignInHandler = useCallback(
|
||||||
async (connectorId: string, target: string, callback?: () => void) => {
|
async (connector: ConnectorData, callback?: () => void) => {
|
||||||
if (!(await termsValidation())) {
|
if (!(await termsValidation())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { id: connectorId } = connector;
|
||||||
|
|
||||||
const state = generateState();
|
const state = generateState();
|
||||||
storeState(state, connectorId);
|
storeState(state, connectorId);
|
||||||
|
|
||||||
const { origin } = window.location;
|
|
||||||
|
|
||||||
const result = await asyncInvokeSocialSignIn(
|
const result = await asyncInvokeSocialSignIn(
|
||||||
connectorId,
|
connectorId,
|
||||||
state,
|
state,
|
||||||
`${origin}/callback/${connectorId}`
|
`${window.location.origin}/callback/${connectorId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result?.redirectTo) {
|
if (!result?.redirectTo) {
|
||||||
|
@ -39,10 +60,7 @@ const useSocial = () => {
|
||||||
|
|
||||||
// Invoke Native Social Sign In flow
|
// Invoke Native Social Sign In flow
|
||||||
if (isNativeWebview()) {
|
if (isNativeWebview()) {
|
||||||
getLogtoNativeSdk()?.getPostMessage()({
|
nativeSignInHandler(result.redirectTo, connector);
|
||||||
callbackUri: `${origin}/sign-in/callback/${connectorId}`,
|
|
||||||
redirectTo: result.redirectTo,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -50,7 +68,7 @@ const useSocial = () => {
|
||||||
// Invoke Web Social Sign In flow
|
// Invoke Web Social Sign In flow
|
||||||
window.location.assign(result.redirectTo);
|
window.location.assign(result.redirectTo);
|
||||||
},
|
},
|
||||||
[asyncInvokeSocialSignIn, termsValidation]
|
[asyncInvokeSocialSignIn, nativeSignInHandler, termsValidation]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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 = [
|
const mockConnectors = [
|
||||||
{ platform: 'Web', target: 'facebook' },
|
{ 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';
|
import { generateRandomString } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -18,44 +18,51 @@ export const isNativeWebview = () => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Social Connector State Utility Methods
|
* Social Connector State Utility Methods
|
||||||
* @param state
|
|
||||||
* @param state.uuid - unique id
|
|
||||||
* @param state.platform - platform
|
|
||||||
* @param state.callbackLink - callback uri scheme
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type State = {
|
const storageStateKeyPrefix = 'social_auth_state';
|
||||||
uuid: string;
|
|
||||||
platform: 'web' | 'ios' | 'android';
|
|
||||||
callbackLink?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const storageKeyPrefix = 'social_auth_state';
|
|
||||||
|
|
||||||
export const generateState = () => {
|
export const generateState = () => {
|
||||||
const uuid = generateRandomString();
|
const uuid = generateRandomString();
|
||||||
const platform = getLogtoNativeSdk()?.platform ?? 'web';
|
|
||||||
const callbackLink = getLogtoNativeSdk()?.callbackLink;
|
|
||||||
|
|
||||||
const state: State = { uuid, platform, callbackLink };
|
return uuid;
|
||||||
|
|
||||||
return btoa(JSON.stringify(state));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decodeState = (state: string) => {
|
export const storeState = (state: string, connectorId: string) => {
|
||||||
try {
|
sessionStorage.setItem(`${storageStateKeyPrefix}:${connectorId}`, state);
|
||||||
return JSON.parse(atob(state)) as State;
|
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stateValidation = (state: string, connectorId: string) => {
|
export const stateValidation = (state: string, connectorId: string) => {
|
||||||
const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`);
|
const stateStorage = sessionStorage.getItem(`${storageStateKeyPrefix}:${connectorId}`);
|
||||||
|
|
||||||
return stateStorage === state;
|
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;
|
@include _.flex-column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connector > img {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
@include _.image-align-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingLabel {
|
.connectorContainer {
|
||||||
margin-bottom: _.unit(6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@include _.flex-column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@include _.full-width;
|
@include _.full-width;
|
||||||
margin-bottom: _.unit(4);
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import Button from '@/components/Button';
|
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 useSocialCallbackHandler from '@/hooks/use-social-callback-handler';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
connector?: string;
|
connector: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Callback = () => {
|
const Callback = () => {
|
||||||
const { connector: connectorId } = useParams<Props>();
|
const { connector: connectorId } = useParams<Props>();
|
||||||
const { experienceSettings } = useContext(PageContext);
|
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
|
||||||
|
|
||||||
const socialCallbackHandler = useSocialCallbackHandler();
|
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
|
// SocialSignIn Callback Handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socialCallbackHandler();
|
if (!connectorId) {
|
||||||
}, [socialCallbackHandler]);
|
return;
|
||||||
|
}
|
||||||
|
socialCallbackHandler(connectorId);
|
||||||
|
}, [socialCallbackHandler, connectorId]);
|
||||||
|
|
||||||
|
if (!connectorId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<div className={styles.container}>
|
<SocialLanding
|
||||||
{connectorLabel}
|
className={styles.connectorContainer}
|
||||||
<div className={styles.loadingLabel}>loading...</div>
|
connectorId={connectorId}
|
||||||
</div>
|
message={t('description.redirecting')}
|
||||||
<Button className={styles.button} onClick={socialCallbackHandler}>
|
/>
|
||||||
|
<Button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => {
|
||||||
|
socialCallbackHandler(connectorId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t('action.continue')}
|
{t('action.continue')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 type LocalSignInMethod = 'username' | 'email' | 'sms';
|
||||||
|
|
||||||
export enum SearchParameters {
|
export enum SearchParameters {
|
||||||
bindWithSocial = 'bw',
|
bindWithSocial = 'bind_with',
|
||||||
|
nativeCallbackLink = 'native_callback',
|
||||||
|
redirectTo = 'redirect_to',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Platform = 'web' | 'mobile';
|
export type Platform = 'web' | 'mobile';
|
||||||
|
|
|
@ -11,6 +11,12 @@ describe('util methods', () => {
|
||||||
expect(parameters).toEqual({ foo: 'test', bar: 'test2' });
|
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', () => {
|
it('queryStringify', () => {
|
||||||
expect(queryStringify(new URLSearchParams({ foo: 'test' }))).toEqual('foo=test');
|
expect(queryStringify(new URLSearchParams({ foo: 'test' }))).toEqual('foo=test');
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue