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

feat(ui): add social web login flow (#531)

* feat(ui): add social web login flow

add social web login flow

* fix(ui): cr fix

code review fix

* fix(ui): fix typo

fix typo

* refactor(ui): social api renaming

social api renaming
This commit is contained in:
simeng-li 2022-04-12 15:03:38 +08:00 committed by GitHub
parent 1f6f0a3796
commit 7dba17b867
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 391 additions and 23 deletions

View file

@ -22,6 +22,7 @@
"classnames": "^2.3.1",
"i18next": "^21.6.11",
"i18next-browser-languagedetector": "^6.1.3",
"js-base64": "^3.7.2",
"ky": "^0.29.0",
"libphonenumber-js": "^1.9.49",
"react": "^17.0.2",
@ -37,6 +38,7 @@
"@jest/types": "^27.5.1",
"@parcel/core": "^2.3.2",
"@parcel/transformer-sass": "^2.3.2",
"@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "^0.10.2",
"@silverhand/eslint-config-react": "^0.10.3",
"@silverhand/ts-config": "^0.10.2",

View file

@ -4,6 +4,7 @@ import { Route, Routes, BrowserRouter } from 'react-router-dom';
import AppContent from './components/AppContent';
import useTheme from './hooks/use-theme';
import initI18n from './i18n/init';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
import Passcode from './pages/Passcode';
import Register from './pages/Register';
@ -27,6 +28,7 @@ const App = () => {
<Route path="/register" element={<Register />} />
<Route path="/register/:channel" element={<Register />} />
<Route path="/:type/:channel/passcode-validation" element={<Passcode />} />
<Route path="/callback/:connector" element={<Callback />} />
</Routes>
</BrowserRouter>
</AppContent>

View file

@ -1,2 +1,29 @@
export const appLogo = 'https://avatars.githubusercontent.com/u/88327661?s=200&v=4';
export const appHeadline = 'Build user identity in a modern way';
export const socialConnectors = [
{
id: 'github',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: 'GitHub',
},
{
id: 'alipay',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: 'alipay',
},
{
id: 'wechat',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: 'wechat',
},
{
id: 'google',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: 'google',
},
{
id: 'facebook',
logo: 'https://user-images.githubusercontent.com/5717882/156983224-7ea0296b-38fa-419d-9515-67e8a9612e09.png',
name: 'Meta',
},
];

View file

@ -15,7 +15,12 @@ import {
verifyEmailPasscode,
verifyPhonePasscode,
} from './sign-in';
import { signInWithSocial, signInToSoical, bindSocialAccount, registerWithSocial } from './social';
import {
invokeSocialSignIn,
signInWithSoical,
bindSocialAccount,
registerWithSocial,
} from './social';
jest.mock('ky', () => ({
post: jest.fn(() => ({
@ -137,8 +142,8 @@ describe('api', () => {
});
});
it('signInWithSocial', async () => {
await signInWithSocial('connectorId', 'state', 'redirectUri');
it('invokeSocialSignIn', async () => {
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
json: {
connectorId: 'connectorId',
@ -148,14 +153,14 @@ describe('api', () => {
});
});
it('signInToSoical', async () => {
it('signInWithSoical', async () => {
const parameters = {
connectorId: 'connectorId',
state: 'state',
redirectUri: 'redirectUri',
code: 'code',
};
await signInToSoical(parameters);
await signInWithSoical(parameters);
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
json: parameters,
});

View file

@ -1,6 +1,10 @@
import ky from 'ky';
export const signInWithSocial = async (connectorId: string, state: string, redirectUri: string) => {
export const invokeSocialSignIn = async (
connectorId: string,
state: string,
redirectUri: string
) => {
type Response = {
redirectTo: string;
};
@ -16,7 +20,7 @@ export const signInWithSocial = async (connectorId: string, state: string, redir
.json<Response>();
};
export const signInToSoical = async (parameters: {
export const signInWithSoical = async (parameters: {
connectorId: string;
state: string;
redirectUri: string;

View file

@ -0,0 +1,4 @@
<svg id="more" width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="19" stroke="#AEAEAE" stroke-width="2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17 24C17 25.1046 16.1046 26 15 26C13.8954 26 13 25.1046 13 24C13 22.8954 13.8954 22 15 22C16.1046 22 17 22.8954 17 24ZM26 24C26 25.1046 25.1046 26 24 26C22.8954 26 22 25.1046 22 24C22 22.8954 22.8954 22 24 22C25.1046 22 26 22.8954 26 24ZM33 26C34.1046 26 35 25.1046 35 24C35 22.8954 34.1046 22 33 22C31.8954 22 31 22.8954 31 24C31 25.1046 31.8954 26 33 26Z" fill="#AEAEAE"/>
</svg>

After

Width:  |  Height:  |  Size: 609 B

View file

@ -0,0 +1,17 @@
import classNames from 'classnames';
import React from 'react';
import styles from './SocialIconButton.module.scss';
type Props = {
className?: string;
onClick?: () => void;
};
const MoreButton = ({ className, onClick }: Props) => {
return (
<button className={classNames(styles.socialButton, styles.more, className)} onClick={onClick} />
);
};
export default MoreButton;

View file

@ -0,0 +1,21 @@
@use '@/scss/underscore' as _;
.socialButton {
width: 48px;
height: 48px;
border-radius: 50%;
@include _.flex-column;
background: var(--color-secondary-background-active);
border: none;
&.more {
background: url('../../assets/icons/more-social-icon.svg') no-repeat center;
}
}
.icon {
width: 28px;
height: 28px;
@include _.image-align-center;
}

View file

@ -0,0 +1,23 @@
import { ConnectorMetadata } from '@logto/schemas';
import classNames from 'classnames';
import React from 'react';
import styles from './SocialIconButton.module.scss';
type Props = {
className?: string;
connector: Pick<ConnectorMetadata, 'id' | 'logo'>;
onClick?: () => void;
};
const SocialIconButton = ({ className, connector, onClick }: Props) => {
const { id, logo } = connector;
return (
<button className={classNames(styles.socialButton, className)} onClick={onClick}>
{logo && <img src={logo} alt={id} className={styles.icon} />}
</button>
);
};
export default SocialIconButton;

View file

@ -13,20 +13,4 @@
@include _.image-align-center;
margin-right: _.unit(4);
}
&:active {
background: var(--color-secondary-background-active);
border: _.border(var(--color-border-active));
color: var(--color-font-primary);
}
&:disabled {
background: var(--color-secondary-background-disabled);
border: _.border(var(--color-border-disabled));
color: var(--color-font-secondary-disabled);
.icon {
filter: grayscale(100%);
}
}
}

View file

@ -0,0 +1,27 @@
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { socialConnectors } from '@/__mocks__/logto';
import SecondarySocialSignIn from './SecondarySocialSignIn';
describe('SecondarySocialSignIn', () => {
it('less than four connectors', () => {
const { container } = render(
<MemoryRouter>
<SecondarySocialSignIn connectors={socialConnectors.slice(0, 3)} />
</MemoryRouter>
);
expect(container.querySelectorAll('button')).toHaveLength(3);
});
it('more than four connectors', () => {
const { container } = render(
<MemoryRouter>
<SecondarySocialSignIn connectors={socialConnectors} />
</MemoryRouter>
);
expect(container.querySelectorAll('button')).toHaveLength(4);
});
});

View file

@ -0,0 +1,47 @@
import { ConnectorMetadata } from '@logto/schemas';
import classNames from 'classnames';
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 * as styles from './index.module.scss';
type Props = {
className?: string;
connectors: Array<Pick<ConnectorMetadata, 'id' | 'logo'>>;
};
const SecondarySocialSignIn = ({ className, connectors }: Props) => {
const { invokeSocialSignIn } = useSocial();
const sampled = connectors.length > 4;
const sampledConnectors = useMemo(() => {
// TODO: filter with native returned
if (sampled) {
return connectors.slice(0, 3);
}
return connectors;
}, [connectors, sampled]);
return (
<div className={classNames(styles.socialIconList, className)}>
{sampledConnectors.map((connector) => (
<SocialIconButton
key={connector.id}
className={styles.socialButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id);
}}
/>
))}
{sampled && <MoreButton className={styles.socialButton} />}
</div>
);
};
export default SecondarySocialSignIn;

View file

@ -0,0 +1,16 @@
@use '@/scss/underscore' as _;
.socialIconList {
width: 100%;
max-width: 360px;
@include _.flex-row;
justify-content: center;
}
.socialButton {
margin-right: _.unit(10);
&:last-child {
margin-right: 0;
}
}

View file

@ -0,0 +1 @@
export { default as SecondarySocialSignIn } from './SecondarySocialSignIn';

View file

@ -0,0 +1,87 @@
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

@ -1,3 +1,5 @@
import { Crypto } from '@peculiar/webcrypto';
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'matchMedia', {
@ -15,6 +17,9 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
// eslint-disable-next-line @silverhand/fp/no-mutation
global.crypto = new Crypto();
const translation = (key: string) => key;
jest.mock('react-i18next', () => ({

View file

@ -0,0 +1,21 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import useSocial from '@/hooks/use-social-connector';
type Props = {
connector?: string;
};
const Callback = () => {
const { connector } = useParams<Props>();
const { signInWithSocial } = useSocial();
useEffect(() => {
signInWithSocial(connector);
}, [signInWithSocial, connector]);
return <div>{connector} loading...</div>;
};
export default Callback;

View file

@ -0,0 +1,8 @@
import { generateRandomString } from '.';
describe('util methods', () => {
it('generateRandomString', () => {
const random = generateRandomString();
expect(random).not.toBeNull();
});
});

View file

@ -0,0 +1,4 @@
import { fromUint8Array } from 'js-base64';
export const generateRandomString = (length = 16) =>
fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true);

63
pnpm-lock.yaml generated
View file

@ -353,6 +353,7 @@ importers:
'@logto/schemas': ^0.1.0
'@parcel/core': ^2.3.2
'@parcel/transformer-sass': ^2.3.2
'@peculiar/webcrypto': ^1.3.3
'@silverhand/eslint-config': ^0.10.2
'@silverhand/eslint-config-react': ^0.10.3
'@silverhand/essentials': ^1.1.7
@ -371,6 +372,7 @@ importers:
identity-obj-proxy: ^3.0.0
jest: ^27.5.1
jest-transform-stub: ^2.0.0
js-base64: ^3.7.2
ky: ^0.29.0
libphonenumber-js: ^1.9.49
lint-staged: ^11.1.1
@ -396,6 +398,7 @@ importers:
classnames: 2.3.1
i18next: 21.6.11
i18next-browser-languagedetector: 6.1.3
js-base64: 3.7.2
ky: 0.29.0
libphonenumber-js: 1.9.49
react: 17.0.2
@ -410,6 +413,7 @@ importers:
'@jest/types': 27.5.1
'@parcel/core': 2.3.2
'@parcel/transformer-sass': 2.3.2_@parcel+core@2.3.2
'@peculiar/webcrypto': 1.3.3
'@silverhand/eslint-config': 0.10.2_3a533fa6cc3da0cf8525ef55d41c4384
'@silverhand/eslint-config-react': 0.10.3_75f27ebc3d08f23e7ca8c3f63c7fa502
'@silverhand/ts-config': 0.10.2_typescript@4.6.2
@ -5103,6 +5107,33 @@ packages:
nullthrows: 1.1.1
dev: true
/@peculiar/asn1-schema/2.1.0:
resolution: {integrity: sha512-D6g4C5YRKC/iPujMAOXuZ7YGdaoMx8GsvWzfVSyx2LYeL38ECOKNywlYAuwbqQvON64lgsYdAujWQPX8hhoBLw==}
dependencies:
'@types/asn1js': 2.0.2
asn1js: 2.3.2
pvtsutils: 1.2.2
tslib: 2.3.1
dev: true
/@peculiar/json-schema/1.1.12:
resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==}
engines: {node: '>=8.0.0'}
dependencies:
tslib: 2.3.1
dev: true
/@peculiar/webcrypto/1.3.3:
resolution: {integrity: sha512-+jkp16Hp18HkphJlMtqsQKjyDWJBh0AhDuoB+vVakuIRbkBdaFb7v26Ldm25altjiYhCyQnR5NChHxwSTvbXJw==}
engines: {node: '>=10.12.0'}
dependencies:
'@peculiar/asn1-schema': 2.1.0
'@peculiar/json-schema': 1.1.12
pvtsutils: 1.2.2
tslib: 2.3.1
webcrypto-core: 1.7.3
dev: true
/@polka/url/1.0.0-next.21:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: false
@ -5574,6 +5605,10 @@ packages:
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
dev: true
/@types/asn1js/2.0.2:
resolution: {integrity: sha512-t4YHCgtD+ERvH0FyxvNlYwJ2ezhqw7t+Ygh4urQ7dJER8i185JPv6oIM3ey5YQmGN6Zp9EMbpohkjZi9t3UxwA==}
dev: true
/@types/babel__core/7.1.17:
resolution: {integrity: sha512-6zzkezS9QEIL8yCBvXWxPTJPNuMeECJVxSOhxNY/jfq9LxOTHivaYTqr37n9LknWWRTIkzqH2UilS5QFvfa90A==}
dependencies:
@ -6726,6 +6761,13 @@ packages:
safer-buffer: 2.1.2
dev: true
/asn1js/2.3.2:
resolution: {integrity: sha512-IYzujqcOk7fHaePpTyvD3KPAA0AjT3qZlaQAw76zmPPAV/XTjhO+tbHjbFbIQZIhw+fk9wCSfb0Z6K+JHe8Q2g==}
engines: {node: '>=6.0.0'}
dependencies:
pvutils: 1.1.3
dev: true
/assert-plus/1.0.0:
resolution: {integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=}
engines: {node: '>=0.8'}
@ -15792,6 +15834,17 @@ packages:
resolution: {integrity: sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4=}
dev: false
/pvtsutils/1.2.2:
resolution: {integrity: sha512-OALo5ZEdqiI127i64+CXwkCOyFHUA+tCQgaUO/MvRDFXWPr53f2sx28ECNztUEzuyu5xvuuD1EB/szg9mwJoGA==}
dependencies:
tslib: 2.3.1
dev: true
/pvutils/1.1.3:
resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
engines: {node: '>=6.0.0'}
dev: true
/q/1.5.1:
resolution: {integrity: sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
@ -19158,6 +19211,16 @@ packages:
resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==}
dev: false
/webcrypto-core/1.7.3:
resolution: {integrity: sha512-8TnMtwwC/hQOyvElAOJ26lJKGgcErUG02KnKS1+QhjV4mDvQetVWU1EUEeLF8ICOrdc42+GypocyBJKRqo2kQg==}
dependencies:
'@peculiar/asn1-schema': 2.1.0
'@peculiar/json-schema': 1.1.12
asn1js: 2.3.2
pvtsutils: 1.2.2
tslib: 2.3.1
dev: true
/webidl-conversions/3.0.1:
resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=}