diff --git a/packages/ui/package.json b/packages/ui/package.json index 32e3006ee..568044149 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 3601fec30..0fa52cee7 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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 = () => { } /> } /> } /> + } /> diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index cfc9c9c28..7992e80d1 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -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', + }, +]; diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 51a919dc4..f1c6a3e2b 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -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, }); diff --git a/packages/ui/src/apis/social.ts b/packages/ui/src/apis/social.ts index f51822dc5..e8661f388 100644 --- a/packages/ui/src/apis/social.ts +++ b/packages/ui/src/apis/social.ts @@ -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(); }; -export const signInToSoical = async (parameters: { +export const signInWithSoical = async (parameters: { connectorId: string; state: string; redirectUri: string; diff --git a/packages/ui/src/assets/icons/more-social-icon.svg b/packages/ui/src/assets/icons/more-social-icon.svg new file mode 100644 index 000000000..977653230 --- /dev/null +++ b/packages/ui/src/assets/icons/more-social-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/ui/src/components/Button/MoreButton.tsx b/packages/ui/src/components/Button/MoreButton.tsx new file mode 100644 index 000000000..b8478d7b1 --- /dev/null +++ b/packages/ui/src/components/Button/MoreButton.tsx @@ -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 ( + + ); +}; + +export default SocialIconButton; diff --git a/packages/ui/src/components/Button/SocialLinkButton.module.scss b/packages/ui/src/components/Button/SocialLinkButton.module.scss index 5f84e0a93..1c526dc6c 100644 --- a/packages/ui/src/components/Button/SocialLinkButton.module.scss +++ b/packages/ui/src/components/Button/SocialLinkButton.module.scss @@ -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%); - } - } } diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx new file mode 100644 index 000000000..711712915 --- /dev/null +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.test.tsx @@ -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( + + + + ); + expect(container.querySelectorAll('button')).toHaveLength(3); + }); + + it('more than four connectors', () => { + const { container } = render( + + + + ); + expect(container.querySelectorAll('button')).toHaveLength(4); + }); +}); diff --git a/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx new file mode 100644 index 000000000..131cfefa9 --- /dev/null +++ b/packages/ui/src/containers/SocialSignIn/SecondarySocialSignIn.tsx @@ -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>; +}; + +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 ( +
+ {sampledConnectors.map((connector) => ( + { + void invokeSocialSignIn(connector.id); + }} + /> + ))} + {sampled && } +
+ ); +}; + +export default SecondarySocialSignIn; diff --git a/packages/ui/src/containers/SocialSignIn/index.module.scss b/packages/ui/src/containers/SocialSignIn/index.module.scss new file mode 100644 index 000000000..8e46bbb57 --- /dev/null +++ b/packages/ui/src/containers/SocialSignIn/index.module.scss @@ -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; + } +} diff --git a/packages/ui/src/containers/SocialSignIn/index.ts b/packages/ui/src/containers/SocialSignIn/index.ts new file mode 100644 index 000000000..a5247871b --- /dev/null +++ b/packages/ui/src/containers/SocialSignIn/index.ts @@ -0,0 +1 @@ +export { default as SecondarySocialSignIn } from './SecondarySocialSignIn'; diff --git a/packages/ui/src/hooks/use-social-connector.ts b/packages/ui/src/hooks/use-social-connector.ts new file mode 100644 index 000000000..0f94448b0 --- /dev/null +++ b/packages/ui/src/hooks/use-social-connector.ts @@ -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; diff --git a/packages/ui/src/jest.setup.ts b/packages/ui/src/jest.setup.ts index 52ca21b8f..b843b08dd 100644 --- a/packages/ui/src/jest.setup.ts +++ b/packages/ui/src/jest.setup.ts @@ -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', () => ({ diff --git a/packages/ui/src/pages/Callback/index.tsx b/packages/ui/src/pages/Callback/index.tsx new file mode 100644 index 000000000..b5e38d2c8 --- /dev/null +++ b/packages/ui/src/pages/Callback/index.tsx @@ -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(); + const { signInWithSocial } = useSocial(); + + useEffect(() => { + signInWithSocial(connector); + }, [signInWithSocial, connector]); + + return
{connector} loading...
; +}; + +export default Callback; diff --git a/packages/ui/src/utils/index.test.ts b/packages/ui/src/utils/index.test.ts new file mode 100644 index 000000000..2c2a77468 --- /dev/null +++ b/packages/ui/src/utils/index.test.ts @@ -0,0 +1,8 @@ +import { generateRandomString } from '.'; + +describe('util methods', () => { + it('generateRandomString', () => { + const random = generateRandomString(); + expect(random).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts new file mode 100644 index 000000000..e47257af0 --- /dev/null +++ b/packages/ui/src/utils/index.ts @@ -0,0 +1,4 @@ +import { fromUint8Array } from 'js-base64'; + +export const generateRandomString = (length = 16) => + fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d58e16f6e..3384dea43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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=}