From a5e72c456e84b6187166dfa4891b81c0f1d0db9e Mon Sep 17 00:00:00 2001 From: dominik-zeglen Date: Thu, 7 May 2020 13:04:15 +0200 Subject: [PATCH 1/3] Handle session expiration --- src/auth/AuthProvider.tsx | 36 ++++++++++++++++++++++++++++++---- src/auth/errors.ts | 15 ++++++++++++++ src/auth/index.tsx | 2 ++ src/auth/mutations.ts | 14 +++++++++++++ src/auth/types/RefreshToken.ts | 21 ++++++++++++++++++++ src/hooks/makeMutation.ts | 9 +++++++++ src/hooks/makeQuery.ts | 11 ++++++++++- src/index.tsx | 6 +++++- src/intl.ts | 3 +++ src/mutations.tsx | 8 ++++++++ src/queries.tsx | 14 ++++++++++--- 11 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 src/auth/errors.ts create mode 100644 src/auth/types/RefreshToken.ts diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx index fdb809cd8..a99fc03e1 100644 --- a/src/auth/AuthProvider.tsx +++ b/src/auth/AuthProvider.tsx @@ -7,11 +7,16 @@ import { } from "@saleor/utils/credentialsManagement"; import { MutationFunction, MutationResult } from "react-apollo"; import { maybe } from "@saleor/misc"; -import { TypedTokenAuthMutation, TypedVerifyTokenMutation } from "./mutations"; +import { + TypedTokenAuthMutation, + TypedVerifyTokenMutation, + TokenRefreshMutation +} from "./mutations"; import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth"; import { User } from "./types/User"; import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken"; import { getAuthToken, removeAuthToken, setAuthToken } from "./utils"; +import { RefreshToken, RefreshTokenVariables } from "./types/RefreshToken"; import { UserContext } from "./"; interface AuthProviderOperationsProps { @@ -30,9 +35,17 @@ const AuthProviderOperations: React.FC = ({ {(...tokenAuth) => ( {(...tokenVerify) => ( - - {children} - + + {(...tokenRefresh) => ( + + {children} + + )} + )} )} @@ -55,6 +68,10 @@ interface AuthProviderProps { MutationFunction, MutationResult ]; + tokenRefresh: [ + MutationFunction, + MutationResult + ]; } interface AuthProviderState { @@ -143,6 +160,16 @@ class AuthProvider extends React.Component< return tokenVerifyFn({ variables: { token } }); }; + refreshToken = async () => { + const { tokenRefresh } = this.props; + const [tokenRefreshFn] = tokenRefresh; + const token = getAuthToken(); + + const refreshData = await tokenRefreshFn({ variables: { token } }); + + setAuthToken(refreshData.data.tokenRefresh.token, this.state.persistToken); + }; + render() { const { children, tokenAuth, tokenVerify } = this.props; const tokenAuthOpts = tokenAuth[1]; @@ -157,6 +184,7 @@ class AuthProvider extends React.Component< loginByToken: this.loginByToken, logout: this.logout, tokenAuthLoading: tokenAuthOpts.loading, + tokenRefresh: this.refreshToken, tokenVerifyLoading: tokenVerifyOpts.loading, user }} diff --git a/src/auth/errors.ts b/src/auth/errors.ts new file mode 100644 index 000000000..61808e3b2 --- /dev/null +++ b/src/auth/errors.ts @@ -0,0 +1,15 @@ +import { GraphQLError } from "graphql"; +import { findValueInEnum } from "@saleor/misc"; + +export enum JWTError { + invalid = "JSONWebTokenError", + expired = "JSONWebTokenExpired" +} + +export function isJwtError(error: GraphQLError): boolean { + return !!findValueInEnum(error.extensions.exception.code, JWTError); +} + +export function isTokenExpired(error: GraphQLError): boolean { + return error.extensions.exception.code === JWTError.expired; +} diff --git a/src/auth/index.tsx b/src/auth/index.tsx index e57a0159b..7eecf89d1 100644 --- a/src/auth/index.tsx +++ b/src/auth/index.tsx @@ -19,6 +19,7 @@ interface UserContext { loginByToken: (token: string, user: User) => void; logout: () => void; tokenAuthLoading: boolean; + tokenRefresh: () => Promise; tokenVerifyLoading: boolean; user?: User; } @@ -28,6 +29,7 @@ export const UserContext = React.createContext({ loginByToken: undefined, logout: undefined, tokenAuthLoading: false, + tokenRefresh: undefined, tokenVerifyLoading: false }); diff --git a/src/auth/mutations.ts b/src/auth/mutations.ts index e5480427b..ef6152076 100644 --- a/src/auth/mutations.ts +++ b/src/auth/mutations.ts @@ -9,6 +9,7 @@ import { import { SetPassword, SetPasswordVariables } from "./types/SetPassword"; import { TokenAuth, TokenAuthVariables } from "./types/TokenAuth"; import { VerifyToken, VerifyTokenVariables } from "./types/VerifyToken"; +import { RefreshToken, RefreshTokenVariables } from "./types/RefreshToken"; export const fragmentUser = gql` fragment User on User { @@ -98,3 +99,16 @@ export const SetPasswordMutation = TypedMutation< SetPassword, SetPasswordVariables >(setPassword); + +const refreshToken = gql` + mutation RefreshToken($token: String!) { + tokenRefresh(token: $token) { + token + payload + } + } +`; +export const TokenRefreshMutation = TypedMutation< + RefreshToken, + RefreshTokenVariables +>(refreshToken); diff --git a/src/auth/types/RefreshToken.ts b/src/auth/types/RefreshToken.ts new file mode 100644 index 000000000..cbd3679b9 --- /dev/null +++ b/src/auth/types/RefreshToken.ts @@ -0,0 +1,21 @@ +/* tslint:disable */ +/* eslint-disable */ +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: RefreshToken +// ==================================================== + +export interface RefreshToken_tokenRefresh { + __typename: "RefreshToken"; + token: string | null; + payload: any | null; +} + +export interface RefreshToken { + tokenRefresh: RefreshToken_tokenRefresh | null; +} + +export interface RefreshTokenVariables { + token: string; +} diff --git a/src/hooks/makeMutation.ts b/src/hooks/makeMutation.ts index 52f75eeb4..1bda2b926 100644 --- a/src/hooks/makeMutation.ts +++ b/src/hooks/makeMutation.ts @@ -10,7 +10,9 @@ import { useIntl } from "react-intl"; import { commonMessages } from "@saleor/intl"; import { maybe, getMutationStatus } from "@saleor/misc"; import { MutationResultAdditionalProps } from "@saleor/types"; +import { isJwtError } from "@saleor/auth/errors"; import useNotifier from "./useNotifier"; +import useUser from "./useUser"; export type UseMutation = [ MutationFunction, @@ -33,6 +35,8 @@ function makeMutation( }: UseMutationCbs): UseMutation { const notify = useNotifier(); const intl = useIntl(); + const user = useUser(); + const [mutateFn, result] = useBaseMutation(mutation, { onCompleted, onError: (err: ApolloError) => { @@ -46,6 +50,11 @@ function makeMutation( notify({ text: intl.formatMessage(commonMessages.readOnly) }); + } else if (err.graphQLErrors.every(isJwtError)) { + user.logout(); + notify({ + text: intl.formatMessage(commonMessages.sessionExpired) + }); } else { notify({ text: intl.formatMessage(commonMessages.somethingWentWrong) diff --git a/src/hooks/makeQuery.ts b/src/hooks/makeQuery.ts index 3cc28a19b..e5ff1703c 100644 --- a/src/hooks/makeQuery.ts +++ b/src/hooks/makeQuery.ts @@ -6,8 +6,10 @@ import { useIntl } from "react-intl"; import { commonMessages } from "@saleor/intl"; import { maybe, RequireAtLeastOne } from "@saleor/misc"; +import { isJwtError } from "@saleor/auth/errors"; import useAppState from "./useAppState"; import useNotifier from "./useNotifier"; +import useUser from "./useUser"; export interface LoadMore { loadMore: ( @@ -38,6 +40,8 @@ function makeQuery( const notify = useNotifier(); const intl = useIntl(); const [, dispatchAppState] = useAppState(); + const user = useUser(); + const queryData = useBaseQuery(query, { context: { useBatching: true @@ -60,7 +64,12 @@ function makeQuery( }, [queryData.loading]); if (queryData.error) { - if ( + if (queryData.error.graphQLErrors.every(isJwtError)) { + user.logout(); + notify({ + text: intl.formatMessage(commonMessages.sessionExpired) + }); + } else if ( !queryData.error.graphQLErrors.every( err => maybe(() => err.extensions.exception.code) === "PermissionDenied" diff --git a/src/index.tsx b/src/index.tsx index 12279e63b..2a60c75cb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import useAppState from "@saleor/hooks/useAppState"; import AttributeSection from "./attributes"; import { attributeSection } from "./attributes/urls"; import Auth, { getAuthToken, removeAuthToken } from "./auth"; +import { isJwtError } from "./auth/errors"; import AuthProvider from "./auth/AuthProvider"; import LoginLoading from "./auth/components/LoginLoading/LoginLoading"; import SectionRoute from "./auth/components/SectionRoute"; @@ -66,7 +67,10 @@ interface ResponseError extends ErrorResponse { } const invalidTokenLink = onError((error: ResponseError) => { - if (error.networkError && error.networkError.statusCode === 401) { + if ( + (error.networkError && error.networkError.statusCode === 401) || + error.graphQLErrors?.some(isJwtError) + ) { removeAuthToken(); } }); diff --git a/src/intl.ts b/src/intl.ts index 458961851..479b630dc 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -56,6 +56,9 @@ export const commonMessages = defineMessages({ savedChanges: { defaultMessage: "Saved changes" }, + sessionExpired: { + defaultMessage: "Your session has expired. Please log in again to continue." + }, somethingWentWrong: { defaultMessage: "Saleor ran into an unexpected problem" }, diff --git a/src/mutations.tsx b/src/mutations.tsx index c829e8410..727a9d8e0 100644 --- a/src/mutations.tsx +++ b/src/mutations.tsx @@ -8,6 +8,8 @@ import useNotifier from "./hooks/useNotifier"; import { commonMessages } from "./intl"; import { maybe, getMutationStatus } from "./misc"; import { MutationResultAdditionalProps } from "./types"; +import { isJwtError } from "./auth/errors"; +import useUser from "./hooks/useUser"; export interface TypedMutationInnerProps { children: ( @@ -27,6 +29,7 @@ export function TypedMutation( return (props: TypedMutationInnerProps) => { const notify = useNotifier(); const intl = useIntl(); + const user = useUser(); const { children, onCompleted, onError, variables } = props; return ( @@ -44,6 +47,11 @@ export function TypedMutation( notify({ text: intl.formatMessage(commonMessages.readOnly) }); + } else if (err.graphQLErrors.every(isJwtError)) { + user.logout(); + notify({ + text: intl.formatMessage(commonMessages.sessionExpired) + }); } else { notify({ text: intl.formatMessage(commonMessages.somethingWentWrong) diff --git a/src/queries.tsx b/src/queries.tsx index abe3e8bc0..952c943d8 100644 --- a/src/queries.tsx +++ b/src/queries.tsx @@ -9,6 +9,8 @@ import useAppState from "./hooks/useAppState"; import useNotifier from "./hooks/useNotifier"; import { commonMessages } from "./intl"; import { maybe, RequireAtLeastOne } from "./misc"; +import { isJwtError } from "./auth/errors"; +import useUser from "./hooks/useUser"; export interface LoadMore { loadMore: ( @@ -65,9 +67,10 @@ export function TypedQuery( query: DocumentNode ): React.FC> { return ({ children, displayLoader, skip, variables }) => { - const pushMessage = useNotifier(); + const notify = useNotifier(); const [, dispatchAppState] = useAppState(); const intl = useIntl(); + const user = useUser(); return ( ( > {(queryData: QueryResult) => { if (queryData.error) { - if ( + if (queryData.error.graphQLErrors.every(isJwtError)) { + user.logout(); + notify({ + text: intl.formatMessage(commonMessages.sessionExpired) + }); + } else if ( !queryData.error.graphQLErrors.every( err => maybe(() => err.extensions.exception.code) === "PermissionDenied" ) ) { - pushMessage({ + notify({ text: intl.formatMessage(commonMessages.somethingWentWrong) }); } From 170130eb21067a3845b44371285f01b40cfee61b Mon Sep 17 00:00:00 2001 From: dominik-zeglen Date: Thu, 7 May 2020 13:26:20 +0200 Subject: [PATCH 2/3] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d8bd133..179d1e69e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ All notable, unreleased changes to this project will be documented in this file. - Add Permission Groups section - #406 by @krzysztofwolski - Add warehouse management - #390 by @dominik-zeglen - Fix minor visual bugs - #521 by @dominik-zeglen +- Handle session expiration - #520 by @dominik-zeglen ## 2.0.0 From 375d038242a3e97aea400b0646033f4c29631b7f Mon Sep 17 00:00:00 2001 From: dominik-zeglen Date: Thu, 7 May 2020 13:28:07 +0200 Subject: [PATCH 3/3] Update messages --- locale/defaultMessages.json | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 9f56ff639..f1d38d88a 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3621,17 +3621,14 @@ "context": "header", "string": "Quantity" }, - "src_dot_products_dot_components_dot_ProductStocks_dot_3143542809": { - "string": "This product doesn't have any stock. You can add it here." - }, - "src_dot_products_dot_components_dot_ProductStocks_dot_3482985373": { - "context": "button", - "string": "Edit Warehouses" - }, "src_dot_products_dot_components_dot_ProductStocks_dot_3633706025": { "context": "product inventory, checkbox", "string": "Track Inventory" }, + "src_dot_products_dot_components_dot_ProductStocks_dot_3857433664": { + "context": "button", + "string": "Assign Warehouse" + }, "src_dot_products_dot_components_dot_ProductStocks_dot_849869830": { "string": "Active inventory tracking will automatically calculate changes of stock" }, @@ -3859,13 +3856,6 @@ "context": "product variant name", "string": "Variant" }, - "src_dot_products_dot_components_dot_ProductWarehousesDialog_dot_3482985373": { - "context": "dialog header", - "string": "Edit Warehouses" - }, - "src_dot_products_dot_components_dot_ProductWarehousesDialog_dot_4112406382": { - "string": "Select warehouses that stock selected product" - }, "src_dot_products_dot_views_dot_1542417144": { "context": "window title", "string": "Create Product" @@ -4147,6 +4137,9 @@ "context": "window title", "string": "Create Service Account" }, + "src_dot_sessionExpired": { + "string": "Your session has expired. Please log in again to continue." + }, "src_dot_shipping": { "context": "shipping section name", "string": "Shipping Methods"