diff --git a/.changeset/good-actors-yawn.md b/.changeset/good-actors-yawn.md new file mode 100644 index 00000000000..4856fbc6e48 --- /dev/null +++ b/.changeset/good-actors-yawn.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Now there can only be one login request running at a time. This means using a password manager to log in no longer cause an error. If there are too many login requests Dashboard now shows a corresponding error message. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index d24557eac7d..ee46deb6044 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -4612,6 +4612,10 @@ "context": "order does not require shipping", "string": "does not apply" }, + "RyZd9J": { + "context": "error message", + "string": "Please wait a moment before trying again." + }, "Ryh3iR": { "context": "header", "string": "Create Webhook" diff --git a/src/auth/AuthProvider.test.tsx b/src/auth/AuthProvider.test.tsx index e723c6b6f5a..a756fc1fa4c 100644 --- a/src/auth/AuthProvider.test.tsx +++ b/src/auth/AuthProvider.test.tsx @@ -211,4 +211,49 @@ describe("AuthProvider", () => { expect(hook.result.current.errors).toEqual(["noPermissionsError"]); expect(hook.result.current.authenticated).toBe(false); }); + + it("should handle concurrent login attempts correctly", async () => { + const intl = useIntl(); + const notify = useNotifier(); + const apolloClient = useApolloClient(); + + (useAuthState as jest.Mock).mockImplementation(() => ({ + authenticated: false, + authenticating: false, + })); + + const loginMock = jest.fn( + () => + new Promise(resolve => { + return resolve({ + data: { + tokenCreate: { + errors: [], + user: { + userPermissions: [ + { + code: "MANAGE_USERS", + name: "Handle checkouts", + }, + ], + }, + }, + }, + }); + }), + ); + + (useAuth as jest.Mock).mockImplementation(() => ({ + login: loginMock, + logout: jest.fn(), + })); + + const { result } = renderHook(() => useAuthProvider({ intl, notify, apolloClient })); + + // Simulate two concurrent login attempts + result.current.login!("email", "password"); + result.current.login!("email", "password"); + + expect(loginMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/auth/components/LoginPage/messages.ts b/src/auth/components/LoginPage/messages.ts index 3d18f97cff0..369269d6479 100644 --- a/src/auth/components/LoginPage/messages.ts +++ b/src/auth/components/LoginPage/messages.ts @@ -22,6 +22,11 @@ export const errorMessages = defineMessages({ defaultMessage: "You don't have permission to login.", description: "error message", }, + loginAttemptDelay: { + defaultMessage: "Please wait a moment before trying again.", + description: "error message", + id: "RyZd9J", + }, }); export function getErrorMessage(err: UserContextError, intl: IntlShape): string { @@ -36,5 +41,7 @@ export function getErrorMessage(err: UserContextError, intl: IntlShape): string return intl.formatMessage(errorMessages.serverError); case "noPermissionsError": return intl.formatMessage(errorMessages.noPermissionsError); + case "loginAttemptDelay": + return intl.formatMessage(errorMessages.loginAttemptDelay); } } diff --git a/src/auth/hooks/useAuthProvider.ts b/src/auth/hooks/useAuthProvider.ts index 46ae6d1eb24..30ab804c3f8 100644 --- a/src/auth/hooks/useAuthProvider.ts +++ b/src/auth/hooks/useAuthProvider.ts @@ -2,7 +2,7 @@ import { ApolloClient, ApolloError } from "@apollo/client"; import { IMessageContext } from "@dashboard/components/messages"; import { useAnalytics } from "@dashboard/components/ProductAnalytics/useAnalytics"; import { DEMO_MODE } from "@dashboard/config"; -import { useUserDetailsQuery } from "@dashboard/graphql"; +import { AccountErrorCode, useUserDetailsQuery } from "@dashboard/graphql"; import useLocalStorage from "@dashboard/hooks/useLocalStorage"; import useNavigator from "@dashboard/hooks/useNavigator"; import { commonMessages } from "@dashboard/intl"; @@ -34,6 +34,7 @@ export interface UseAuthProviderOpts { notify: IMessageContext; apolloClient: ApolloClient; } +type AuthErrorCodes = `${AccountErrorCode}`; export function useAuthProvider({ intl, notify, apolloClient }: UseAuthProviderOpts): UserContext { const { login, getExternalAuthUrl, getExternalAccessToken, logout } = useAuth(); @@ -41,6 +42,7 @@ export function useAuthProvider({ intl, notify, apolloClient }: UseAuthProviderO const navigate = useNavigator(); const { authenticated, authenticating, user } = useAuthState(); const [requestedExternalPluginId] = useLocalStorage("requestedExternalPluginId", null); + const [isAuthRequestRunning, setIsAuthRequestRunning] = useState(false); const [errors, setErrors] = useState([]); const permitCredentialsAPI = useRef(true); @@ -116,27 +118,45 @@ export function useAuthProvider({ intl, notify, apolloClient }: UseAuthProviderO } } }; + const handleLogin = async (email: string, password: string) => { + if (isAuthRequestRunning) { + return; + } + try { + setIsAuthRequestRunning(true); + const result = await login({ email, password, includeDetails: false, }); + const errorList = result.data?.tokenCreate?.errors?.map( + ({ code }) => code, + // SDK is deprecated and has outdated types - we need to use ones from Dashboard + ) as AuthErrorCodes[]; + if (isEmpty(result.data?.tokenCreate?.user?.userPermissions)) { setErrors(["noPermissionsError"]); await handleLogout(); } - if (result && !result.data?.tokenCreate?.errors.length) { + if (result && !errorList?.length) { if (DEMO_MODE) { displayDemoMessage(intl, notify); } saveCredentials(result.data?.tokenCreate?.user!, password); } else { - setErrors(["loginError"]); + // While login page can show multiple errors, "loginError" doesn't match "attemptDelay" + // and should be shown when no other error is present + if (errorList?.includes(AccountErrorCode.LOGIN_ATTEMPT_DELAYED)) { + setErrors(["loginAttemptDelay"]); + } else { + setErrors(["loginError"]); + } } await logoutNonStaffUser(result.data?.tokenCreate!); @@ -148,6 +168,8 @@ export function useAuthProvider({ intl, notify, apolloClient }: UseAuthProviderO } else { setErrors(["unknownLoginError"]); } + } finally { + setIsAuthRequestRunning(false); } }; const handleRequestExternalLogin = async (pluginId: string, input: RequestExternalLoginInput) => { diff --git a/src/auth/types.ts b/src/auth/types.ts index 3dd781ffbe9..023f6b2a45b 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -20,6 +20,7 @@ export const UserContextError = { serverError: "serverError", noPermissionsError: "noPermissionsError", externalLoginError: "externalLoginError", + loginAttemptDelay: "loginAttemptDelay", unknownLoginError: "unknownLoginError", } as const; diff --git a/src/auth/views/Login.tsx b/src/auth/views/Login.tsx index 16e5bb9f729..41fe9572a19 100644 --- a/src/auth/views/Login.tsx +++ b/src/auth/views/Login.tsx @@ -32,7 +32,11 @@ const LoginView: React.FC = ({ params }) => { setRequestedExternalPluginId, } = useAuthParameters(); const handleSubmit = async (data: LoginFormData) => { - const result = await login!(data.email, data.password); + if (!login) { + return; + } + + const result = await login(data.email, data.password); const errors = result?.errors || []; return errors;