Merge pull request #963 from mirumee/SALEOR-1301-oauth-openid-flow

1301 - OAuth2 flow with OpenID
This commit is contained in:
Maciej Korycinski 2021-02-02 08:58:50 +01:00 committed by GitHub
commit 836fc6dce4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1391 additions and 378 deletions

View file

@ -88,7 +88,7 @@ jobs:
aws s3 sync ./build/storybook s3://${{ secrets.AWS_TEST_DEPLOYMENT_BUCKET }}/${{ steps.set-domain.outputs.domain }}/storybook aws s3 sync ./build/storybook s3://${{ secrets.AWS_TEST_DEPLOYMENT_BUCKET }}/${{ steps.set-domain.outputs.domain }}/storybook
- name: Invalidate cache - 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 - name: Update deployment status
uses: bobheadxi/deployments@v0.4.2 uses: bobheadxi/deployments@v0.4.2

View file

@ -18,6 +18,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Add product reference attributes - #948 by @orzechdev - Add product reference attributes - #948 by @orzechdev
- Drop descriptionJson and contentJson fields - #950 by @jwm0 - Drop descriptionJson and contentJson fields - #950 by @jwm0
- Add error tracking with Sentry adapter - #956 by @jwm0 - Add error tracking with Sentry adapter - #956 by @jwm0
- Add OAuth2 login with OpenID support - #963 by @orzechdev
# 2.11.1 # 2.11.1

View file

@ -1016,12 +1016,23 @@
"src_dot_auth_dot_components_dot_LoginPage_dot_2237029987": { "src_dot_auth_dot_components_dot_LoginPage_dot_2237029987": {
"string": "Password" "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": { "src_dot_auth_dot_components_dot_LoginPage_dot_3476994590": {
"string": "Sorry, your username and/or password are incorrect. Please try again." "string": "Sorry, your username and/or password are incorrect. Please try again."
}, },
"src_dot_auth_dot_components_dot_LoginPage_dot_4028609483": { "src_dot_auth_dot_components_dot_LoginPage_dot_3762459576": {
"context": "button", "context": "description",
"string": "Reset your password" "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": { "src_dot_auth_dot_components_dot_NewPasswordPage_dot_1254879564": {
"string": "New Password" "string": "New Password"

View file

@ -1943,6 +1943,49 @@ enum ExportScope {
FILTER 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 { type File {
url: String! url: String!
contentType: String contentType: String
@ -2765,6 +2808,11 @@ type Mutation {
tokenRefresh(csrfToken: String, refreshToken: String): RefreshToken tokenRefresh(csrfToken: String, refreshToken: String): RefreshToken
tokenVerify(token: String!): VerifyToken tokenVerify(token: String!): VerifyToken
tokensDeactivateAll: DeactivateAllUserTokens 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 requestPasswordReset(email: String!, redirectUrl: String!): RequestPasswordReset
confirmAccount(email: String!, token: String!): ConfirmAccount confirmAccount(email: String!, token: String!): ConfirmAccount
setPassword(email: String!, password: String!, token: String!): SetPassword setPassword(email: String!, password: String!, token: String!): SetPassword
@ -4906,6 +4954,7 @@ input ShippingZoneUpdateInput {
type Shop { type Shop {
availablePaymentGateways(currency: String): [PaymentGateway!]! availablePaymentGateways(currency: String): [PaymentGateway!]!
availableExternalAuthentications: [ExternalAuthentication!]!
availableShippingMethods(channel: String!, address: AddressInput): [ShippingMethod] availableShippingMethods(channel: String!, address: AddressInput): [ShippingMethod]
geolocalization: Geolocalization geolocalization: Geolocalization
countries(languageCode: LanguageCodeEnum): [CountryDisplay!]! countries(languageCode: LanguageCodeEnum): [CountryDisplay!]!

View file

@ -2,7 +2,7 @@ import setupApi from "@test/api";
import { act, renderHook } from "@testing-library/react-hooks"; import { act, renderHook } from "@testing-library/react-hooks";
import ApolloClient from "apollo-client"; import ApolloClient from "apollo-client";
import { useAuthProvider } from "./AuthProvider"; import { useAuthProvider } from "./hooks/useAuthProvider";
import { getTokens, setAuthToken } from "./utils"; import { getTokens, setAuthToken } from "./utils";
const apolloClient = setupApi(); const apolloClient = setupApi();
@ -14,7 +14,7 @@ function renderAuthProvider(apolloClient: ApolloClient<any>) {
const notify = jest.fn(); const notify = jest.fn();
const { result } = renderHook(() => const { result } = renderHook(() =>
useAuthProvider(intl as any, notify, apolloClient) useAuthProvider({ apolloClient, intl: intl as any, notify })
); );
return result; return result;
@ -43,7 +43,7 @@ describe("User", () => {
await act(() => await act(() =>
hook.current.login(adminCredentials.email, adminCredentials.password) 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; adminCredentials.token = getTokens().auth;
done(); done();
@ -55,7 +55,7 @@ describe("User", () => {
await act(() => await act(() =>
hook.current.login(adminCredentials.email, "NotAValidPassword123!") hook.current.login(adminCredentials.email, "NotAValidPassword123!")
); );
expect(hook.current.userContext).toBe(null); expect(hook.current.user).toBe(null);
done(); done();
}); });
@ -69,7 +69,7 @@ describe("User", () => {
nonStaffUserCredentials.password nonStaffUserCredentials.password
) )
); );
expect(hook.current.userContext).toBe(undefined); expect(hook.current.user).toBe(undefined);
done(); done();
}); });
@ -79,7 +79,7 @@ describe("User", () => {
const hook = renderAuthProvider(apolloClient); const hook = renderAuthProvider(apolloClient);
await act(() => hook.current.autologinPromise.current); await act(() => hook.current.autologinPromise.current);
expect(hook.current.userContext.email).toBe(adminCredentials.email); expect(hook.current.user.email).toBe(adminCredentials.email);
done(); done();
}); });
@ -89,7 +89,7 @@ describe("User", () => {
const hook = renderAuthProvider(apolloClient); const hook = renderAuthProvider(apolloClient);
await act(() => hook.current.autologinPromise.current); await act(() => hook.current.autologinPromise.current);
expect(hook.current.userContext).toBe(undefined); expect(hook.current.user).toBe(undefined);
done(); done();
}); });

View file

@ -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 useNotifier from "@saleor/hooks/useNotifier";
import { commonMessages } from "@saleor/intl"; import React, { useContext } from "react";
import { getMutationStatus } from "@saleor/misc"; import { useApolloClient } from "react-apollo";
import errorTracker from "@saleor/services/errorTracking"; import { useIntl } from "react-intl";
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 { UserContext } from "./"; import { UserContext } from "./";
import { import { useAuthProvider } from "./hooks/useAuthProvider";
tokenAuthMutation, import { getTokens } from "./utils";
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<any>
) {
const [userContext, setUserContext] = useState<undefined | User>(undefined);
const autologinPromise = useRef<Promise<any>>();
const refreshPromise = useRef<Promise<boolean>>();
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<RefreshToken, RefreshTokenVariables>(
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<boolean> => {
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
};
}
interface AuthProviderProps { interface AuthProviderProps {
children: React.ReactNode; children: React.ReactNode;
@ -202,30 +16,10 @@ const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const intl = useIntl(); const intl = useIntl();
const notify = useNotifier(); const notify = useNotifier();
const { const authProvider = useAuthProvider({ apolloClient, intl, notify });
login,
loginByToken,
logout,
tokenAuthOpts,
refreshToken,
tokenVerifyOpts,
userContext
} = useAuthProvider(intl, notify, apolloClient);
return ( return (
<UserContext.Provider <UserContext.Provider value={authProvider}>{children}</UserContext.Provider>
value={{
login,
loginByToken,
logout,
tokenAuthLoading: tokenAuthOpts.loading,
tokenRefresh: refreshToken,
tokenVerifyLoading: tokenVerifyOpts.loading,
user: userContext
}}
>
{children}
</UserContext.Provider>
); );
}; };

View file

@ -1,8 +1,8 @@
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react"; import { storiesOf } from "@storybook/react";
import React from "react"; import React from "react";
import LoginLoading from "../../../auth/components/LoginLoading"; import LoginLoading from ".";
import Decorator from "../../Decorator";
storiesOf("Views / Authentication / Verifying remembered user", module) storiesOf("Views / Authentication / Verifying remembered user", module)
.addDecorator(Decorator) .addDecorator(Decorator)

View file

@ -1,14 +1,24 @@
import { Omit } from "@material-ui/core"; import { Omit } from "@material-ui/core";
import CardDecorator from "@saleor/storybook/CardDecorator";
import Decorator from "@saleor/storybook/Decorator";
import { storiesOf } from "@storybook/react"; import { storiesOf } from "@storybook/react";
import React from "react"; import React from "react";
import LoginPage, { LoginCardProps } from "../../../auth/components/LoginPage"; import LoginPage, { LoginCardProps } from "../../../auth/components/LoginPage";
import CardDecorator from "../../CardDecorator";
import Decorator from "../../Decorator";
const props: Omit<LoginCardProps, "classes"> = { const props: Omit<LoginCardProps, "classes"> = {
disableLoginButton: true, disabled: false,
error: false, error: false,
externalAuthentications: [
{
__typename: "ExternalAuthentication",
id: "auth.plugin.example",
name: "Example auth plugin"
}
],
externalError: false,
loading: false,
onExternalAuthentication: () => undefined,
onPasswordRecovery: undefined, onPasswordRecovery: undefined,
onSubmit: () => undefined onSubmit: () => undefined
}; };
@ -18,4 +28,5 @@ storiesOf("Views / Authentication / Log in", module)
.addDecorator(Decorator) .addDecorator(Decorator)
.add("default", () => <LoginPage {...props} />) .add("default", () => <LoginPage {...props} />)
.add("error", () => <LoginPage {...props} error={true} />) .add("error", () => <LoginPage {...props} error={true} />)
.add("loading", () => <LoginPage {...props} disableLoginButton={true} />); .add("disabled", () => <LoginPage {...props} disabled={true} />)
.add("loading", () => <LoginPage {...props} loading={true} />);

View file

@ -1,18 +1,17 @@
import Button from "@material-ui/core/Button"; 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 { makeStyles } from "@material-ui/core/styles";
import TextField from "@material-ui/core/TextField"; import TextField from "@material-ui/core/TextField";
import Typography from "@material-ui/core/Typography"; 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 { FormSpacer } from "@saleor/components/FormSpacer";
import { DEMO_MODE } from "@saleor/config"; import { SubmitPromise } from "@saleor/hooks/useForm";
import { commonMessages } from "@saleor/intl"; import { commonMessages } from "@saleor/intl";
import React from "react"; import React from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
export interface FormData { import LoginForm, { LoginFormData } from "./form";
email: string;
password: string;
}
const useStyles = makeStyles( const useStyles = makeStyles(
theme => ({ theme => ({
@ -23,7 +22,13 @@ const useStyles = makeStyles(
link: { link: {
color: theme.palette.primary.main, color: theme.palette.primary.main,
cursor: "pointer", cursor: "pointer",
textAlign: "center" textDecoration: "underline"
},
loading: {
alignItems: "center",
display: "flex",
height: "100vh",
justifyContent: "center"
}, },
loginButton: { loginButton: {
width: 140 width: 140
@ -43,27 +48,40 @@ const useStyles = makeStyles(
export interface LoginCardProps { export interface LoginCardProps {
error: boolean; error: boolean;
disableLoginButton: boolean; externalError: boolean;
disabled: boolean;
loading: boolean;
externalAuthentications?: AvailableExternalAuthentications_shop_availableExternalAuthentications[];
onExternalAuthentication: (pluginId: string) => void;
onPasswordRecovery: () => void; onPasswordRecovery: () => void;
onSubmit?(event: FormData); onSubmit?: (event: LoginFormData) => SubmitPromise;
} }
const LoginCard: React.FC<LoginCardProps> = props => { const LoginCard: React.FC<LoginCardProps> = props => {
const { error, disableLoginButton, onPasswordRecovery, onSubmit } = props; const {
error,
externalError,
disabled,
loading,
externalAuthentications = [],
onExternalAuthentication,
onPasswordRecovery,
onSubmit
} = props;
const classes = useStyles(props); const classes = useStyles(props);
const intl = useIntl(); const intl = useIntl();
let initialFormData = { email: "", password: "" }; if (loading) {
if (DEMO_MODE) { return (
initialFormData = { <div className={classes.loading}>
email: "admin@example.com", <CircularProgress size={128} />
password: "admin" </div>
}; );
} }
return ( return (
<Form initial={initialFormData} onSubmit={onSubmit}> <LoginForm onSubmit={onSubmit}>
{({ change: handleChange, data, submit: handleSubmit }) => ( {({ change: handleChange, data, submit: handleSubmit }) => (
<> <>
{error && ( {error && (
@ -73,6 +91,13 @@ const LoginCard: React.FC<LoginCardProps> = props => {
</Typography> </Typography>
</div> </div>
)} )}
{externalError && (
<div className={classes.panel} data-test="loginErrorMessage">
<Typography variant="caption">
<FormattedMessage defaultMessage="Sorry, login went wrong. Please try again." />
</Typography>
</div>
)}
<TextField <TextField
autoFocus autoFocus
fullWidth fullWidth
@ -84,6 +109,7 @@ const LoginCard: React.FC<LoginCardProps> = props => {
inputProps={{ inputProps={{
"data-test": "email" "data-test": "email"
}} }}
disabled={disabled}
/> />
<FormSpacer /> <FormSpacer />
<TextField <TextField
@ -99,13 +125,14 @@ const LoginCard: React.FC<LoginCardProps> = props => {
inputProps={{ inputProps={{
"data-test": "password" "data-test": "password"
}} }}
disabled={disabled}
/> />
<FormSpacer /> <FormSpacer />
<div className={classes.buttonContainer}> <div className={classes.buttonContainer}>
<Button <Button
className={classes.loginButton} className={classes.loginButton}
color="primary" color="primary"
disabled={disableLoginButton} disabled={disabled}
variant="contained" variant="contained"
onClick={handleSubmit} onClick={handleSubmit}
type="submit" type="submit"
@ -115,15 +142,56 @@ const LoginCard: React.FC<LoginCardProps> = props => {
</Button> </Button>
</div> </div>
<FormSpacer /> <FormSpacer />
<Typography className={classes.link} onClick={onPasswordRecovery}> <Typography>
<FormattedMessage <FormattedMessage
defaultMessage="Reset your password" defaultMessage="Forgot password? {resetPasswordLink}"
description="button" description="description"
values={{
resetPasswordLink: (
<a className={classes.link} onClick={onPasswordRecovery}>
<FormattedMessage
defaultMessage="Use this link to recover it"
description="link"
/>
</a>
)
}}
/> />
</Typography> </Typography>
{externalAuthentications.length > 0 && (
<>
<FormSpacer />
<Divider />
<FormSpacer />
<Typography>
<FormattedMessage
defaultMessage="or login using"
description="description"
/>
</Typography>
</>
)}
{externalAuthentications.map(externalAuthentication => (
<React.Fragment key={externalAuthentication.id}>
<FormSpacer />
<Button
color="primary"
fullWidth
variant="outlined"
size="large"
onClick={() =>
onExternalAuthentication(externalAuthentication.id)
}
data-test="external-authentication"
disabled={disabled}
>
{externalAuthentication.name}
</Button>
</React.Fragment>
))}
</> </>
)} )}
</Form> </LoginForm>
); );
}; };
LoginCard.displayName = "LoginCard"; LoginCard.displayName = "LoginCard";

View file

@ -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<boolean>;
}
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<LoginFormProps> = ({ children, onSubmit }) => {
const props = useLoginForm(onSubmit);
return <form onSubmit={props.submit}>{children(props)}</form>;
};
LoginForm.displayName = "LoginForm";
export default LoginForm;

View file

@ -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<boolean>;
tokenVerifyLoading: boolean;
user?: User;
autologinPromise?: MutableRefObject<Promise<any>>;
}
export interface UseAuthProviderOpts {
intl: IntlShape;
notify: IMessageContext;
apolloClient: ApolloClient<any>;
}
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
};
}

View file

@ -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<void>;
loginByExternalPlugin: (
input: ExternalLoginInput
) => Promise<ExternalObtainAccessTokens_externalObtainAccessTokens>;
}
export interface UseExternalAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>;
authPlugin: string;
}
const persistToken = false;
export function useExternalAuthProvider({
apolloClient,
authPlugin,
intl,
notify,
setAuthPlugin
}: UseExternalAuthProviderOpts): UseExternalAuthProvider {
const [userContext, setUserContext] = useState<undefined | User>(undefined);
const autologinPromise = useRef<Promise<any>>();
const refreshPromise = useRef<Promise<boolean>>();
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<boolean> => {
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
};
}

View file

@ -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<TokenAuth_tokenCreate>;
loginByToken: (auth: string, csrf: string, user: User) => void;
}
export interface UseSaleorAuthProviderOpts extends UseAuthProviderOpts {
setAuthPlugin: SetLocalStorage<any>;
authPlugin: string;
}
const persistToken = false;
export function useSaleorAuthProvider({
apolloClient,
authPlugin,
intl,
notify,
setAuthPlugin
}: UseSaleorAuthProviderOpts): UseSaleorAuthProvider {
const [userContext, setUserContext] = useState<undefined | User>(undefined);
const autologinPromise = useRef<Promise<any>>();
const refreshPromise = useRef<Promise<boolean>>();
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<RefreshToken, RefreshTokenVariables>(
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<boolean> => {
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
};
}

View file

@ -1,32 +1,57 @@
import { User } from "@saleor/fragments/types/User"; import { User } from "@saleor/fragments/types/User";
import React from "react"; import { parse as parseQs } from "qs";
import { Route, Switch } from "react-router-dom"; import React, { MutableRefObject } from "react";
import { Route, RouteComponentProps, Switch } from "react-router-dom";
import Layout from "./components/Layout"; import Layout from "./components/Layout";
import { import {
ExternalLoginInput,
RequestExternalLoginInput
} from "./hooks/useExternalAuthProvider";
import { ExternalObtainAccessTokens_externalObtainAccessTokens } from "./types/ExternalObtainAccessTokens";
import { TokenAuth_tokenCreate } from "./types/TokenAuth";
import {
LoginUrlQueryParams,
newPasswordPath, newPasswordPath,
passwordResetPath, passwordResetPath,
passwordResetSuccessPath passwordResetSuccessPath
} from "./urls"; } from "./urls";
import LoginView from "./views/Login"; import LoginViewComponent from "./views/Login";
import NewPassword from "./views/NewPassword"; import NewPassword from "./views/NewPassword";
import ResetPassword from "./views/ResetPassword"; import ResetPassword from "./views/ResetPassword";
import ResetPasswordSuccess from "./views/ResetPasswordSuccess"; import ResetPasswordSuccess from "./views/ResetPasswordSuccess";
const LoginView: React.FC<RouteComponentProps<any>> = () => {
const qs = parseQs(location.search.substr(1));
const params: LoginUrlQueryParams = qs;
return <LoginViewComponent params={params} />;
};
interface UserContext { interface UserContext {
login: (username: string, password: string) => void; login: (username: string, password: string) => Promise<TokenAuth_tokenCreate>;
loginByExternalPlugin: (
input: ExternalLoginInput
) => Promise<ExternalObtainAccessTokens_externalObtainAccessTokens>;
loginByToken: (auth: string, csrf: string, user: User) => void; loginByToken: (auth: string, csrf: string, user: User) => void;
logout: () => void; logout: () => void;
requestLoginByExternalPlugin: (
pluginId: string,
input: RequestExternalLoginInput
) => Promise<void>;
tokenAuthLoading: boolean; tokenAuthLoading: boolean;
tokenRefresh: () => Promise<boolean>; tokenRefresh: () => Promise<boolean>;
tokenVerifyLoading: boolean; tokenVerifyLoading: boolean;
user?: User; user?: User;
autologinPromise?: MutableRefObject<Promise<any>>;
} }
export const UserContext = React.createContext<UserContext>({ export const UserContext = React.createContext<UserContext>({
login: undefined, login: undefined,
loginByExternalPlugin: undefined,
loginByToken: undefined, loginByToken: undefined,
logout: undefined, logout: undefined,
requestLoginByExternalPlugin: undefined,
tokenAuthLoading: false, tokenAuthLoading: false,
tokenRefresh: undefined, tokenRefresh: undefined,
tokenVerifyLoading: false tokenVerifyLoading: false

View file

@ -82,3 +82,52 @@ export const SetPasswordMutation = TypedMutation<
SetPassword, SetPassword,
SetPasswordVariables SetPasswordVariables
>(setPassword); >(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
}
}
}
`;

12
src/auth/queries.ts Normal file
View file

@ -0,0 +1,12 @@
import gql from "graphql-tag";
export const availableExternalAuthentications = gql`
query AvailableExternalAuthentications {
shop {
availableExternalAuthentications {
id
name
}
}
}
`;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -7,9 +7,18 @@ export const passwordResetSuccessPath = "/reset-password/success/";
export const passwordResetSuccessUrl = passwordResetSuccessPath; export const passwordResetSuccessUrl = passwordResetSuccessPath;
export const newPasswordPath = "/new-password/"; export const newPasswordPath = "/new-password/";
export const loginCallbackPath = "/login/callback/";
export interface NewPasswordUrlQueryParams { export interface NewPasswordUrlQueryParams {
email: string; email: string;
token: string; token: string;
} }
export const newPasswordUrl = (params?: NewPasswordUrlQueryParams) => export const newPasswordUrl = (params?: NewPasswordUrlQueryParams) =>
newPasswordPath + "?" + stringifyQs(params); newPasswordPath + "?" + stringifyQs(params);
export interface LoginOpenidconnectUrlQueryParams {
code: string;
state: string;
}
export type LoginUrlQueryParams = LoginOpenidconnectUrlQueryParams;

View file

@ -1,20 +1,93 @@
import { APP_DEFAULT_URI, APP_MOUNT_URI } from "@saleor/config";
import useNavigator from "@saleor/hooks/useNavigator"; import useNavigator from "@saleor/hooks/useNavigator";
import useUser from "@saleor/hooks/useUser"; 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 LoginPage from "../components/LoginPage";
import { passwordResetUrl } from "../urls"; 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<LoginViewProps> = ({ params }) => {
const navigate = useNavigator(); 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>(
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 ( return (
<LoginPage <LoginPage
error={user === null} error={isError}
disableLoginButton={tokenAuthLoading} externalError={isExternalError}
disabled={tokenAuthLoading}
externalAuthentications={
externalAuthentications?.shop?.availableExternalAuthentications
}
loading={externalAuthenticationsLoading}
onExternalAuthentication={handleRequestExternalAuthentication}
onPasswordRecovery={() => navigate(passwordResetUrl)} onPasswordRecovery={() => navigate(passwordResetUrl)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />

View file

@ -3,6 +3,7 @@ import { SearchVariables } from "./hooks/makeSearch";
import { ListSettings, ListViews, Pagination } from "./types"; import { ListSettings, ListViews, Pagination } from "./types";
export const APP_MOUNT_URI = process.env.APP_MOUNT_URI; 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 API_URI = process.env.API_URI;
export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = { export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = {

View file

@ -6,8 +6,10 @@ export const UserDecorator = (user: User) => storyFn => (
<UserContext.Provider <UserContext.Provider
value={{ value={{
login: undefined, login: undefined,
loginByExternalPlugin: undefined,
loginByToken: undefined, loginByToken: undefined,
logout: undefined, logout: undefined,
requestLoginByExternalPlugin: undefined,
tokenAuthLoading: false, tokenAuthLoading: false,
tokenRefresh: undefined, tokenRefresh: undefined,
tokenVerifyLoading: false, tokenVerifyLoading: false,

View file

@ -36858,6 +36858,165 @@ exports[`Storyshots Views / Authentication / Log in default 1`] = `
<div <div
class="FormSpacer-spacer-id" class="FormSpacer-spacer-id"
/> />
<div
class="LoginCard-buttonContainer-id"
>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id LoginCard-loginButton-id MuiButton-containedPrimary-id"
data-test="submit"
tabindex="0"
type="submit"
>
<span
class="MuiButton-label-id"
>
Login
</span>
</button>
</div>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiTypography-root-id MuiTypography-body1-id"
>
Forgot password?
<a
class="LoginCard-link-id"
>
Use this link to recover it
</a>
</div>
<div
class="FormSpacer-spacer-id"
/>
<hr
class="MuiDivider-root-id"
/>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiTypography-root-id MuiTypography-body1-id"
>
or login using
</div>
<div
class="FormSpacer-spacer-id"
/>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-outlined-id MuiButton-outlinedPrimary-id MuiButton-outlinedSizeLarge-id MuiButton-sizeLarge-id MuiButton-fullWidth-id"
data-test="external-authentication"
tabindex="0"
type="button"
>
<span
class="MuiButton-label-id"
>
Example auth plugin
</span>
</button>
</form>
</div>
</div>
</div>
`;
exports[`Storyshots Views / Authentication / Log in disabled 1`] = `
<div
style="padding:24px"
>
<div
class="MuiPaper-root-id MuiPaper-elevation0-id MuiCard-root-id MuiPaper-rounded-id"
style="margin:auto;overflow:visible;position:relative;width:400px"
>
<div
class="MuiCardContent-root-id"
>
<form>
<div
class="MuiFormControl-root-id MuiTextField-root-id MuiFormControl-fullWidth-id"
>
<label
class="MuiFormLabel-root-id MuiInputLabel-root-id MuiInputLabel-formControl-id MuiInputLabel-animated-id MuiInputLabel-outlined-id MuiFormLabel-disabled-id MuiInputLabel-disabled-id"
data-shrink="false"
>
E-mail Address
</label>
<div
class="MuiInputBase-root-id MuiOutlinedInput-root-id MuiInputBase-disabled-id MuiOutlinedInput-disabled-id MuiInputBase-fullWidth-id MuiInputBase-formControl-id"
>
<input
aria-invalid="false"
autocomplete="username"
autofocus=""
class="MuiInputBase-input-id MuiOutlinedInput-input-id MuiInputBase-disabled-id MuiOutlinedInput-disabled-id"
data-test="email"
disabled=""
name="email"
type="text"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-id MuiOutlinedInput-notchedOutline-id"
style="padding-left:8px"
>
<legend
class="PrivateNotchedOutline-legend-id"
style="width:0.01px"
>
<span>
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiFormControl-root-id MuiTextField-root-id MuiFormControl-fullWidth-id"
>
<label
class="MuiFormLabel-root-id MuiInputLabel-root-id MuiInputLabel-formControl-id MuiInputLabel-animated-id MuiInputLabel-outlined-id MuiFormLabel-disabled-id MuiInputLabel-disabled-id"
data-shrink="false"
>
Password
</label>
<div
class="MuiInputBase-root-id MuiOutlinedInput-root-id MuiInputBase-disabled-id MuiOutlinedInput-disabled-id MuiInputBase-fullWidth-id MuiInputBase-formControl-id"
>
<input
aria-invalid="false"
autocomplete="password"
class="MuiInputBase-input-id MuiOutlinedInput-input-id MuiInputBase-disabled-id MuiOutlinedInput-disabled-id"
data-test="password"
disabled=""
name="password"
type="password"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-id MuiOutlinedInput-notchedOutline-id"
style="padding-left:8px"
>
<legend
class="PrivateNotchedOutline-legend-id"
style="width:0.01px"
>
<span>
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="FormSpacer-spacer-id"
/>
<div <div
class="LoginCard-buttonContainer-id" class="LoginCard-buttonContainer-id"
> >
@ -36879,10 +37038,45 @@ exports[`Storyshots Views / Authentication / Log in default 1`] = `
class="FormSpacer-spacer-id" class="FormSpacer-spacer-id"
/> />
<div <div
class="MuiTypography-root-id LoginCard-link-id MuiTypography-body1-id" class="MuiTypography-root-id MuiTypography-body1-id"
> >
Reset your password Forgot password?
<a
class="LoginCard-link-id"
>
Use this link to recover it
</a>
</div> </div>
<div
class="FormSpacer-spacer-id"
/>
<hr
class="MuiDivider-root-id"
/>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiTypography-root-id MuiTypography-body1-id"
>
or login using
</div>
<div
class="FormSpacer-spacer-id"
/>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-outlined-id MuiButton-outlinedPrimary-id MuiButton-outlinedSizeLarge-id MuiButton-sizeLarge-id MuiButton-disabled-id MuiButton-fullWidth-id MuiButtonBase-disabled-id"
data-test="external-authentication"
disabled=""
tabindex="-1"
type="button"
>
<span
class="MuiButton-label-id"
>
Example auth plugin
</span>
</button>
</form> </form>
</div> </div>
</div> </div>
@ -36996,10 +37190,9 @@ exports[`Storyshots Views / Authentication / Log in error 1`] = `
class="LoginCard-buttonContainer-id" class="LoginCard-buttonContainer-id"
> >
<button <button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id LoginCard-loginButton-id MuiButton-containedPrimary-id MuiButton-disabled-id MuiButtonBase-disabled-id" class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id LoginCard-loginButton-id MuiButton-containedPrimary-id"
data-test="submit" data-test="submit"
disabled="" tabindex="0"
tabindex="-1"
type="submit" type="submit"
> >
<span <span
@ -37013,10 +37206,44 @@ exports[`Storyshots Views / Authentication / Log in error 1`] = `
class="FormSpacer-spacer-id" class="FormSpacer-spacer-id"
/> />
<div <div
class="MuiTypography-root-id LoginCard-link-id MuiTypography-body1-id" class="MuiTypography-root-id MuiTypography-body1-id"
> >
Reset your password Forgot password?
<a
class="LoginCard-link-id"
>
Use this link to recover it
</a>
</div> </div>
<div
class="FormSpacer-spacer-id"
/>
<hr
class="MuiDivider-root-id"
/>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiTypography-root-id MuiTypography-body1-id"
>
or login using
</div>
<div
class="FormSpacer-spacer-id"
/>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-outlined-id MuiButton-outlinedPrimary-id MuiButton-outlinedSizeLarge-id MuiButton-sizeLarge-id MuiButton-fullWidth-id"
data-test="external-authentication"
tabindex="0"
type="button"
>
<span
class="MuiButton-label-id"
>
Example auth plugin
</span>
</button>
</form> </form>
</div> </div>
</div> </div>
@ -37034,114 +37261,29 @@ exports[`Storyshots Views / Authentication / Log in loading 1`] = `
<div <div
class="MuiCardContent-root-id" class="MuiCardContent-root-id"
> >
<form> <div
class="LoginCard-loading-id"
>
<div <div
class="MuiFormControl-root-id MuiTextField-root-id MuiFormControl-fullWidth-id" class="MuiCircularProgress-root-id MuiCircularProgress-colorPrimary-id MuiCircularProgress-indeterminate-id"
role="progressbar"
style="width:128px;height:128px"
> >
<label <svg
class="MuiFormLabel-root-id MuiInputLabel-root-id MuiInputLabel-formControl-id MuiInputLabel-animated-id MuiInputLabel-outlined-id" class="MuiCircularProgress-svg-id"
data-shrink="false" viewBox="22 22 44 44"
> >
E-mail Address <circle
</label> class="MuiCircularProgress-circle-id MuiCircularProgress-circleIndeterminate-id"
<div cx="44"
class="MuiInputBase-root-id MuiOutlinedInput-root-id MuiInputBase-fullWidth-id MuiInputBase-formControl-id" cy="44"
> fill="none"
<input r="20.2"
aria-invalid="false" stroke-width="3.6"
autocomplete="username"
autofocus=""
class="MuiInputBase-input-id MuiOutlinedInput-input-id"
data-test="email"
name="email"
type="text"
value=""
/> />
<fieldset </svg>
aria-hidden="true"
class="PrivateNotchedOutline-root-id MuiOutlinedInput-notchedOutline-id"
style="padding-left:8px"
>
<legend
class="PrivateNotchedOutline-legend-id"
style="width:0.01px"
>
<span>
</span>
</legend>
</fieldset>
</div>
</div> </div>
<div </div>
class="FormSpacer-spacer-id"
/>
<div
class="MuiFormControl-root-id MuiTextField-root-id MuiFormControl-fullWidth-id"
>
<label
class="MuiFormLabel-root-id MuiInputLabel-root-id MuiInputLabel-formControl-id MuiInputLabel-animated-id MuiInputLabel-outlined-id"
data-shrink="false"
>
Password
</label>
<div
class="MuiInputBase-root-id MuiOutlinedInput-root-id MuiInputBase-fullWidth-id MuiInputBase-formControl-id"
>
<input
aria-invalid="false"
autocomplete="password"
class="MuiInputBase-input-id MuiOutlinedInput-input-id"
data-test="password"
name="password"
type="password"
value=""
/>
<fieldset
aria-hidden="true"
class="PrivateNotchedOutline-root-id MuiOutlinedInput-notchedOutline-id"
style="padding-left:8px"
>
<legend
class="PrivateNotchedOutline-legend-id"
style="width:0.01px"
>
<span>
</span>
</legend>
</fieldset>
</div>
</div>
<div
class="FormSpacer-spacer-id"
/>
<div
class="LoginCard-buttonContainer-id"
>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id LoginCard-loginButton-id MuiButton-containedPrimary-id MuiButton-disabled-id MuiButtonBase-disabled-id"
data-test="submit"
disabled=""
tabindex="-1"
type="submit"
>
<span
class="MuiButton-label-id"
>
Login
</span>
</button>
</div>
<div
class="FormSpacer-spacer-id"
/>
<div
class="MuiTypography-root-id LoginCard-link-id MuiTypography-body1-id"
>
Reset your password
</div>
</form>
</div> </div>
</div> </div>
</div> </div>

View file

@ -44,10 +44,6 @@ function loadStories() {
require("./stories/components/WeightRange"); require("./stories/components/WeightRange");
require("./stories/components/messages"); require("./stories/components/messages");
// Authentication
require("./stories/auth/LoginPage");
require("./stories/auth/LoginLoading");
// Attributes // Attributes
require("./stories/attributes/AttributeBulkDeleteDialog"); require("./stories/attributes/AttributeBulkDeleteDialog");
require("./stories/attributes/AttributeDeleteDialog"); require("./stories/attributes/AttributeDeleteDialog");