Skip to content
Snippets Groups Projects
Unverified Commit 7d235203 authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Migrate auth to RTK (#31089)

parent 383c8bed
No related branches found
No related tags found
No related merge requests found
Showing
with 228 additions and 214 deletions
import React, { ReactNode } from "react";
import { useSelector } from "metabase/lib/redux";
import LogoIcon from "metabase/components/LogoIcon";
import { getHasIllustration } from "../../selectors";
import {
LayoutBody,
LayoutCard,
......@@ -7,18 +9,16 @@ import {
LayoutRoot,
} from "./AuthLayout.styled";
export interface AuthLayoutProps {
showIllustration: boolean;
interface AuthLayoutProps {
children?: ReactNode;
}
const AuthLayout = ({
showIllustration,
children,
}: AuthLayoutProps): JSX.Element => {
export const AuthLayout = ({ children }: AuthLayoutProps): JSX.Element => {
const hasIllustration = useSelector(getHasIllustration);
return (
<LayoutRoot>
{showIllustration && <LayoutIllustration />}
{hasIllustration && <LayoutIllustration />}
<LayoutBody>
<LogoIcon height={65} />
<LayoutCard>{children}</LayoutCard>
......@@ -26,6 +26,3 @@ const AuthLayout = ({
</LayoutRoot>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default AuthLayout;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./AuthLayout";
export * from "./AuthLayout";
import React, { useCallback, useState } from "react";
import { t } from "ttag";
import { Location } from "history";
import { useDispatch, useSelector } from "metabase/lib/redux";
import Button from "metabase/core/components/Button";
import AuthLayout from "../../containers/AuthLayout";
import ForgotPasswordForm from "../ForgotPasswordForm";
import { forgotPassword } from "../../actions";
import { getIsEmailConfigured, getIsLdapEnabled } from "../../selectors";
import { AuthLayout } from "../AuthLayout";
import { ForgotPasswordForm } from "../ForgotPasswordForm";
import {
InfoBody,
InfoIcon,
......@@ -13,27 +17,33 @@ import {
type ViewType = "form" | "disabled" | "success";
export interface ForgotPasswordProps {
canResetPassword: boolean;
initialEmail?: string;
onResetPassword: (email: string) => void;
interface ForgotPasswordQueryString {
email?: string;
}
const ForgotPassword = ({
canResetPassword,
initialEmail,
onResetPassword,
interface ForgotPasswordProps {
location?: Location<ForgotPasswordQueryString>;
}
export const ForgotPassword = ({
location,
}: ForgotPasswordProps): JSX.Element => {
const isEmailConfigured = useSelector(getIsEmailConfigured);
const isLdapEnabled = useSelector(getIsLdapEnabled);
const canResetPassword = isEmailConfigured && !isLdapEnabled;
const initialEmail = location?.query?.email;
const [view, setView] = useState<ViewType>(
canResetPassword ? "form" : "disabled",
);
const dispatch = useDispatch();
const handleSubmit = useCallback(
async (email: string) => {
await onResetPassword(email);
await dispatch(forgotPassword(email)).unwrap();
setView("success");
},
[onResetPassword],
[dispatch],
);
return (
......@@ -74,6 +84,3 @@ const ForgotPasswordDisabled = (): JSX.Element => {
</InfoBody>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default ForgotPassword;
import React, { ReactNode } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { Route } from "react-router";
import userEvent from "@testing-library/user-event";
import ForgotPassword, { ForgotPasswordProps } from "./ForgotPassword";
import {
createMockSettingsState,
createMockState,
} from "metabase-types/store/mocks";
import { setupForgotPasswordEndpoint } from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { ForgotPassword } from "./ForgotPassword";
const TEST_EMAIL = "user@metabase.test";
interface SetupOpts {
isEmailConfigured?: boolean;
isLdapEnabled?: boolean;
}
const setup = ({ isEmailConfigured, isLdapEnabled }: SetupOpts) => {
const state = createMockState({
settings: createMockSettingsState({
"email-configured?": isEmailConfigured,
"ldap-enabled": isLdapEnabled,
}),
});
setupForgotPasswordEndpoint();
renderWithProviders(
<Route path="/auth/forgot_password" component={ForgotPassword} />,
{
storeInitialState: state,
withRouter: true,
initialRoute: "/auth/forgot_password",
},
);
};
describe("ForgotPassword", () => {
it("should show a form when the user can reset their password", () => {
const props = getProps({ canResetPassword: true });
render(<ForgotPassword {...props} />);
setup({ isEmailConfigured: true });
expect(screen.getByText("Forgot password")).toBeInTheDocument();
});
it("should show a success message when the form is submitted", async () => {
const email = "user@metabase.test";
const props = getProps({
canResetPassword: true,
onResetPassword: jest.fn().mockResolvedValue({}),
});
render(<ForgotPassword {...props} />);
userEvent.type(screen.getByLabelText("Email address"), email);
setup({ isEmailConfigured: true });
userEvent.type(screen.getByLabelText("Email address"), TEST_EMAIL);
await waitFor(() => {
expect(screen.getByText("Send password reset email")).toBeEnabled();
});
userEvent.click(screen.getByText("Send password reset email"));
await waitFor(() => {
expect(props.onResetPassword).toHaveBeenCalledWith(email);
});
expect(screen.getByText(/Check your email/)).toBeInTheDocument();
expect(await screen.findByText(/Check your email/)).toBeInTheDocument();
});
it("should show an error message when the user cannot reset their password", () => {
const props = getProps({ canResetPassword: false });
render(<ForgotPassword {...props} />);
setup({ isEmailConfigured: false });
expect(screen.getByText(/contact an administrator/)).toBeInTheDocument();
});
});
const getProps = (
opts?: Partial<ForgotPasswordProps>,
): ForgotPasswordProps => ({
canResetPassword: false,
onResetPassword: jest.fn(),
...opts,
});
interface AuthLayoutMockProps {
children?: ReactNode;
}
const AuthLayoutMock = ({ children }: AuthLayoutMockProps) => {
return <div>{children}</div>;
};
jest.mock("../../containers/AuthLayout", () => AuthLayoutMock);
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./ForgotPassword";
export * from "./ForgotPassword";
......@@ -18,12 +18,12 @@ const FORGOT_PASSWORD_SCHEMA = Yup.object({
email: Yup.string().required(Errors.required).email(Errors.email),
});
export interface ForgotPasswordFormProps {
interface ForgotPasswordFormProps {
initialEmail?: string;
onSubmit: (email: string) => void;
}
const ForgotPasswordForm = ({
export const ForgotPasswordForm = ({
initialEmail = "",
onSubmit,
}: ForgotPasswordFormProps): JSX.Element => {
......@@ -66,6 +66,3 @@ const ForgotPasswordForm = ({
</div>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default ForgotPasswordForm;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./ForgotPasswordForm";
export * from "./ForgotPasswordForm";
......@@ -2,7 +2,10 @@ import React, { useCallback, useState } from "react";
import { t } from "ttag";
import { getIn } from "icepick";
import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google";
import { useDispatch, useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { loginGoogle } from "../../actions";
import { getGoogleClientId, getSiteLocale } from "../../selectors";
import {
GoogleButtonRoot,
AuthError,
......@@ -10,37 +13,31 @@ import {
TextLink,
} from "./GoogleButton.styled";
export interface GoogleButtonProps {
clientId: string | null;
locale: string;
interface GoogleButtonProps {
redirectUrl?: string;
isCard?: boolean;
onLogin: (token: string, redirectUrl?: string) => void;
}
interface CredentialResponse {
credential?: string;
}
const GoogleButton = ({
clientId,
locale,
redirectUrl,
isCard,
onLogin,
}: GoogleButtonProps) => {
export const GoogleButton = ({ redirectUrl, isCard }: GoogleButtonProps) => {
const clientId = useSelector(getGoogleClientId);
const locale = useSelector(getSiteLocale);
const [errors, setErrors] = useState<string[]>([]);
const dispatch = useDispatch();
const handleLogin = useCallback(
async ({ credential = "" }: CredentialResponse) => {
try {
setErrors([]);
await onLogin(credential, redirectUrl);
await dispatch(loginGoogle({ credential, redirectUrl })).unwrap();
} catch (error) {
setErrors(getErrors(error));
}
},
[onLogin, redirectUrl],
[dispatch, redirectUrl],
);
const handleError = useCallback(() => {
......@@ -82,6 +79,3 @@ const getErrors = (error: unknown): string[] => {
const errors = getIn(error, ["data", "errors"]);
return errors ? Object.values(errors) : [];
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default GoogleButton;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./GoogleButton";
export * from "./GoogleButton";
import React from "react";
import { t } from "ttag";
import AuthLayout from "../../containers/AuthLayout";
import { AuthProvider } from "../../types";
import { Location } from "history";
import { useSelector } from "metabase/lib/redux";
import { AuthProvider } from "metabase/plugins/types";
import { AuthLayout } from "../AuthLayout";
import { getAuthProviders } from "../../selectors";
import {
ActionList,
ActionListItem,
......@@ -9,18 +12,23 @@ import {
LoginTitle,
} from "./Login.styled";
export interface LoginProps {
providers: AuthProvider[];
providerName?: string;
redirectUrl?: string;
interface LoginQueryString {
redirect?: string;
}
const Login = ({
providers,
providerName,
redirectUrl,
}: LoginProps): JSX.Element => {
const selection = getSelectedProvider(providers, providerName);
interface LoginQueryParams {
provider?: string;
}
interface LoginProps {
params?: LoginQueryParams;
location?: Location<LoginQueryString>;
}
export const Login = ({ params, location }: LoginProps): JSX.Element => {
const providers = useSelector(getAuthProviders);
const selection = getSelectedProvider(providers, params?.provider);
const redirectUrl = location?.query?.redirect;
return (
<AuthLayout>
......@@ -54,6 +62,3 @@ const getSelectedProvider = (
return provider?.Panel ? provider : undefined;
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Login;
import React, { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { AuthProvider } from "../../types";
import Login from "./Login";
import React from "react";
import { Route } from "react-router";
import MetabaseSettings from "metabase/lib/settings";
import {
createMockSettingsState,
createMockState,
} from "metabase-types/store/mocks";
import { renderWithProviders, screen } from "__support__/ui";
import { Login } from "./Login";
interface SetupOpts {
initialRoute?: string;
isPasswordLoginEnabled?: boolean;
isGoogleAuthEnabled?: boolean;
}
const setup = ({
initialRoute = "/auth/login",
isPasswordLoginEnabled = true,
isGoogleAuthEnabled = false,
}: SetupOpts = {}) => {
const state = createMockState({
settings: createMockSettingsState({
"enable-password-login": isPasswordLoginEnabled,
"google-auth-enabled": isGoogleAuthEnabled,
}),
});
MetabaseSettings.set("enable-password-login", isPasswordLoginEnabled);
MetabaseSettings.set("google-auth-enabled", isGoogleAuthEnabled);
renderWithProviders(
<>
<Route path="/auth/login" component={Login} />
<Route path="/auth/login/:provider" component={Login} />
</>,
{ storeInitialState: state, withRouter: true, initialRoute },
);
};
const cleanUp = () => {
MetabaseSettings.set("enable-password-login", true);
MetabaseSettings.set("google-auth-enabled", false);
};
describe("Login", () => {
it("should render a list of auth providers", () => {
const providers = [
getAuthProvider({ name: "password", Panel: AuthPanelMock }),
getAuthProvider({ name: "google" }),
];
afterEach(() => {
cleanUp();
});
render(<Login providers={providers} />);
it("should render a list of auth providers", () => {
setup({ isPasswordLoginEnabled: true, isGoogleAuthEnabled: true });
expect(screen.getAllByRole("link")).toHaveLength(2);
});
it("should render the panel of the selected provider", () => {
const providers = [
getAuthProvider({ name: "password", Panel: AuthPanelMock }),
getAuthProvider({ name: "google" }),
];
render(<Login providers={providers} providerName="password" />);
setup({
initialRoute: "/auth/login/password",
isPasswordLoginEnabled: true,
isGoogleAuthEnabled: true,
});
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should implicitly select the only provider with a panel", () => {
const providers = [
getAuthProvider({ name: "password", Panel: AuthPanelMock }),
];
render(<Login providers={providers} />);
setup({
isPasswordLoginEnabled: true,
isGoogleAuthEnabled: false,
});
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should not implicitly select the only provider without a panel", () => {
const providers = [getAuthProvider({ name: "google" })];
render(<Login providers={providers} />);
expect(screen.getByRole("link")).toBeInTheDocument();
});
});
const getAuthProvider = (opts?: Partial<AuthProvider>): AuthProvider => ({
name: "password",
Button: AuthButtonMock,
...opts,
});
const AuthButtonMock = () => <a href="/">Sign in</a>;
const AuthPanelMock = () => <button>Sign in</button>;
interface AuthLayoutMockProps {
children?: ReactNode;
}
const AuthLayoutMock = ({ children }: AuthLayoutMockProps) => {
return <div>{children}</div>;
};
jest.mock("../../containers/AuthLayout", () => AuthLayoutMock);
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./Login";
export * from "./Login";
......@@ -21,13 +21,13 @@ const LOGIN_SCHEMA = Yup.object().shape({
remember: Yup.boolean(),
});
export interface LoginFormProps {
interface LoginFormProps {
isLdapEnabled: boolean;
hasSessionCookies: boolean;
onSubmit: (data: LoginData) => void;
}
const LoginForm = ({
export const LoginForm = ({
isLdapEnabled,
hasSessionCookies,
onSubmit,
......@@ -80,6 +80,3 @@ const LoginForm = ({
</FormProvider>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default LoginForm;
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./LoginForm";
export * from "./LoginForm";
import { useEffect } from "react";
import { useDispatch } from "metabase/lib/redux";
import { logout } from "../../actions";
interface LogoutProps {
onLogout: () => void;
}
export const Logout = (): JSX.Element | null => {
const dispatch = useDispatch();
const Logout = ({ onLogout }: LogoutProps): JSX.Element | null => {
useEffect(() => {
onLogout();
}, [onLogout]);
dispatch(logout());
}, [dispatch]);
return null;
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default Logout;
import React from "react";
import { render } from "@testing-library/react";
import Logout from "./Logout";
import fetchMock from "fetch-mock";
import { setupLogoutEndpoint } from "__support__/server-mocks";
import { renderWithProviders, waitFor } from "__support__/ui";
import { Logout } from "./Logout";
const setup = () => {
jest.spyOn(window, "location", "get").mockReturnValue({
...window.location,
reload: jest.fn(),
});
setupLogoutEndpoint();
renderWithProviders(<Logout />);
};
describe("Logout", () => {
it("should logout on mount", () => {
const onLogout = jest.fn();
afterEach(() => {
jest.restoreAllMocks();
});
render(<Logout onLogout={onLogout} />);
it("should logout on mount", async () => {
setup();
expect(onLogout).toHaveBeenCalled();
await waitFor(() => expect(fetchMock.done("path:/api/session")).toBe(true));
await waitFor(() => expect(window.location.reload).toHaveBeenCalled());
});
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./Logout";
export * from "./Logout";
import React from "react";
import { t } from "ttag";
import AuthButton from "../AuthButton";
import { useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getIsLdapEnabled } from "../../selectors";
import { AuthButton } from "../AuthButton";
export interface PasswordButtonProps {
isLdapEnabled: boolean;
interface PasswordButtonProps {
redirectUrl?: string;
}
const PasswordButton = ({
isLdapEnabled,
redirectUrl,
}: PasswordButtonProps) => {
const link = redirectUrl
? `/auth/login/password?redirect=${encodeURIComponent(redirectUrl)}`
: `/auth/login/password`;
export const PasswordButton = ({ redirectUrl }: PasswordButtonProps) => {
const isLdapEnabled = useSelector(getIsLdapEnabled);
return (
<AuthButton link={link}>
<AuthButton link={Urls.password(redirectUrl)}>
{isLdapEnabled
? t`Sign in with username or email`
: t`Sign in with email`}
</AuthButton>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default PasswordButton;
import React from "react";
import { render, screen } from "@testing-library/react";
import PasswordButton, { PasswordButtonProps } from "./PasswordButton";
import {
createMockSettingsState,
createMockState,
} from "metabase-types/store/mocks";
import { renderWithProviders, screen } from "__support__/ui";
import { PasswordButton } from "./PasswordButton";
interface SetupOpts {
isLdapEnabled?: boolean;
}
const setup = ({ isLdapEnabled }: SetupOpts = {}) => {
const state = createMockState({
settings: createMockSettingsState({
"ldap-enabled": isLdapEnabled,
}),
});
renderWithProviders(<PasswordButton />, { storeInitialState: state });
};
describe("PasswordButton", () => {
it("should render the login button", () => {
const props = getProps();
render(<PasswordButton {...props} />);
setup();
expect(screen.getByText("Sign in with email")).toBeInTheDocument();
});
it("should render the login button when ldap is enabled", () => {
const props = getProps({ isLdapEnabled: true });
render(<PasswordButton {...props} />);
setup({ isLdapEnabled: true });
expect(
screen.getByText("Sign in with username or email"),
).toBeInTheDocument();
});
});
const getProps = (
opts?: Partial<PasswordButtonProps>,
): PasswordButtonProps => ({
isLdapEnabled: false,
...opts,
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./PasswordButton";
export * from "./PasswordButton";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment