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:
parent
dc2a6ac961
commit
65059565e6
15 changed files with 389 additions and 104 deletions
|
@ -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 />} />
|
||||
|
|
19
packages/ui/src/__mocks__/ContextProvider.tsx
Normal file
19
packages/ui/src/__mocks__/ContextProvider.tsx
Normal 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;
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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;
|
205
packages/ui/src/hooks/use-social.ts
Normal file
205
packages/ui/src/hooks/use-social.ts
Normal 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
10
packages/ui/src/include.d/global.d.ts
vendored
Normal 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;
|
10
packages/ui/src/pages/Callback/index.module.scss
Normal file
10
packages/ui/src/pages/Callback/index.module.scss
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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());
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue