From 1645e2fdab136c0211b43a318b1a6caca60784b3 Mon Sep 17 00:00:00 2001 From: Dawid Tarasiuk Date: Tue, 26 Jan 2021 23:04:54 +0100 Subject: [PATCH] Implement Oauth2 flow with OpenID --- .github/workflows/test-env-deploy.yml | 2 +- CHANGELOG.md | 1 + locale/defaultMessages.json | 17 +- schema.graphql | 49 +++ src/auth/AuthProvider.test.ts | 14 +- src/auth/AuthProvider.tsx | 220 +---------- .../LoginLoading/LoginLoading.stories.tsx} | 4 +- .../LoginPage/LoginPage.stories.tsx} | 19 +- src/auth/components/LoginPage/LoginPage.tsx | 112 ++++-- src/auth/components/LoginPage/form.tsx | 73 ++++ src/auth/hooks/useAuthProvider.ts | 58 +++ src/auth/hooks/useExternalAuthProvider.ts | 262 +++++++++++++ src/auth/hooks/useSaleorAuthProvider.ts | 204 ++++++++++ src/auth/index.tsx | 33 +- src/auth/mutations.ts | 49 +++ src/auth/queries.ts | 12 + .../types/AvailableExternalAuthentications.ts | 22 ++ src/auth/types/ExternalAuthenticationUrl.ts | 30 ++ src/auth/types/ExternalObtainAccessTokens.ts | 54 +++ src/auth/types/ExternalRefreshToken.ts | 21 + src/auth/types/ExternalVerifyToken.ts | 46 +++ src/auth/urls.ts | 9 + src/auth/views/Login.tsx | 89 ++++- src/config.ts | 1 + src/storybook/UserDecorator.tsx | 2 + .../__snapshots__/Stories.test.ts.snap | 362 ++++++++++++------ src/storybook/config.js | 4 - 27 files changed, 1391 insertions(+), 378 deletions(-) rename src/{storybook/stories/auth/LoginLoading.tsx => auth/components/LoginLoading/LoginLoading.stories.tsx} (66%) rename src/{storybook/stories/auth/LoginPage.tsx => auth/components/LoginPage/LoginPage.stories.tsx} (51%) create mode 100644 src/auth/components/LoginPage/form.tsx create mode 100644 src/auth/hooks/useAuthProvider.ts create mode 100644 src/auth/hooks/useExternalAuthProvider.ts create mode 100644 src/auth/hooks/useSaleorAuthProvider.ts create mode 100644 src/auth/queries.ts create mode 100644 src/auth/types/AvailableExternalAuthentications.ts create mode 100644 src/auth/types/ExternalAuthenticationUrl.ts create mode 100644 src/auth/types/ExternalObtainAccessTokens.ts create mode 100644 src/auth/types/ExternalRefreshToken.ts create mode 100644 src/auth/types/ExternalVerifyToken.ts diff --git a/.github/workflows/test-env-deploy.yml b/.github/workflows/test-env-deploy.yml index 17055ec08..d7603e822 100644 --- a/.github/workflows/test-env-deploy.yml +++ b/.github/workflows/test-env-deploy.yml @@ -88,7 +88,7 @@ jobs: aws s3 sync ./build/storybook s3://${{ secrets.AWS_TEST_DEPLOYMENT_BUCKET }}/${{ steps.set-domain.outputs.domain }}/storybook - name: Invalidate cache - run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_TEST_CF_DIST_ID }} --paths "/${{ env.domain }}/*" + run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_TEST_CF_DIST_ID }} --paths "/${{ steps.set-domain.outputs.domain }}/*" - name: Update deployment status uses: bobheadxi/deployments@v0.4.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index e3deeb342..88c8d972c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add product reference attributes - #948 by @orzechdev - Drop descriptionJson and contentJson fields - #950 by @jwm0 - Add error tracking with Sentry adapter - #956 by @jwm0 +- Add OAuth2 login with OpenID support - #963 by @orzechdev # 2.11.1 diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index bafa10e42..7ea290962 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1016,12 +1016,23 @@ "src_dot_auth_dot_components_dot_LoginPage_dot_2237029987": { "string": "Password" }, + "src_dot_auth_dot_components_dot_LoginPage_dot_2981302356": { + "context": "link", + "string": "Use this link to recover it" + }, "src_dot_auth_dot_components_dot_LoginPage_dot_3476994590": { "string": "Sorry, your username and/or password are incorrect. Please try again." }, - "src_dot_auth_dot_components_dot_LoginPage_dot_4028609483": { - "context": "button", - "string": "Reset your password" + "src_dot_auth_dot_components_dot_LoginPage_dot_3762459576": { + "context": "description", + "string": "or login using" + }, + "src_dot_auth_dot_components_dot_LoginPage_dot_534894384": { + "string": "Sorry, login went wrong. Please try again." + }, + "src_dot_auth_dot_components_dot_LoginPage_dot_599516345": { + "context": "description", + "string": "Forgot password? {resetPasswordLink}" }, "src_dot_auth_dot_components_dot_NewPasswordPage_dot_1254879564": { "string": "New Password" diff --git a/schema.graphql b/schema.graphql index 0ed146de4..2945ad6ca 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1943,6 +1943,49 @@ enum ExportScope { FILTER } +type ExternalAuthentication { + id: String! + name: String +} + +type ExternalAuthenticationUrl { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + authenticationData: JSONString + accountErrors: [AccountError!]! +} + +type ExternalLogout { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + logoutData: JSONString + accountErrors: [AccountError!]! +} + +type ExternalObtainAccessTokens { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + token: String + refreshToken: String + csrfToken: String + user: User + accountErrors: [AccountError!]! +} + +type ExternalRefresh { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + token: String + refreshToken: String + csrfToken: String + user: User + accountErrors: [AccountError!]! +} + +type ExternalVerify { + errors: [Error!]! @deprecated(reason: "Use typed errors with error codes. This field will be removed after 2020-07-31.") + user: User + isValid: Boolean! + verifyData: JSONString + accountErrors: [AccountError!]! +} + type File { url: String! contentType: String @@ -2765,6 +2808,11 @@ type Mutation { tokenRefresh(csrfToken: String, refreshToken: String): RefreshToken tokenVerify(token: String!): VerifyToken tokensDeactivateAll: DeactivateAllUserTokens + externalAuthenticationUrl(input: JSONString!, pluginId: String!): ExternalAuthenticationUrl + externalObtainAccessTokens(input: JSONString!, pluginId: String!): ExternalObtainAccessTokens + externalRefresh(input: JSONString!, pluginId: String!): ExternalRefresh + externalLogout(input: JSONString!, pluginId: String!): ExternalLogout + externalVerify(input: JSONString!, pluginId: String!): ExternalVerify requestPasswordReset(email: String!, redirectUrl: String!): RequestPasswordReset confirmAccount(email: String!, token: String!): ConfirmAccount setPassword(email: String!, password: String!, token: String!): SetPassword @@ -4906,6 +4954,7 @@ input ShippingZoneUpdateInput { type Shop { availablePaymentGateways(currency: String): [PaymentGateway!]! + availableExternalAuthentications: [ExternalAuthentication!]! availableShippingMethods(channel: String!, address: AddressInput): [ShippingMethod] geolocalization: Geolocalization countries(languageCode: LanguageCodeEnum): [CountryDisplay!]! diff --git a/src/auth/AuthProvider.test.ts b/src/auth/AuthProvider.test.ts index 058ad8b08..7a418071d 100644 --- a/src/auth/AuthProvider.test.ts +++ b/src/auth/AuthProvider.test.ts @@ -2,7 +2,7 @@ import setupApi from "@test/api"; import { act, renderHook } from "@testing-library/react-hooks"; import ApolloClient from "apollo-client"; -import { useAuthProvider } from "./AuthProvider"; +import { useAuthProvider } from "./hooks/useAuthProvider"; import { getTokens, setAuthToken } from "./utils"; const apolloClient = setupApi(); @@ -14,7 +14,7 @@ function renderAuthProvider(apolloClient: ApolloClient) { const notify = jest.fn(); const { result } = renderHook(() => - useAuthProvider(intl as any, notify, apolloClient) + useAuthProvider({ apolloClient, intl: intl as any, notify }) ); return result; @@ -43,7 +43,7 @@ describe("User", () => { await act(() => hook.current.login(adminCredentials.email, adminCredentials.password) ); - expect(hook.current.userContext.email).toBe(adminCredentials.email); + expect(hook.current.user.email).toBe(adminCredentials.email); adminCredentials.token = getTokens().auth; done(); @@ -55,7 +55,7 @@ describe("User", () => { await act(() => hook.current.login(adminCredentials.email, "NotAValidPassword123!") ); - expect(hook.current.userContext).toBe(null); + expect(hook.current.user).toBe(null); done(); }); @@ -69,7 +69,7 @@ describe("User", () => { nonStaffUserCredentials.password ) ); - expect(hook.current.userContext).toBe(undefined); + expect(hook.current.user).toBe(undefined); done(); }); @@ -79,7 +79,7 @@ describe("User", () => { const hook = renderAuthProvider(apolloClient); await act(() => hook.current.autologinPromise.current); - expect(hook.current.userContext.email).toBe(adminCredentials.email); + expect(hook.current.user.email).toBe(adminCredentials.email); done(); }); @@ -89,7 +89,7 @@ describe("User", () => { const hook = renderAuthProvider(apolloClient); await act(() => hook.current.autologinPromise.current); - expect(hook.current.userContext).toBe(undefined); + expect(hook.current.user).toBe(undefined); done(); }); diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index 7cfe3a77a..974cee3f3 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -1,197 +1,11 @@ -import { IMessageContext } from "@saleor/components/messages"; -import { DEMO_MODE } from "@saleor/config"; -import { User } from "@saleor/fragments/types/User"; import useNotifier from "@saleor/hooks/useNotifier"; -import { commonMessages } from "@saleor/intl"; -import { getMutationStatus } from "@saleor/misc"; -import errorTracker from "@saleor/services/errorTracking"; -import { - isSupported as isCredentialsManagementAPISupported, - login as loginWithCredentialsManagementAPI, - saveCredentials -} from "@saleor/utils/credentialsManagement"; -import ApolloClient from "apollo-client"; -import React, { useContext, useEffect, useRef, useState } from "react"; -import { useApolloClient, useMutation } from "react-apollo"; -import { IntlShape, useIntl } from "react-intl"; +import React, { useContext } from "react"; +import { useApolloClient } from "react-apollo"; +import { useIntl } from "react-intl"; import { UserContext } from "./"; -import { - tokenAuthMutation, - tokenRefreshMutation, - tokenVerifyMutation -} from "./mutations"; -import { RefreshToken, RefreshTokenVariables } from "./types/RefreshToken"; -import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth"; -import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken"; -import { - displayDemoMessage, - getTokens, - removeTokens, - setAuthToken, - setTokens -} from "./utils"; - -const persistToken = false; - -export function useAuthProvider( - intl: IntlShape, - notify: IMessageContext, - apolloClient: ApolloClient -) { - const [userContext, setUserContext] = useState(undefined); - const autologinPromise = useRef>(); - const refreshPromise = useRef>(); - - useEffect(() => { - const token = getTokens().auth; - if (!!token && !userContext) { - autologinPromise.current = tokenVerify({ variables: { token } }); - } else { - autologinPromise.current = loginWithCredentialsManagementAPI(login); - } - }, []); - - useEffect(() => { - if (userContext) { - const { id, email, firstName, lastName } = userContext; - errorTracker.setUserData({ - email, - id, - username: `${firstName} ${lastName}` - }); - - if (!userContext.isStaff) { - logout(); - notify({ - status: "error", - text: intl.formatMessage(commonMessages.unauthorizedDashboardAccess), - title: intl.formatMessage(commonMessages.insufficientPermissions) - }); - } - } - }, [userContext]); - - const logout = () => { - setUserContext(undefined); - if (isCredentialsManagementAPISupported) { - navigator.credentials.preventSilentAccess(); - } - removeTokens(); - }; - - const [tokenAuth, tokenAuthResult] = useMutation< - TokenAuth, - TokenAuthVariables - >(tokenAuthMutation, { - client: apolloClient, - onCompleted: ({ tokenCreate }) => { - if (tokenCreate.errors.length > 0) { - logout(); - } - - const user = tokenCreate.user; - - // FIXME: Now we set state also when auth fails and returned user is - // `null`, because the LoginView uses this `null` to display error. - setUserContext(user); - if (user) { - setTokens(tokenCreate.token, tokenCreate.csrfToken, persistToken); - } - }, - onError: logout - }); - const [tokenRefresh] = useMutation( - tokenRefreshMutation, - { - client: apolloClient, - onError: logout - } - ); - const [tokenVerify, tokenVerifyResult] = useMutation< - VerifyToken, - VerifyTokenVariables - >(tokenVerifyMutation, { - client: apolloClient, - onCompleted: result => { - if (result.tokenVerify === null) { - logout(); - } else { - const user = result.tokenVerify?.user; - - if (!!user) { - setUserContext(user); - } - } - }, - onError: logout - }); - - const tokenAuthOpts = { - ...tokenAuthResult, - status: getMutationStatus(tokenAuthResult) - }; - const tokenVerifyOpts = { - ...tokenVerifyResult, - status: getMutationStatus(tokenVerifyResult) - }; - - const onLogin = () => { - if (DEMO_MODE) { - displayDemoMessage(intl, notify); - } - }; - - const login = async (email: string, password: string) => { - const result = await tokenAuth({ variables: { email, password } }); - - if (result && !result.data.tokenCreate.errors.length) { - if (!!onLogin) { - onLogin(); - } - saveCredentials(result.data.tokenCreate.user, password); - - return result.data.tokenCreate.user; - } - - return null; - }; - - const loginByToken = (auth: string, refresh: string, user: User) => { - setUserContext(user); - setTokens(auth, refresh, persistToken); - }; - - const refreshToken = (): Promise => { - if (!!refreshPromise.current) { - return refreshPromise.current; - } - - return new Promise(resolve => { - const token = getTokens().refresh; - - return tokenRefresh({ variables: { token } }).then(refreshData => { - if (!!refreshData.data.tokenRefresh?.token) { - setAuthToken(refreshData.data.tokenRefresh.token, persistToken); - return resolve(true); - } - - return resolve(false); - }); - }); - }; - - return { - autologinPromise, - login, - loginByToken, - logout, - refreshToken, - tokenAuthOpts, - tokenVerifyOpts, - userContext - }; -} +import { useAuthProvider } from "./hooks/useAuthProvider"; +import { getTokens } from "./utils"; interface AuthProviderProps { children: React.ReactNode; @@ -202,30 +16,10 @@ const AuthProvider: React.FC = ({ children }) => { const intl = useIntl(); const notify = useNotifier(); - const { - login, - loginByToken, - logout, - tokenAuthOpts, - refreshToken, - tokenVerifyOpts, - userContext - } = useAuthProvider(intl, notify, apolloClient); + const authProvider = useAuthProvider({ apolloClient, intl, notify }); return ( - - {children} - + {children} ); }; diff --git a/src/storybook/stories/auth/LoginLoading.tsx b/src/auth/components/LoginLoading/LoginLoading.stories.tsx similarity index 66% rename from src/storybook/stories/auth/LoginLoading.tsx rename to src/auth/components/LoginLoading/LoginLoading.stories.tsx index a3cb8fb09..8945047ff 100644 --- a/src/storybook/stories/auth/LoginLoading.tsx +++ b/src/auth/components/LoginLoading/LoginLoading.stories.tsx @@ -1,8 +1,8 @@ +import Decorator from "@saleor/storybook/Decorator"; import { storiesOf } from "@storybook/react"; import React from "react"; -import LoginLoading from "../../../auth/components/LoginLoading"; -import Decorator from "../../Decorator"; +import LoginLoading from "."; storiesOf("Views / Authentication / Verifying remembered user", module) .addDecorator(Decorator) diff --git a/src/storybook/stories/auth/LoginPage.tsx b/src/auth/components/LoginPage/LoginPage.stories.tsx similarity index 51% rename from src/storybook/stories/auth/LoginPage.tsx rename to src/auth/components/LoginPage/LoginPage.stories.tsx index 68a3d0add..a1d05ccdb 100644 --- a/src/storybook/stories/auth/LoginPage.tsx +++ b/src/auth/components/LoginPage/LoginPage.stories.tsx @@ -1,14 +1,24 @@ import { Omit } from "@material-ui/core"; +import CardDecorator from "@saleor/storybook/CardDecorator"; +import Decorator from "@saleor/storybook/Decorator"; import { storiesOf } from "@storybook/react"; import React from "react"; import LoginPage, { LoginCardProps } from "../../../auth/components/LoginPage"; -import CardDecorator from "../../CardDecorator"; -import Decorator from "../../Decorator"; const props: Omit = { - disableLoginButton: true, + disabled: false, error: false, + externalAuthentications: [ + { + __typename: "ExternalAuthentication", + id: "auth.plugin.example", + name: "Example auth plugin" + } + ], + externalError: false, + loading: false, + onExternalAuthentication: () => undefined, onPasswordRecovery: undefined, onSubmit: () => undefined }; @@ -18,4 +28,5 @@ storiesOf("Views / Authentication / Log in", module) .addDecorator(Decorator) .add("default", () => ) .add("error", () => ) - .add("loading", () => ); + .add("disabled", () => ) + .add("loading", () => ); diff --git a/src/auth/components/LoginPage/LoginPage.tsx b/src/auth/components/LoginPage/LoginPage.tsx index 700a9d0f4..1cda9ef13 100644 --- a/src/auth/components/LoginPage/LoginPage.tsx +++ b/src/auth/components/LoginPage/LoginPage.tsx @@ -1,18 +1,17 @@ import Button from "@material-ui/core/Button"; +import CircularProgress from "@material-ui/core/CircularProgress"; +import Divider from "@material-ui/core/Divider"; import { makeStyles } from "@material-ui/core/styles"; import TextField from "@material-ui/core/TextField"; import Typography from "@material-ui/core/Typography"; -import Form from "@saleor/components/Form"; +import { AvailableExternalAuthentications_shop_availableExternalAuthentications } from "@saleor/auth/types/AvailableExternalAuthentications"; import { FormSpacer } from "@saleor/components/FormSpacer"; -import { DEMO_MODE } from "@saleor/config"; +import { SubmitPromise } from "@saleor/hooks/useForm"; import { commonMessages } from "@saleor/intl"; import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; -export interface FormData { - email: string; - password: string; -} +import LoginForm, { LoginFormData } from "./form"; const useStyles = makeStyles( theme => ({ @@ -23,7 +22,13 @@ const useStyles = makeStyles( link: { color: theme.palette.primary.main, cursor: "pointer", - textAlign: "center" + textDecoration: "underline" + }, + loading: { + alignItems: "center", + display: "flex", + height: "100vh", + justifyContent: "center" }, loginButton: { width: 140 @@ -43,27 +48,40 @@ const useStyles = makeStyles( export interface LoginCardProps { error: boolean; - disableLoginButton: boolean; + externalError: boolean; + disabled: boolean; + loading: boolean; + externalAuthentications?: AvailableExternalAuthentications_shop_availableExternalAuthentications[]; + onExternalAuthentication: (pluginId: string) => void; onPasswordRecovery: () => void; - onSubmit?(event: FormData); + onSubmit?: (event: LoginFormData) => SubmitPromise; } const LoginCard: React.FC = props => { - const { error, disableLoginButton, onPasswordRecovery, onSubmit } = props; + const { + error, + externalError, + disabled, + loading, + externalAuthentications = [], + onExternalAuthentication, + onPasswordRecovery, + onSubmit + } = props; const classes = useStyles(props); const intl = useIntl(); - let initialFormData = { email: "", password: "" }; - if (DEMO_MODE) { - initialFormData = { - email: "admin@example.com", - password: "admin" - }; + if (loading) { + return ( +
+ +
+ ); } return ( -
+ {({ change: handleChange, data, submit: handleSubmit }) => ( <> {error && ( @@ -73,6 +91,13 @@ const LoginCard: React.FC = props => { )} + {externalError && ( +
+ + + +
+ )} = props => { inputProps={{ "data-test": "email" }} + disabled={disabled} /> = props => { inputProps={{ "data-test": "password" }} + disabled={disabled} />
- + + + + ) + }} /> + {externalAuthentications.length > 0 && ( + <> + + + + + + + + )} + {externalAuthentications.map(externalAuthentication => ( + + + + + ))} )} - +
); }; LoginCard.displayName = "LoginCard"; diff --git a/src/auth/components/LoginPage/form.tsx b/src/auth/components/LoginPage/form.tsx new file mode 100644 index 000000000..8dbd7389d --- /dev/null +++ b/src/auth/components/LoginPage/form.tsx @@ -0,0 +1,73 @@ +import { DEMO_MODE } from "@saleor/config"; +import useForm, { FormChange, SubmitPromise } from "@saleor/hooks/useForm"; +import handleFormSubmit from "@saleor/utils/handlers/handleFormSubmit"; +import React from "react"; + +export interface LoginFormData { + email: string; + password: string; +} + +export interface UseLoginFormResult { + change: FormChange; + data: LoginFormData; + hasChanged: boolean; + submit: () => Promise; +} + +export interface LoginFormProps { + children: (props: UseLoginFormResult) => React.ReactNode; + onSubmit: (data: LoginFormData) => SubmitPromise; +} + +const getLoginFormData = () => { + if (DEMO_MODE) { + return { + email: "admin@example.com", + password: "admin" + }; + } + return { email: "", password: "" }; +}; + +function useLoginForm( + onSubmit: (data: LoginFormData) => SubmitPromise +): UseLoginFormResult { + const [changed, setChanged] = React.useState(false); + const triggerChange = () => setChanged(true); + + const form = useForm(getLoginFormData()); + + const handleChange: FormChange = (event, cb) => { + form.change(event, cb); + triggerChange(); + }; + + const data: LoginFormData = { + ...form.data + }; + + const handleSubmit = async (data: LoginFormData) => { + const errors = await onSubmit(data); + + return errors; + }; + + const submit = async () => handleFormSubmit(data, handleSubmit, setChanged); + + return { + change: handleChange, + data, + hasChanged: changed, + submit + }; +} + +const LoginForm: React.FC = ({ children, onSubmit }) => { + const props = useLoginForm(onSubmit); + + return
{children(props)}
; +}; + +LoginForm.displayName = "LoginForm"; +export default LoginForm; diff --git a/src/auth/hooks/useAuthProvider.ts b/src/auth/hooks/useAuthProvider.ts new file mode 100644 index 000000000..ea3260469 --- /dev/null +++ b/src/auth/hooks/useAuthProvider.ts @@ -0,0 +1,58 @@ +import { IMessageContext } from "@saleor/components/messages"; +import { User } from "@saleor/fragments/types/User"; +import useLocalStorage from "@saleor/hooks/useLocalStorage"; +import ApolloClient from "apollo-client"; +import { MutableRefObject } from "react"; +import { IntlShape } from "react-intl"; + +import { useExternalAuthProvider } from "./useExternalAuthProvider"; +import { useSaleorAuthProvider } from "./useSaleorAuthProvider"; + +export interface UseAuthProvider { + logout: () => void; + tokenAuthLoading: boolean; + tokenRefresh: () => Promise; + tokenVerifyLoading: boolean; + user?: User; + autologinPromise?: MutableRefObject>; +} +export interface UseAuthProviderOpts { + intl: IntlShape; + notify: IMessageContext; + apolloClient: ApolloClient; +} + +export function useAuthProvider(opts: UseAuthProviderOpts) { + const [authPlugin, setAuthPlugin] = useLocalStorage("authPlugin", undefined); + + const saleorAuth = useSaleorAuthProvider({ + authPlugin, + setAuthPlugin, + ...opts + }); + + const externalAuth = useExternalAuthProvider({ + authPlugin, + setAuthPlugin, + ...opts + }); + + const loginAuth = { + login: saleorAuth.login, + loginByExternalPlugin: externalAuth.loginByExternalPlugin, + loginByToken: saleorAuth.loginByToken, + requestLoginByExternalPlugin: externalAuth.requestLoginByExternalPlugin + }; + + if (authPlugin) { + return { + ...externalAuth, + ...loginAuth + }; + } + + return { + ...saleorAuth, + ...loginAuth + }; +} diff --git a/src/auth/hooks/useExternalAuthProvider.ts b/src/auth/hooks/useExternalAuthProvider.ts new file mode 100644 index 000000000..72d06e4d6 --- /dev/null +++ b/src/auth/hooks/useExternalAuthProvider.ts @@ -0,0 +1,262 @@ +import { DEMO_MODE } from "@saleor/config"; +import { User } from "@saleor/fragments/types/User"; +import { SetLocalStorage } from "@saleor/hooks/useLocalStorage"; +import { commonMessages } from "@saleor/intl"; +import { getMutationStatus } from "@saleor/misc"; +import errorTracker from "@saleor/services/errorTracking"; +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "react-apollo"; + +import { + externalAuthenticationUrlMutation, + externalObtainAccessTokensMutation, + externalTokenRefreshMutation, + externalTokenVerifyMutation +} from "../mutations"; +import { + ExternalAuthenticationUrl, + ExternalAuthenticationUrlVariables +} from "../types/ExternalAuthenticationUrl"; +import { + ExternalObtainAccessTokens, + ExternalObtainAccessTokens_externalObtainAccessTokens, + ExternalObtainAccessTokensVariables +} from "../types/ExternalObtainAccessTokens"; +import { + ExternalRefreshToken, + ExternalRefreshTokenVariables +} from "../types/ExternalRefreshToken"; +import { + ExternalVerifyToken, + ExternalVerifyTokenVariables +} from "../types/ExternalVerifyToken"; +import { + displayDemoMessage, + getTokens, + removeTokens, + setAuthToken, + setTokens +} from "../utils"; +import { UseAuthProvider, UseAuthProviderOpts } from "./useAuthProvider"; + +export interface RequestExternalLoginInput { + redirectUri: string; +} +export interface ExternalLoginInput { + code: string; + state: string; +} + +export interface UseExternalAuthProvider extends UseAuthProvider { + requestLoginByExternalPlugin: ( + pluginId: string, + input: RequestExternalLoginInput + ) => Promise; + loginByExternalPlugin: ( + input: ExternalLoginInput + ) => Promise; +} +export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts { + setAuthPlugin: SetLocalStorage; + authPlugin: string; +} + +const persistToken = false; + +export function useExternalAuthProvider({ + apolloClient, + authPlugin, + intl, + notify, + setAuthPlugin +}: UseExternalAuthProviderOpts): UseExternalAuthProvider { + const [userContext, setUserContext] = useState(undefined); + const autologinPromise = useRef>(); + const refreshPromise = useRef>(); + + useEffect(() => { + const token = getTokens().auth; + if (authPlugin && !!token && !userContext) { + const input = JSON.stringify({ + token + }); + autologinPromise.current = tokenVerify({ + variables: { input, pluginId: authPlugin } + }); + } + }, []); + + useEffect(() => { + if (authPlugin && userContext) { + const { id, email, firstName, lastName } = userContext; + errorTracker.setUserData({ + email, + id, + username: `${firstName} ${lastName}` + }); + + if (!userContext.isStaff) { + logout(); + notify({ + status: "error", + text: intl.formatMessage(commonMessages.unauthorizedDashboardAccess), + title: intl.formatMessage(commonMessages.insufficientPermissions) + }); + } + } + }, [userContext]); + + const logout = () => { + setUserContext(undefined); + setAuthPlugin(undefined); + removeTokens(); + }; + + const [externalAuthenticationUrl] = useMutation< + ExternalAuthenticationUrl, + ExternalAuthenticationUrlVariables + >(externalAuthenticationUrlMutation, { + client: apolloClient, + onError: logout + }); + const [obtainAccessTokens, obtainAccessTokensResult] = useMutation< + ExternalObtainAccessTokens, + ExternalObtainAccessTokensVariables + >(externalObtainAccessTokensMutation, { + client: apolloClient, + onCompleted: ({ externalObtainAccessTokens }) => { + if (externalObtainAccessTokens.errors.length > 0) { + logout(); + } + + const user = externalObtainAccessTokens.user; + + setUserContext(user); + if (user) { + setTokens( + externalObtainAccessTokens.token, + externalObtainAccessTokens.csrfToken, + persistToken + ); + } + }, + onError: logout + }); + const [tokenRefresh] = useMutation< + ExternalRefreshToken, + ExternalRefreshTokenVariables + >(externalTokenRefreshMutation, { + client: apolloClient, + onError: logout + }); + const [tokenVerify, tokenVerifyResult] = useMutation< + ExternalVerifyToken, + ExternalVerifyTokenVariables + >(externalTokenVerifyMutation, { + client: apolloClient, + onCompleted: result => { + if (result.externalVerify === null) { + logout(); + } else { + const user = result.externalVerify?.user; + + if (!!user) { + setUserContext(user); + } + } + }, + onError: logout + }); + + const obtainAccessTokensOpts = { + ...obtainAccessTokensResult, + status: getMutationStatus(obtainAccessTokensResult) + }; + const tokenVerifyOpts = { + ...tokenVerifyResult, + status: getMutationStatus(tokenVerifyResult) + }; + + const onLogin = () => { + if (DEMO_MODE) { + displayDemoMessage(intl, notify); + } + }; + + const requestLoginByExternalPlugin = async ( + pluginId: string, + pluginInput: RequestExternalLoginInput + ) => { + const input = JSON.stringify(pluginInput); + const result = await externalAuthenticationUrl({ + variables: { + input, + pluginId + } + }); + + if (result && !result.data.externalAuthenticationUrl.errors.length) { + setAuthPlugin(pluginId); + + const authenticationData = JSON.parse( + result.data.externalAuthenticationUrl.authenticationData + ); + + location.href = authenticationData.authorizationUrl; + } else { + setAuthPlugin(undefined); + } + }; + + const loginByExternalPlugin = async (loginInput: ExternalLoginInput) => { + const input = JSON.stringify(loginInput); + const result = await obtainAccessTokens({ + variables: { input, pluginId: authPlugin } + }); + + if (result && !result.data?.externalObtainAccessTokens?.errors?.length) { + if (!!onLogin) { + onLogin(); + } + } else { + setAuthPlugin(undefined); + } + + return result?.data?.externalObtainAccessTokens; + }; + + const refreshToken = (): Promise => { + if (!!refreshPromise.current) { + return refreshPromise.current; + } + + return new Promise(resolve => { + const token = getTokens().refresh; + const input = JSON.stringify({ + refreshToken: token + }); + + return tokenRefresh({ variables: { input, pluginId: authPlugin } }).then( + refreshData => { + if (!!refreshData.data.externalRefresh?.token) { + setAuthToken(refreshData.data.externalRefresh.token, persistToken); + return resolve(true); + } + + return resolve(false); + } + ); + }); + }; + + return { + autologinPromise, + loginByExternalPlugin, + logout, + requestLoginByExternalPlugin, + tokenAuthLoading: obtainAccessTokensOpts.loading, + tokenRefresh: refreshToken, + tokenVerifyLoading: tokenVerifyOpts.loading, + user: userContext + }; +} diff --git a/src/auth/hooks/useSaleorAuthProvider.ts b/src/auth/hooks/useSaleorAuthProvider.ts new file mode 100644 index 000000000..e671c2e69 --- /dev/null +++ b/src/auth/hooks/useSaleorAuthProvider.ts @@ -0,0 +1,204 @@ +import { DEMO_MODE } from "@saleor/config"; +import { User } from "@saleor/fragments/types/User"; +import { SetLocalStorage } from "@saleor/hooks/useLocalStorage"; +import { commonMessages } from "@saleor/intl"; +import { getMutationStatus } from "@saleor/misc"; +import errorTracker from "@saleor/services/errorTracking"; +import { + isSupported as isCredentialsManagementAPISupported, + login as loginWithCredentialsManagementAPI, + saveCredentials +} from "@saleor/utils/credentialsManagement"; +import { useEffect, useRef, useState } from "react"; +import { useMutation } from "react-apollo"; + +import { + tokenAuthMutation, + tokenRefreshMutation, + tokenVerifyMutation +} from "../mutations"; +import { RefreshToken, RefreshTokenVariables } from "../types/RefreshToken"; +import { + TokenAuth, + TokenAuth_tokenCreate, + TokenAuthVariables +} from "../types/TokenAuth"; +import { VerifyToken, VerifyTokenVariables } from "../types/VerifyToken"; +import { + displayDemoMessage, + getTokens, + removeTokens, + setAuthToken, + setTokens +} from "../utils"; +import { UseAuthProvider, UseAuthProviderOpts } from "./useAuthProvider"; + +export interface UseSaleorAuthProvider extends UseAuthProvider { + login: (username: string, password: string) => Promise; + loginByToken: (auth: string, csrf: string, user: User) => void; +} +export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts { + setAuthPlugin: SetLocalStorage; + authPlugin: string; +} + +const persistToken = false; + +export function useSaleorAuthProvider({ + apolloClient, + authPlugin, + intl, + notify, + setAuthPlugin +}: UseSaleorAuthProviderOpts): UseSaleorAuthProvider { + const [userContext, setUserContext] = useState(undefined); + const autologinPromise = useRef>(); + const refreshPromise = useRef>(); + + useEffect(() => { + const token = getTokens().auth; + if (!authPlugin && !!token && !userContext) { + autologinPromise.current = tokenVerify({ variables: { token } }); + } else if (!authPlugin) { + autologinPromise.current = loginWithCredentialsManagementAPI(login); + } + }, []); + + useEffect(() => { + if (!authPlugin && userContext) { + const { id, email, firstName, lastName } = userContext; + errorTracker.setUserData({ + email, + id, + username: `${firstName} ${lastName}` + }); + + if (!userContext.isStaff) { + logout(); + notify({ + status: "error", + text: intl.formatMessage(commonMessages.unauthorizedDashboardAccess), + title: intl.formatMessage(commonMessages.insufficientPermissions) + }); + } + } + }, [userContext]); + + const logout = () => { + setUserContext(undefined); + if (isCredentialsManagementAPISupported) { + navigator.credentials.preventSilentAccess(); + } + removeTokens(); + }; + + const [tokenAuth, tokenAuthResult] = useMutation< + TokenAuth, + TokenAuthVariables + >(tokenAuthMutation, { + client: apolloClient, + onCompleted: ({ tokenCreate }) => { + if (tokenCreate.errors.length > 0) { + logout(); + } + + const user = tokenCreate.user; + + setUserContext(user); + if (user) { + setTokens(tokenCreate.token, tokenCreate.csrfToken, persistToken); + } + }, + onError: logout + }); + const [tokenRefresh] = useMutation( + tokenRefreshMutation, + { + client: apolloClient, + onError: logout + } + ); + const [tokenVerify, tokenVerifyResult] = useMutation< + VerifyToken, + VerifyTokenVariables + >(tokenVerifyMutation, { + client: apolloClient, + onCompleted: result => { + if (result.tokenVerify === null) { + logout(); + } else { + const user = result.tokenVerify?.user; + + if (!!user) { + setUserContext(user); + } + } + }, + onError: logout + }); + + const tokenAuthOpts = { + ...tokenAuthResult, + status: getMutationStatus(tokenAuthResult) + }; + const tokenVerifyOpts = { + ...tokenVerifyResult, + status: getMutationStatus(tokenVerifyResult) + }; + + const onLogin = () => { + if (DEMO_MODE) { + displayDemoMessage(intl, notify); + } + }; + + const login = async (email: string, password: string) => { + setAuthPlugin(undefined); + const result = await tokenAuth({ variables: { email, password } }); + + if (result && !result.data.tokenCreate.errors.length) { + if (!!onLogin) { + onLogin(); + } + saveCredentials(result.data.tokenCreate.user, password); + } + + return result.data.tokenCreate; + }; + + const loginByToken = (auth: string, refresh: string, user: User) => { + setAuthPlugin(undefined); + setUserContext(user); + setTokens(auth, refresh, persistToken); + }; + + const refreshToken = (): Promise => { + if (!!refreshPromise.current) { + return refreshPromise.current; + } + + return new Promise(resolve => { + const token = getTokens().refresh; + + return tokenRefresh({ variables: { token } }).then(refreshData => { + if (!!refreshData.data.tokenRefresh?.token) { + setAuthToken(refreshData.data.tokenRefresh.token, persistToken); + return resolve(true); + } + + return resolve(false); + }); + }); + }; + + return { + autologinPromise, + login, + loginByToken, + logout, + tokenAuthLoading: tokenAuthOpts.loading, + tokenRefresh: refreshToken, + tokenVerifyLoading: tokenVerifyOpts.loading, + user: userContext + }; +} diff --git a/src/auth/index.tsx b/src/auth/index.tsx index 4865edc02..b46640c16 100644 --- a/src/auth/index.tsx +++ b/src/auth/index.tsx @@ -1,32 +1,57 @@ import { User } from "@saleor/fragments/types/User"; -import React from "react"; -import { Route, Switch } from "react-router-dom"; +import { parse as parseQs } from "qs"; +import React, { MutableRefObject } from "react"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; import Layout from "./components/Layout"; import { + ExternalLoginInput, + RequestExternalLoginInput +} from "./hooks/useExternalAuthProvider"; +import { ExternalObtainAccessTokens_externalObtainAccessTokens } from "./types/ExternalObtainAccessTokens"; +import { TokenAuth_tokenCreate } from "./types/TokenAuth"; +import { + LoginUrlQueryParams, newPasswordPath, passwordResetPath, passwordResetSuccessPath } from "./urls"; -import LoginView from "./views/Login"; +import LoginViewComponent from "./views/Login"; import NewPassword from "./views/NewPassword"; import ResetPassword from "./views/ResetPassword"; import ResetPasswordSuccess from "./views/ResetPasswordSuccess"; +const LoginView: React.FC> = () => { + const qs = parseQs(location.search.substr(1)); + const params: LoginUrlQueryParams = qs; + + return ; +}; + interface UserContext { - login: (username: string, password: string) => void; + login: (username: string, password: string) => Promise; + loginByExternalPlugin: ( + input: ExternalLoginInput + ) => Promise; loginByToken: (auth: string, csrf: string, user: User) => void; logout: () => void; + requestLoginByExternalPlugin: ( + pluginId: string, + input: RequestExternalLoginInput + ) => Promise; tokenAuthLoading: boolean; tokenRefresh: () => Promise; tokenVerifyLoading: boolean; user?: User; + autologinPromise?: MutableRefObject>; } export const UserContext = React.createContext({ login: undefined, + loginByExternalPlugin: undefined, loginByToken: undefined, logout: undefined, + requestLoginByExternalPlugin: undefined, tokenAuthLoading: false, tokenRefresh: undefined, tokenVerifyLoading: false diff --git a/src/auth/mutations.ts b/src/auth/mutations.ts index 8c21ce32d..226c23a37 100644 --- a/src/auth/mutations.ts +++ b/src/auth/mutations.ts @@ -82,3 +82,52 @@ export const SetPasswordMutation = TypedMutation< SetPassword, SetPasswordVariables >(setPassword); + +export const externalAuthenticationUrlMutation = gql` + ${accountErrorFragment} + mutation ExternalAuthenticationUrl($pluginId: String!, $input: JSONString!) { + externalAuthenticationUrl(pluginId: $pluginId, input: $input) { + authenticationData + errors: accountErrors { + ...AccountErrorFragment + } + } + } +`; + +export const externalObtainAccessTokensMutation = gql` + ${accountErrorFragment} + ${fragmentUser} + mutation ExternalObtainAccessTokens($pluginId: String!, $input: JSONString!) { + externalObtainAccessTokens(pluginId: $pluginId, input: $input) { + token + csrfToken + user { + ...User + } + errors: accountErrors { + ...AccountErrorFragment + } + } + } +`; + +export const externalTokenRefreshMutation = gql` + mutation ExternalRefreshToken($pluginId: String!, $input: JSONString!) { + externalRefresh(pluginId: $pluginId, input: $input) { + token + } + } +`; + +export const externalTokenVerifyMutation = gql` + ${fragmentUser} + mutation ExternalVerifyToken($pluginId: String!, $input: JSONString!) { + externalVerify(pluginId: $pluginId, input: $input) { + verifyData + user { + ...User + } + } + } +`; diff --git a/src/auth/queries.ts b/src/auth/queries.ts new file mode 100644 index 000000000..18304273e --- /dev/null +++ b/src/auth/queries.ts @@ -0,0 +1,12 @@ +import gql from "graphql-tag"; + +export const availableExternalAuthentications = gql` + query AvailableExternalAuthentications { + shop { + availableExternalAuthentications { + id + name + } + } + } +`; diff --git a/src/auth/types/AvailableExternalAuthentications.ts b/src/auth/types/AvailableExternalAuthentications.ts new file mode 100644 index 000000000..886830ab0 --- /dev/null +++ b/src/auth/types/AvailableExternalAuthentications.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: AvailableExternalAuthentications +// ==================================================== + +export interface AvailableExternalAuthentications_shop_availableExternalAuthentications { + __typename: "ExternalAuthentication"; + id: string; + name: string | null; +} + +export interface AvailableExternalAuthentications_shop { + __typename: "Shop"; + availableExternalAuthentications: AvailableExternalAuthentications_shop_availableExternalAuthentications[]; +} + +export interface AvailableExternalAuthentications { + shop: AvailableExternalAuthentications_shop; +} diff --git a/src/auth/types/ExternalAuthenticationUrl.ts b/src/auth/types/ExternalAuthenticationUrl.ts new file mode 100644 index 000000000..1fb2fd723 --- /dev/null +++ b/src/auth/types/ExternalAuthenticationUrl.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { AccountErrorCode } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: ExternalAuthenticationUrl +// ==================================================== + +export interface ExternalAuthenticationUrl_externalAuthenticationUrl_errors { + __typename: "AccountError"; + code: AccountErrorCode; + field: string | null; +} + +export interface ExternalAuthenticationUrl_externalAuthenticationUrl { + __typename: "ExternalAuthenticationUrl"; + authenticationData: any | null; + errors: ExternalAuthenticationUrl_externalAuthenticationUrl_errors[]; +} + +export interface ExternalAuthenticationUrl { + externalAuthenticationUrl: ExternalAuthenticationUrl_externalAuthenticationUrl | null; +} + +export interface ExternalAuthenticationUrlVariables { + pluginId: string; + input: any; +} diff --git a/src/auth/types/ExternalObtainAccessTokens.ts b/src/auth/types/ExternalObtainAccessTokens.ts new file mode 100644 index 000000000..8497ab2b8 --- /dev/null +++ b/src/auth/types/ExternalObtainAccessTokens.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { PermissionEnum, AccountErrorCode } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: ExternalObtainAccessTokens +// ==================================================== + +export interface ExternalObtainAccessTokens_externalObtainAccessTokens_user_userPermissions { + __typename: "UserPermission"; + code: PermissionEnum; + name: string; +} + +export interface ExternalObtainAccessTokens_externalObtainAccessTokens_user_avatar { + __typename: "Image"; + url: string; +} + +export interface ExternalObtainAccessTokens_externalObtainAccessTokens_user { + __typename: "User"; + id: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; + userPermissions: (ExternalObtainAccessTokens_externalObtainAccessTokens_user_userPermissions | null)[] | null; + avatar: ExternalObtainAccessTokens_externalObtainAccessTokens_user_avatar | null; +} + +export interface ExternalObtainAccessTokens_externalObtainAccessTokens_errors { + __typename: "AccountError"; + code: AccountErrorCode; + field: string | null; +} + +export interface ExternalObtainAccessTokens_externalObtainAccessTokens { + __typename: "ExternalObtainAccessTokens"; + token: string | null; + csrfToken: string | null; + user: ExternalObtainAccessTokens_externalObtainAccessTokens_user | null; + errors: ExternalObtainAccessTokens_externalObtainAccessTokens_errors[]; +} + +export interface ExternalObtainAccessTokens { + externalObtainAccessTokens: ExternalObtainAccessTokens_externalObtainAccessTokens | null; +} + +export interface ExternalObtainAccessTokensVariables { + pluginId: string; + input: any; +} diff --git a/src/auth/types/ExternalRefreshToken.ts b/src/auth/types/ExternalRefreshToken.ts new file mode 100644 index 000000000..2c2822403 --- /dev/null +++ b/src/auth/types/ExternalRefreshToken.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: ExternalRefreshToken +// ==================================================== + +export interface ExternalRefreshToken_externalRefresh { + __typename: "ExternalRefresh"; + token: string | null; +} + +export interface ExternalRefreshToken { + externalRefresh: ExternalRefreshToken_externalRefresh | null; +} + +export interface ExternalRefreshTokenVariables { + pluginId: string; + input: any; +} diff --git a/src/auth/types/ExternalVerifyToken.ts b/src/auth/types/ExternalVerifyToken.ts new file mode 100644 index 000000000..8e2851aed --- /dev/null +++ b/src/auth/types/ExternalVerifyToken.ts @@ -0,0 +1,46 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +import { PermissionEnum } from "./../../types/globalTypes"; + +// ==================================================== +// GraphQL mutation operation: ExternalVerifyToken +// ==================================================== + +export interface ExternalVerifyToken_externalVerify_user_userPermissions { + __typename: "UserPermission"; + code: PermissionEnum; + name: string; +} + +export interface ExternalVerifyToken_externalVerify_user_avatar { + __typename: "Image"; + url: string; +} + +export interface ExternalVerifyToken_externalVerify_user { + __typename: "User"; + id: string; + email: string; + firstName: string; + lastName: string; + isStaff: boolean; + userPermissions: (ExternalVerifyToken_externalVerify_user_userPermissions | null)[] | null; + avatar: ExternalVerifyToken_externalVerify_user_avatar | null; +} + +export interface ExternalVerifyToken_externalVerify { + __typename: "ExternalVerify"; + verifyData: any | null; + user: ExternalVerifyToken_externalVerify_user | null; +} + +export interface ExternalVerifyToken { + externalVerify: ExternalVerifyToken_externalVerify | null; +} + +export interface ExternalVerifyTokenVariables { + pluginId: string; + input: any; +} diff --git a/src/auth/urls.ts b/src/auth/urls.ts index c0ac7c68b..fc7c39e08 100644 --- a/src/auth/urls.ts +++ b/src/auth/urls.ts @@ -7,9 +7,18 @@ export const passwordResetSuccessPath = "/reset-password/success/"; export const passwordResetSuccessUrl = passwordResetSuccessPath; export const newPasswordPath = "/new-password/"; + +export const loginCallbackPath = "/login/callback/"; + export interface NewPasswordUrlQueryParams { email: string; token: string; } export const newPasswordUrl = (params?: NewPasswordUrlQueryParams) => newPasswordPath + "?" + stringifyQs(params); + +export interface LoginOpenidconnectUrlQueryParams { + code: string; + state: string; +} +export type LoginUrlQueryParams = LoginOpenidconnectUrlQueryParams; diff --git a/src/auth/views/Login.tsx b/src/auth/views/Login.tsx index 70a7a8dda..2f7b9e294 100644 --- a/src/auth/views/Login.tsx +++ b/src/auth/views/Login.tsx @@ -1,20 +1,93 @@ +import { APP_DEFAULT_URI, APP_MOUNT_URI } from "@saleor/config"; import useNavigator from "@saleor/hooks/useNavigator"; import useUser from "@saleor/hooks/useUser"; -import React from "react"; +import React, { useEffect, useState } from "react"; +import { useQuery } from "react-apollo"; +import urlJoin from "url-join"; +import useRouter from "use-react-router"; -import LoginPage, { FormData } from "../components/LoginPage"; -import { passwordResetUrl } from "../urls"; +import LoginPage from "../components/LoginPage"; +import { LoginFormData } from "../components/LoginPage/form"; +import { availableExternalAuthentications } from "../queries"; +import { AvailableExternalAuthentications } from "../types/AvailableExternalAuthentications"; +import { + loginCallbackPath, + LoginUrlQueryParams, + passwordResetUrl +} from "../urls"; -const LoginView: React.FC = () => { +interface LoginViewProps { + params: LoginUrlQueryParams; +} + +const LoginView: React.FC = ({ params }) => { const navigate = useNavigator(); - const { login, user, tokenAuthLoading } = useUser(); + const { location } = useRouter(); + const { + login, + requestLoginByExternalPlugin, + loginByExternalPlugin, + tokenAuthLoading + } = useUser(); + const [isError, setIsError] = useState(false); + const [isExternalError, setIsExternalError] = useState(false); + const { + data: externalAuthentications, + loading: externalAuthenticationsLoading + } = useQuery( + availableExternalAuthentications + ); - const handleSubmit = (data: FormData) => login(data.email, data.password); + const handleSubmit = async (data: LoginFormData) => { + const result = await login(data.email, data.password); + const errors = result?.errors || []; + + setIsExternalError(false); + setIsError(!result || errors?.length > 0); + return errors; + }; + + const handleRequestExternalAuthentication = (pluginId: string) => + requestLoginByExternalPlugin(pluginId, { + redirectUri: urlJoin( + window.location.origin, + APP_MOUNT_URI === APP_DEFAULT_URI ? "" : APP_MOUNT_URI, + loginCallbackPath + ) + }); + + const handleExternalAuthentication = async (code: string, state: string) => { + const result = await loginByExternalPlugin({ code, state }); + const errors = result?.errors || []; + + setIsError(false); + if (!result || errors?.length > 0) { + setIsExternalError(true); + } else { + navigate(APP_DEFAULT_URI); + } + return errors; + }; + + useEffect(() => { + const { code, state } = params; + const isCallbackPath = location.pathname.includes(loginCallbackPath); + + if (code && state && isCallbackPath) { + handleExternalAuthentication(code, state); + } + }, []); return ( navigate(passwordResetUrl)} onSubmit={handleSubmit} /> diff --git a/src/config.ts b/src/config.ts index f36ee4d6c..d28d7d027 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { SearchVariables } from "./hooks/makeSearch"; import { ListSettings, ListViews, Pagination } from "./types"; export const APP_MOUNT_URI = process.env.APP_MOUNT_URI; +export const APP_DEFAULT_URI = "/"; export const API_URI = process.env.API_URI; export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = { diff --git a/src/storybook/UserDecorator.tsx b/src/storybook/UserDecorator.tsx index 744223271..a420d1b10 100644 --- a/src/storybook/UserDecorator.tsx +++ b/src/storybook/UserDecorator.tsx @@ -6,8 +6,10 @@ export const UserDecorator = (user: User) => storyFn => ( +
+ +
+
+
+ Forgot password? + + Use this link to recover it + +
+
+
+
+
+ or login using +
+
+ + +
+
+
+`; + +exports[`Storyshots Views / Authentication / Log in disabled 1`] = ` +
+
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
@@ -36879,10 +37038,45 @@ exports[`Storyshots Views / Authentication / Log in default 1`] = ` class="FormSpacer-spacer-id" /> +
+
+
+
+ or login using +
+
+
@@ -36996,10 +37190,9 @@ exports[`Storyshots Views / Authentication / Log in error 1`] = ` class="LoginCard-buttonContainer-id" >
@@ -37034,114 +37261,29 @@ exports[`Storyshots Views / Authentication / Log in loading 1`] = `
-
+
- -
- - -
+
-
-
- -
- - -
-
-
-
- -
-
- - +
diff --git a/src/storybook/config.js b/src/storybook/config.js index be8d805e8..daaff30c3 100644 --- a/src/storybook/config.js +++ b/src/storybook/config.js @@ -44,10 +44,6 @@ function loadStories() { require("./stories/components/WeightRange"); require("./stories/components/messages"); - // Authentication - require("./stories/auth/LoginPage"); - require("./stories/auth/LoginLoading"); - // Attributes require("./stories/attributes/AttributeBulkDeleteDialog"); require("./stories/attributes/AttributeDeleteDialog");