0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(ui): refactor social login hooks (#570)

* feat(ui): adjust toast style

adjust toast style

* refactor(ui): refactor social hooks

refactor social hooks

* fix(ui): fix ci issue
 fix ci issue

* fix(ui): cr fix

cr fix

* fix(ui): fix social sign-in flow

fix social sign-in flow
This commit is contained in:
simeng-li 2022-04-19 16:32:33 +08:00 committed by GitHub
parent dc2a6ac961
commit 65059565e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 389 additions and 104 deletions

View file

@ -50,6 +50,7 @@ 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={<SignIn />} />
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/register" element={<Register />} />
<Route path="/register/:method" element={<Register />} />

View file

@ -0,0 +1,19 @@
import React, { useState, useMemo } from 'react';
import PageContext from '@/hooks/page-context';
import { SignInExperienceSettings } from '@/types';
const ContextProvider = ({ children }: { children: React.ReactNode }) => {
const [loading, setLoading] = useState(false);
const [toast, setToast] = useState('');
const [experienceSettings, setExperienceSettings] = useState<SignInExperienceSettings>();
const context = useMemo(
() => ({ loading, setLoading, toast, setToast, experienceSettings, setExperienceSettings }),
[experienceSettings, loading, toast]
);
return <PageContext.Provider value={context}>{children}</PageContext.Provider>;
};
export default ContextProvider;

View file

@ -17,7 +17,7 @@ import {
} from './sign-in';
import {
invokeSocialSignIn,
signInWithSoical,
signInWithSocial,
bindSocialAccount,
registerWithSocial,
} from './social';
@ -153,14 +153,14 @@ describe('api', () => {
});
});
it('signInWithSoical', async () => {
it('signInWithSocial', async () => {
const parameters = {
connectorId: 'connectorId',
state: 'state',
redirectUri: 'redirectUri',
code: 'code',
};
await signInWithSoical(parameters);
await signInWithSocial(parameters);
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
json: parameters,
});

View file

@ -20,7 +20,7 @@ export const invokeSocialSignIn = async (
.json<Response>();
};
export const signInWithSoical = async (parameters: {
export const signInWithSocial = async (parameters: {
connectorId: string;
state: string;
redirectUri: string;

View file

@ -11,16 +11,17 @@
pointer-events: none;
.toast {
max-width: 360px;
padding: _.unit(2) _.unit(4);
font: var(--font-body-medium);
color: var(--color-font-toast-text);
border-radius: _.unit(2);
max-width: none;
background: var(--color-dark-background);
min-width: _.unit(25);
text-align: center;
opacity: 0%;
transition: opacity 0.3s ease-in-out;
word-break: break-word;
&[data-visible='true'] {
opacity: 100%;

View file

@ -4,7 +4,7 @@ import React, { useState, useMemo } from 'react';
import SocialLinkButton from '@/components/Button/SocialLinkButton';
import { ExpandMoreIcon } from '@/components/Icons';
import useSocial from '@/hooks/use-social-connector';
import useSocial from '@/hooks/use-social';
import * as styles from './index.module.scss';
@ -42,7 +42,6 @@ const PrimarySocialSignIn = ({ className, connectors, isPopup = false }: Props)
))}
{!displayAll && (
<ExpandMoreIcon
className={styles.expandButton}
onClick={() => {
setShowAll(true);
}}

View file

@ -1,12 +1,40 @@
import { render } from '@testing-library/react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import ContextProvider from '@/__mocks__/ContextProvider';
import { socialConnectors } from '@/__mocks__/logto';
import * as socialSignInApi from '@/apis/social';
import { generateState, storeState } from '@/hooks/use-social';
import SecondarySocialSignIn from './SecondarySocialSignIn';
describe('SecondarySocialSignIn', () => {
const mockOrigin = 'https://logto.dev';
const invokeSocialSignInSpy = jest
.spyOn(socialSignInApi, 'invokeSocialSignIn')
.mockResolvedValue({ redirectTo: `${mockOrigin}/callback` });
const signInWithSocialSpy = jest
.spyOn(socialSignInApi, 'signInWithSocial')
.mockResolvedValue({ redirectTo: `${mockOrigin}/callback` });
beforeEach(() => {
/* eslint-disable @silverhand/fp/no-mutation */
// @ts-expect-error mock global object
globalThis.logtoNativeSdk = {
platform: 'web',
getPostMessage: jest.fn(() => jest.fn()),
callbackUriScheme: '/logto:',
};
/* eslint-enable @silverhand/fp/no-mutation */
});
afterEach(() => {
jest.clearAllMocks();
});
it('less than four connectors', () => {
const { container } = render(
<MemoryRouter>
@ -24,4 +52,86 @@ describe('SecondarySocialSignIn', () => {
);
expect(container.querySelectorAll('button')).toHaveLength(4);
});
it('invoke web social signIn', async () => {
const connectors = socialConnectors.slice(0, 1);
const { container } = render(
<ContextProvider>
<MemoryRouter>
<SecondarySocialSignIn connectors={connectors} />
</MemoryRouter>
</ContextProvider>
);
const socialButton = container.querySelector('button');
if (socialButton) {
await waitFor(() => {
fireEvent.click(socialButton);
});
expect(invokeSocialSignInSpy).toBeCalled();
}
});
it('invoke native social signIn', async () => {
/* eslint-disable @silverhand/fp/no-mutation */
// @ts-expect-error mock global object
logtoNativeSdk.platform = 'ios';
/* eslint-enable @silverhand/fp/no-mutation */
const connectors = socialConnectors.slice(0, 1);
const { container } = render(
<ContextProvider>
<MemoryRouter>
<SecondarySocialSignIn connectors={connectors} />
</MemoryRouter>
</ContextProvider>
);
const socialButton = container.querySelector('button');
if (socialButton) {
await waitFor(() => {
fireEvent.click(socialButton);
});
expect(invokeSocialSignInSpy).toBeCalled();
expect(logtoNativeSdk?.getPostMessage).toBeCalled();
}
});
it('callback validation and signIn with social', async () => {
const connectors = socialConnectors.slice(0, 1);
const state = generateState();
storeState(state, 'github');
/* eslint-disable @silverhand/fp/no-mutating-methods */
Object.defineProperty(window, 'location', {
value: {
href: `/sign-in/callback?state=${state}&code=foo`,
search: `?state=${state}&code=foo`,
pathname: '/sign-in/callback',
assign: jest.fn(),
},
});
/* eslint-enable @silverhand/fp/no-mutating-methods */
render(
<ContextProvider>
<MemoryRouter initialEntries={['/sign-in/callback/github']}>
<Routes>
<Route
path="/sign-in/callback/:connector"
element={<SecondarySocialSignIn connectors={connectors} />}
/>
</Routes>
</MemoryRouter>
</ContextProvider>
);
await waitFor(() => {
expect(signInWithSocialSpy).toBeCalled();
});
});
});

View file

@ -4,7 +4,7 @@ import React, { useMemo } from 'react';
import MoreButton from '@/components/Button/MoreButton';
import SocialIconButton from '@/components/Button/SocialIconButton';
import useSocial from '@/hooks/use-social-connector';
import useSocial from '@/hooks/use-social';
import * as styles from './index.module.scss';

View file

@ -1,87 +0,0 @@
import { useEffect, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { invokeSocialSignIn, signInWithSoical } from '@/apis/social';
import { generateRandomString } from '@/utils';
import useApi from './use-api';
const storageKeyPrefix = 'social_auth_state';
const webPlatformPrefix = 'web';
const mobilePlatformPrefix = 'mobile';
const isMobileWebview = () => {
// TODO: read from native sdk embedded params
return true;
};
const useSocial = () => {
const { result: invokeSocialSignInResult, run: asyncSignInWithSocial } =
useApi(invokeSocialSignIn);
const { result: signInToSocialResult, run: asyncSignInWithSoical } = useApi(signInWithSoical);
const { search } = useLocation();
const validateState = useCallback((state: string, connectorId: string) => {
if (state.startsWith(mobilePlatformPrefix)) {
return true; // Not able to validate the state source from the native call stack
}
const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`);
return stateStorage === state;
}, []);
const signInWithSocialHandler = useCallback(
(connector?: string) => {
const uriParameters = new URLSearchParams(search);
const state = uriParameters.get('state');
const code = uriParameters.get('code');
if (!state || !code || !connector) {
// TODO: error message
return;
}
if (!validateState(state, connector)) {
// TODO: error message
return;
}
void asyncSignInWithSoical({ connectorId: connector, state, code, redirectUri: 'TODO' });
},
[asyncSignInWithSoical, search, validateState]
);
const invokeSocialSignInHandler = useCallback(
async (connectorId: string) => {
const state = `${
isMobileWebview() ? mobilePlatformPrefix : webPlatformPrefix
}_${generateRandomString()}`;
const { origin } = window.location;
sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state);
return asyncSignInWithSocial(connectorId, state, `${origin}/callback/${connectorId}`);
},
[asyncSignInWithSocial]
);
useEffect(() => {
if (invokeSocialSignInResult?.redirectTo) {
window.location.assign(invokeSocialSignInResult.redirectTo);
}
}, [invokeSocialSignInResult]);
useEffect(() => {
if (signInToSocialResult?.redirectTo) {
window.location.assign(signInToSocialResult.redirectTo);
}
}, [signInToSocialResult]);
return {
invokeSocialSignIn: invokeSocialSignInHandler,
signInWithSocial: signInWithSocialHandler,
};
};
export default useSocial;

View file

@ -0,0 +1,205 @@
import { useEffect, useCallback, useContext } from 'react';
import { useParams } from 'react-router-dom';
import { invokeSocialSignIn, signInWithSocial } from '@/apis/social';
import { generateRandomString, parseQueryParameters } from '@/utils';
import PageContext from './page-context';
import useApi from './use-api';
/**
* Social Connector State Utility Methods
* @param state
* @param state.uuid - unique id
* @param state.platform - platform
* @param state.callbackUriScheme - callback uri scheme
*/
type State = {
uuid: string;
platform: 'web' | 'ios' | 'android';
callbackUriScheme?: string;
};
const storageKeyPrefix = 'social_auth_state';
const getLogtoNativeSdk = () => {
if (typeof logtoNativeSdk !== 'undefined') {
return logtoNativeSdk;
}
};
export const generateState = () => {
const uuid = generateRandomString();
const platform = getLogtoNativeSdk()?.platform ?? 'web';
const callbackUriScheme = getLogtoNativeSdk()?.callbackUriScheme;
const state: State = { uuid, platform, callbackUriScheme };
return btoa(JSON.stringify(state));
};
export const decodeState = (state: string) => {
try {
return JSON.parse(atob(state)) as State;
} catch {}
};
export const stateValidation = (state: string, connectorId: string) => {
const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`);
return stateStorage === state;
};
export const storeState = (state: string, connectorId: string) => {
sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state);
};
/* ============================================================================ */
const isNativeWebview = () => {
const platform = getLogtoNativeSdk()?.platform ?? '';
return ['ios', 'android'].includes(platform);
};
const useSocial = () => {
const { setToast } = useContext(PageContext);
const parameters = useParams();
const { result: invokeSocialSignInResult, run: asyncInvokeSocialSignIn } =
useApi(invokeSocialSignIn);
const { result: signInWithSocialResult, run: asyncSignInWithSocial } = useApi(signInWithSocial);
const invokeSocialSignInHandler = useCallback(
async (connectorId: string) => {
const state = generateState();
storeState(state, connectorId);
const { origin } = window.location;
return asyncInvokeSocialSignIn(connectorId, state, `${origin}/callback/${connectorId}`);
},
[asyncInvokeSocialSignIn]
);
const signInWithSocialHandler = useCallback(
(connectorId: string, state: string, code: string) => {
if (!stateValidation(state, connectorId)) {
// TODO: Invalid state error message
return;
}
void asyncSignInWithSocial({ connectorId, state, code, redirectUri: '' });
},
[asyncSignInWithSocial]
);
const socialCallbackHandler = useCallback(
(connectorId?: string) => {
const { state, code, error, error_description } = parseQueryParameters(
window.location.search
);
if (error) {
setToast(`${error}${error_description ? `: ${error_description}` : ''}`);
}
if (!state || !code || !connectorId) {
// TODO: error message
return;
}
const decodedState = decodeState(state);
if (!decodedState) {
// TODO: invalid state error message
return;
}
const { platform, callbackUriScheme } = decodedState;
if (platform === 'web') {
window.location.assign(
new URL(`${location.origin}/sign-in/callback/${connectorId}/${window.location.search}`)
);
return;
}
if (!callbackUriScheme) {
// TODO: native callbackUriScheme not found error message
return;
}
window.location.assign(new URL(`${callbackUriScheme}${window.location.search}`));
},
[setToast]
);
// InvokeSocialSignIn Callback
useEffect(() => {
const { redirectTo } = invokeSocialSignInResult ?? {};
if (!redirectTo) {
return;
}
// Invoke Native Social Sign In flow
if (isNativeWebview()) {
getLogtoNativeSdk()?.getPostMessage()({
callbackUri: redirectTo.replace('/callback', '/sign-in/callback'),
redirectTo,
});
return;
}
// Invoke Web Social Sign In flow
window.location.assign(redirectTo);
}, [invokeSocialSignInResult]);
// SignInWithSocial Callback
useEffect(() => {
if (signInWithSocialResult?.redirectTo) {
window.location.assign(signInWithSocialResult.redirectTo);
}
}, [signInWithSocialResult]);
// SignIn Callback Page Handler
useEffect(() => {
if (!location.pathname.includes('/sign-in/callback') || !parameters.connector) {
return;
}
const { state, code } = parseQueryParameters(window.location.search);
if (!state || !code) {
return;
}
signInWithSocialHandler(parameters.connector, state, code);
}, [parameters.connector, signInWithSocialHandler]);
// Monitor Native Error Message
useEffect(() => {
const nativeMessageHandler = (event: MessageEvent) => {
if (event.origin === window.location.origin) {
setToast(JSON.stringify(event.data));
}
};
window.addEventListener('message', nativeMessageHandler);
return () => {
window.removeEventListener('message', nativeMessageHandler);
};
}, [setToast]);
return {
invokeSocialSignIn: invokeSocialSignInHandler,
socialCallbackHandler,
};
};
export default useSocial;

10
packages/ui/src/include.d/global.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
// Logto Native SDK
type LogtoNativeSdkInfo = {
platform: 'ios' | 'android';
callbackUriScheme: string;
getPostMessage: () => (data: { callbackUri?: string; redirectTo?: string }) => void;
supportedSocialConnectors: string[];
};
declare const logtoNativeSdk: LogtoNativeSdkInfo | undefined;

View file

@ -0,0 +1,10 @@
.container {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}

View file

@ -1,7 +1,9 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useSocial from '@/hooks/use-social-connector';
import useSocial from '@/hooks/use-social';
import * as styles from './index.module.scss';
type Props = {
connector?: string;
@ -9,13 +11,16 @@ type Props = {
const Callback = () => {
const { connector } = useParams<Props>();
const { signInWithSocial } = useSocial();
const { socialCallbackHandler } = useSocial();
// SocialSignIn Callback Handler
useEffect(() => {
signInWithSocial(connector);
}, [signInWithSocial, connector]);
if (connector) {
socialCallbackHandler(connector);
}
}, [connector, socialCallbackHandler]);
return <div>{connector} loading...</div>;
return <div className={styles.container}>{connector} loading...</div>;
};
export default Callback;

View file

@ -1,8 +1,13 @@
import { generateRandomString } from '.';
import { generateRandomString, parseQueryParameters } from '.';
describe('util methods', () => {
it('generateRandomString', () => {
const random = generateRandomString();
expect(random).not.toBeNull();
});
it('parseQueryParameters', () => {
const parameters = parseQueryParameters('?foo=test&bar=test2');
expect(parameters).toEqual({ foo: 'test', bar: 'test2' });
});
});

View file

@ -1,4 +1,11 @@
import { fromUint8Array } from 'js-base64';
export const generateRandomString = (length = 16) =>
export const generateRandomString = (length = 8) =>
fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true);
export const parseQueryParameters = (parameters: string | URLSearchParams) => {
const searchParameters =
parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters);
return Object.fromEntries(searchParameters.entries());
};