diff --git a/enterprise/frontend/src/metabase-enterprise/auth/actions.ts b/enterprise/frontend/src/metabase-enterprise/auth/actions.ts index 695b2b82a031c240e2c114545f32009d2e096c70..6dc4409fdcee76d718110e02e30fdb035fcf36aa 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/actions.ts +++ b/enterprise/frontend/src/metabase-enterprise/auth/actions.ts @@ -1,12 +1,20 @@ -import { createThunkAction } from "metabase/lib/redux"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { getSetting } from "metabase/selectors/settings"; +import { State } from "metabase-types/store"; import { trackLoginSSO } from "./analytics"; import { getSSOUrl } from "./utils"; +interface ThunkConfig { + state: State; +} + export const LOGIN_SSO = "metabase-enterprise/auth/LOGIN_SSO"; -export const loginSSO = createThunkAction( +export const loginSSO = createAsyncThunk<void, string | undefined, ThunkConfig>( LOGIN_SSO, - (redirectUrl?: string) => async () => { + (redirectUrl: string | undefined, { getState }) => { trackLoginSSO(); - window.location.href = getSSOUrl(redirectUrl); + + const siteUrl = getSetting(getState(), "site-url"); + window.location.href = getSSOUrl(siteUrl, redirectUrl); }, ); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.tsx deleted file mode 100644 index a9fcde7e86f371ca647c21d86e8b403db76bf4f2..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React, { useCallback, useEffect } from "react"; -import { t } from "ttag"; -import AuthButton from "metabase/auth/components/AuthButton"; - -export interface SSOButtonProps { - isCard?: boolean; - isEmbedded?: boolean; - redirectUrl?: string; - onLogin: (redirectUrl?: string) => void; -} - -const SSOButton = ({ - isCard, - isEmbedded, - redirectUrl, - onLogin, -}: SSOButtonProps): JSX.Element => { - const handleLogin = useCallback(() => { - onLogin(redirectUrl); - }, [onLogin, redirectUrl]); - - useEffect(() => { - isEmbedded && handleLogin(); - }, [isEmbedded, handleLogin]); - - return ( - <AuthButton isCard={isCard} onClick={handleLogin}> - {t`Sign in with SSO`} - </AuthButton> - ); -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default SSOButton; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.unit.spec.tsx deleted file mode 100644 index 03707389b4640799bec788d16b9e05db6e6c8f78..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/SSOButton.unit.spec.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; -import { render } from "@testing-library/react"; -import SSOButton from "./SSOButton"; - -describe("SSOButton", () => { - it("should login immediately when embedded", () => { - const onLogin = jest.fn(); - - render(<SSOButton isEmbedded={true} onLogin={onLogin} />); - - expect(onLogin).toHaveBeenCalled(); - }); -}); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/index.ts b/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/index.ts deleted file mode 100644 index 0b95973c5a42776dc797b38a96e98608ea58fbd1..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/components/SSOButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./SSOButton"; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..edba5e8407d39ae3487c980f5e8593a92df42f52 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.tsx @@ -0,0 +1,35 @@ +import React, { useCallback, useEffect } from "react"; +import { t } from "ttag"; +import { isWithinIframe } from "metabase/lib/dom"; +import { useDispatch } from "metabase/lib/redux"; +import { AuthButton } from "metabase/auth/components/AuthButton"; +import { loginSSO } from "../../actions"; + +interface SsoButtonProps { + isCard?: boolean; + redirectUrl?: string; +} + +export const SsoButton = ({ + isCard, + redirectUrl, +}: SsoButtonProps): JSX.Element => { + const isEmbedded = isWithinIframe(); + const dispatch = useDispatch(); + + const handleLogin = useCallback(() => { + dispatch(loginSSO(redirectUrl)); + }, [dispatch, redirectUrl]); + + useEffect(() => { + if (isEmbedded) { + handleLogin(); + } + }, [isEmbedded, handleLogin]); + + return ( + <AuthButton isCard={isCard} onClick={handleLogin}> + {t`Sign in with SSO`} + </AuthButton> + ); +}; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ffe1404a1450c747a2a60caae315395b9b1f2768 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/SsoButton.unit.spec.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { + createMockSettingsState, + createMockState, +} from "metabase-types/store/mocks"; +import { renderWithProviders, waitFor } from "__support__/ui"; +import { SsoButton } from "./SsoButton"; + +const SITE_URL = "http://metabase.test"; + +const setup = () => { + const state = createMockState({ + settings: createMockSettingsState({ + "site-url": SITE_URL, + }), + }); + + jest.spyOn(window, "top", "get").mockReturnValue({ + ...window, + }); + jest.spyOn(window, "location", "get").mockReturnValue({ + ...window.location, + href: `${SITE_URL}/auth/login`, + }); + + renderWithProviders(<SsoButton />, { storeInitialState: state }); +}; + +describe("SSOButton", () => { + it("should login immediately when embedded", async () => { + setup(); + + await waitFor(() => { + expect(window.location.href).toBe(`${SITE_URL}/auth/sso`); + }); + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/index.ts b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd277d89707b4d0738eb1eed85d6e84ba3e62c2d --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/auth/components/SsoButton/index.ts @@ -0,0 +1 @@ +export * from "./SsoButton"; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/SSOButton.tsx b/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/SSOButton.tsx deleted file mode 100644 index 52a557c9027870c3dd85fd9a0e49111b847e7218..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/SSOButton.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { connect } from "react-redux"; -import { isWithinIframe } from "metabase/lib/dom"; -import SSOButton from "../../components/SSOButton"; -import { loginSSO } from "../../actions"; - -const mapStateToProps = () => ({ - isEmbedded: isWithinIframe(), -}); - -const mapDispatchToProps = { - onLogin: loginSSO, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, mapDispatchToProps)(SSOButton); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/index.ts b/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/index.ts deleted file mode 100644 index 0b95973c5a42776dc797b38a96e98608ea58fbd1..0000000000000000000000000000000000000000 --- a/enterprise/frontend/src/metabase-enterprise/auth/containers/SSOButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./SSOButton"; diff --git a/enterprise/frontend/src/metabase-enterprise/auth/index.js b/enterprise/frontend/src/metabase-enterprise/auth/index.js index a92a477c317afec6ae3f348ad29ca496ff74fc01..1845d8d5bdffc04400ffbcf635422d06fdf7c6e1 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/index.js +++ b/enterprise/frontend/src/metabase-enterprise/auth/index.js @@ -17,7 +17,7 @@ import SessionTimeoutSetting from "metabase-enterprise/auth/components/SessionTi import { createSessionMiddleware } from "../auth/middleware/session-middleware"; import SettingsSAMLForm from "./components/SettingsSAMLForm"; import SettingsJWTForm from "./components/SettingsJWTForm"; -import SSOButton from "./containers/SSOButton"; +import { SsoButton } from "./components/SsoButton"; import JwtAuthCard from "./containers/JwtAuthCard"; import SamlAuthCard from "./containers/SamlAuthCard"; @@ -217,7 +217,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => ({ const SSO_PROVIDER = { name: "sso", - Button: SSOButton, + Button: SsoButton, }; PLUGIN_AUTH_PROVIDERS.push(providers => { diff --git a/enterprise/frontend/src/metabase-enterprise/auth/middleware/session-middleware.js b/enterprise/frontend/src/metabase-enterprise/auth/middleware/session-middleware.js index a2d1a2a853a3426b62f3e335e120e619acb204d6..05d1e2948f68a94e67189e370704fe43706c4051 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/middleware/session-middleware.js +++ b/enterprise/frontend/src/metabase-enterprise/auth/middleware/session-middleware.js @@ -37,7 +37,7 @@ export const createSessionMiddleware = ( if (isLoggedIn) { // get the redirect url before refreshing the session because after the refresh the url will be reset const redirectUrl = getRedirectUrl(); - await store.dispatch(refreshSession()); + await store.dispatch(refreshSession())?.unwrap(); if (redirectUrl !== null) { store.dispatch(replace(redirectUrl)); diff --git a/enterprise/frontend/src/metabase-enterprise/auth/utils.ts b/enterprise/frontend/src/metabase-enterprise/auth/utils.ts index 4fe1e1e9edbdec7a7baf5772bd3ba3d1fa4f28bf..98d4222068dab67209be376cf9f2c1b851ed71bb 100644 --- a/enterprise/frontend/src/metabase-enterprise/auth/utils.ts +++ b/enterprise/frontend/src/metabase-enterprise/auth/utils.ts @@ -1,8 +1,4 @@ -import Settings from "metabase/lib/settings"; - -export const getSSOUrl = (redirectUrl?: string): string => { - const siteUrl = Settings.get("site-url"); - +export const getSSOUrl = (siteUrl: string, redirectUrl?: string): string => { if (redirectUrl) { return `${siteUrl}/auth/sso?redirect=${encodeURIComponent(redirectUrl)}`; } else { diff --git a/frontend/src/metabase-types/api/index.ts b/frontend/src/metabase-types/api/index.ts index ada47ac44ded0b255d891dde189d71e74d344dd9..1faaead28fc1d92693dd139321bcbe178409b259 100644 --- a/frontend/src/metabase-types/api/index.ts +++ b/frontend/src/metabase-types/api/index.ts @@ -21,6 +21,7 @@ export * from "./query"; export * from "./revision"; export * from "./schema"; export * from "./segment"; +export * from "./session"; export * from "./settings"; export * from "./setup"; export * from "./slack"; diff --git a/frontend/src/metabase-types/api/mocks/index.ts b/frontend/src/metabase-types/api/mocks/index.ts index e8a5c862ef6530a99349d76a885d299662752152..8ce1fbbc0fb2fa3cf9dd19a1d233b90918ff072c 100644 --- a/frontend/src/metabase-types/api/mocks/index.ts +++ b/frontend/src/metabase-types/api/mocks/index.ts @@ -15,10 +15,12 @@ export * from "./parameters"; export * from "./query"; export * from "./schema"; export * from "./segment"; -export * from "./table"; -export * from "./timeline"; +export * from "./series"; +export * from "./session"; export * from "./settings"; export * from "./setup"; export * from "./snippets"; export * from "./store"; +export * from "./table"; +export * from "./timeline"; export * from "./user"; diff --git a/frontend/src/metabase-types/api/mocks/session.ts b/frontend/src/metabase-types/api/mocks/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc0adfa1df6b2b1167cb3d108598bcabef36bab7 --- /dev/null +++ b/frontend/src/metabase-types/api/mocks/session.ts @@ -0,0 +1,8 @@ +import { PasswordResetTokenInfo } from "metabase-types/api"; + +export const createMockPasswordResetTokenInfo = ( + opts?: Partial<PasswordResetTokenInfo>, +): PasswordResetTokenInfo => ({ + valid: false, + ...opts, +}); diff --git a/frontend/src/metabase-types/api/session.ts b/frontend/src/metabase-types/api/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a72a8cdbff3beb0163d210acac438ab1d48e9ad --- /dev/null +++ b/frontend/src/metabase-types/api/session.ts @@ -0,0 +1,3 @@ +export interface PasswordResetTokenInfo { + valid: boolean; +} diff --git a/frontend/src/metabase/auth/actions.ts b/frontend/src/metabase/auth/actions.ts index a8c99466b36e18cc002c098fe6636453bfb327a4..c30ce381bd56fd48b8a657c18649476eab9444f2 100644 --- a/frontend/src/metabase/auth/actions.ts +++ b/frontend/src/metabase/auth/actions.ts @@ -1,8 +1,9 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; import { push } from "react-router-redux"; import { getIn } from "icepick"; import { SessionApi, UtilApi } from "metabase/services"; +import { getSetting } from "metabase/selectors/settings"; import MetabaseSettings from "metabase/lib/settings"; -import { createThunkAction } from "metabase/lib/redux"; import { loadLocalization } from "metabase/lib/i18n"; import { deleteSession } from "metabase/lib/auth"; import * as Urls from "metabase/lib/urls"; @@ -18,82 +19,125 @@ import { } from "./analytics"; import { LoginData } from "./types"; +interface ThunkConfig { + state: State; +} + export const REFRESH_LOCALE = "metabase/user/REFRESH_LOCALE"; -export const refreshLocale = createThunkAction( +export const refreshLocale = createAsyncThunk<void, void, ThunkConfig>( REFRESH_LOCALE, - () => async (dispatch: any, getState: () => State) => { + async (_, { getState }) => { const userLocale = getUser(getState())?.locale; - const siteLocale = MetabaseSettings.get("site-locale"); + const siteLocale = getSetting(getState(), "site-locale"); await loadLocalization(userLocale ?? siteLocale ?? "en"); }, ); export const REFRESH_SESSION = "metabase/auth/REFRESH_SESSION"; -export const refreshSession = createThunkAction( +export const refreshSession = createAsyncThunk<void, void, ThunkConfig>( REFRESH_SESSION, - () => async (dispatch: any) => { + async (_, { dispatch }) => { await Promise.all([ dispatch(refreshCurrentUser()), dispatch(refreshSiteSettings()), ]); - await dispatch(refreshLocale()); + await dispatch(refreshLocale()).unwrap(); }, ); +interface LoginPayload { + data: LoginData; + redirectUrl?: string; +} + export const LOGIN = "metabase/auth/LOGIN"; -export const login = createThunkAction( +export const login = createAsyncThunk<void, LoginPayload, ThunkConfig>( LOGIN, - (data: LoginData, redirectUrl = "/") => - async (dispatch: any) => { + async ({ data, redirectUrl = "/" }, { dispatch, rejectWithValue }) => { + try { await SessionApi.create(data); - await dispatch(refreshSession()); + await dispatch(refreshSession()).unwrap(); trackLogin(); - dispatch(push(redirectUrl)); - }, + } catch (error) { + return rejectWithValue(error); + } + }, ); +interface LoginGooglePayload { + credential: string; + redirectUrl?: string; +} + export const LOGIN_GOOGLE = "metabase/auth/LOGIN_GOOGLE"; -export const loginGoogle = createThunkAction( +export const loginGoogle = createAsyncThunk< + void, + LoginGooglePayload, + ThunkConfig +>( LOGIN_GOOGLE, - (token: string, redirectUrl = "/") => - async (dispatch: any) => { - await SessionApi.createWithGoogleAuth({ token }); - await dispatch(refreshSession()); + async ({ credential, redirectUrl = "/" }, { dispatch, rejectWithValue }) => { + try { + await SessionApi.createWithGoogleAuth({ token: credential }); + await dispatch(refreshSession()).unwrap(); trackLoginGoogle(); - dispatch(push(redirectUrl)); - }, + } catch (error) { + return rejectWithValue(error); + } + }, ); export const LOGOUT = "metabase/auth/LOGOUT"; -export const logout = createThunkAction(LOGOUT, (redirectUrl: string) => { - return async (dispatch: any) => { - await deleteSession(); - await dispatch(clearCurrentUser()); - await dispatch(refreshLocale()); - trackLogout(); - - dispatch(push(Urls.login(redirectUrl))); - window.location.reload(); // clears redux state and browser caches - }; -}); +export const logout = createAsyncThunk<void, string | undefined, ThunkConfig>( + LOGOUT, + async (redirectUrl, { dispatch, rejectWithValue }) => { + try { + await deleteSession(); + await dispatch(clearCurrentUser()); + await dispatch(refreshLocale()).unwrap(); + trackLogout(); + dispatch(push(Urls.login(redirectUrl))); + window.location.reload(); // clears redux state and browser caches + } catch (error) { + return rejectWithValue(error); + } + }, +); export const FORGOT_PASSWORD = "metabase/auth/FORGOT_PASSWORD"; -export const forgotPassword = createThunkAction( +export const forgotPassword = createAsyncThunk( FORGOT_PASSWORD, - (email: string) => async () => { - await SessionApi.forgot_password({ email }); + async (email: string, { rejectWithValue }) => { + try { + await SessionApi.forgot_password({ email }); + } catch (error) { + return rejectWithValue(error); + } }, ); +interface ResetPasswordPayload { + token: string; + password: string; +} + export const RESET_PASSWORD = "metabase/auth/RESET_PASSWORD"; -export const resetPassword = createThunkAction( +export const resetPassword = createAsyncThunk< + void, + ResetPasswordPayload, + ThunkConfig +>( RESET_PASSWORD, - (token: string, password: string) => async (dispatch: any) => { - await SessionApi.reset_password({ token, password }); - await dispatch(refreshSession()); - trackPasswordReset(); + async ({ token, password }, { dispatch, rejectWithValue }) => { + try { + await SessionApi.reset_password({ token, password }); + await dispatch(refreshSession()).unwrap(); + trackPasswordReset(); + } catch (error) { + return rejectWithValue(error); + } }, ); @@ -110,15 +154,11 @@ export const validatePassword = async (password: string) => { } }; -export const VALIDATE_PASSWORD_TOKEN = "metabase/auth/VALIDATE_TOKEN"; -export const validatePasswordToken = createThunkAction( - VALIDATE_PASSWORD_TOKEN, - (token: string) => async () => { - const result = await SessionApi.password_reset_token_valid({ token }); - const valid = getIn(result, ["valid"]); +export const validatePasswordToken = async (token: string) => { + const result = await SessionApi.password_reset_token_valid({ token }); + const valid = getIn(result, ["valid"]); - if (!valid) { - throw result; - } - }, -); + if (!valid) { + throw result; + } +}; diff --git a/frontend/src/metabase/auth/components/AuthButton/AuthButton.styled.tsx b/frontend/src/metabase/auth/components/AuthButton/AuthButton.styled.tsx index 4c7e37402e9cdfed90cc2ad124d41be9ce79859c..525625acb9ad92515420ceaebd075dc7b03963d6 100644 --- a/frontend/src/metabase/auth/components/AuthButton/AuthButton.styled.tsx +++ b/frontend/src/metabase/auth/components/AuthButton/AuthButton.styled.tsx @@ -1,6 +1,5 @@ import styled from "@emotion/styled"; import { color } from "metabase/lib/colors"; -import Icon from "metabase/components/Icon"; import Link from "metabase/core/components/Link"; export const TextLink = styled(Link)` @@ -22,10 +21,6 @@ export const CardLink = styled(TextLink)` border-radius: 6px; `; -export const CardIcon = styled(Icon)` - margin-right: 0.5rem; -`; - export const CardText = styled.span` font-weight: 700; line-height: 1rem; diff --git a/frontend/src/metabase/auth/components/AuthButton/AuthButton.tsx b/frontend/src/metabase/auth/components/AuthButton/AuthButton.tsx index f0e6d4daa61cbdd766ce8fb227dae1930dc2da22..8073db5974b0013f24061e68dfa673515c9e06c9 100644 --- a/frontend/src/metabase/auth/components/AuthButton/AuthButton.tsx +++ b/frontend/src/metabase/auth/components/AuthButton/AuthButton.tsx @@ -1,24 +1,21 @@ import React, { ReactNode } from "react"; -import { CardIcon, CardLink, CardText, TextLink } from "./AuthButton.styled"; +import { CardLink, CardText, TextLink } from "./AuthButton.styled"; -export interface AuthButtonProps { +interface AuthButtonProps { link?: string; - icon?: string; isCard?: boolean; children?: ReactNode; onClick?: () => void; } -const AuthButton = ({ +export const AuthButton = ({ link = "", - icon, isCard, children, onClick, }: AuthButtonProps): JSX.Element => { return isCard ? ( <CardLink to={link} onClick={onClick}> - {icon && <CardIcon name={icon} />} <CardText>{children}</CardText> </CardLink> ) : ( @@ -27,6 +24,3 @@ const AuthButton = ({ </TextLink> ); }; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default AuthButton; diff --git a/frontend/src/metabase/auth/components/AuthButton/AuthButton.unit.spec.tsx b/frontend/src/metabase/auth/components/AuthButton/AuthButton.unit.spec.tsx index ccf6b299ce1e4932028c8d3bceb2ae7fffc0ba9d..df2452a0d7f8e9c1621c47c4143bf1a710b24afe 100644 --- a/frontend/src/metabase/auth/components/AuthButton/AuthButton.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/AuthButton/AuthButton.unit.spec.tsx @@ -1,21 +1,24 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; -import AuthButton from "./AuthButton"; +import { renderWithProviders, screen } from "__support__/ui"; +import { AuthButton } from "./AuthButton"; + +interface SetupOpts { + isCard?: boolean; +} + +const setup = ({ isCard }: SetupOpts = {}) => { + renderWithProviders(<AuthButton isCard={isCard}>Sign in</AuthButton>); +}; describe("AuthButton", () => { it("should render a card", () => { - render( - <AuthButton icon="google" isCard={true}> - Sign in - </AuthButton>, - ); + setup({ isCard: true }); expect(screen.getByText("Sign in")).toBeInTheDocument(); - expect(screen.getByLabelText("google icon")).toBeInTheDocument(); }); it("should render a link", () => { - render(<AuthButton>Sign in</AuthButton>); + setup(); expect(screen.getByText("Sign in")).toBeInTheDocument(); }); diff --git a/frontend/src/metabase/auth/components/AuthButton/index.ts b/frontend/src/metabase/auth/components/AuthButton/index.ts index 99dd939a01a6da73971c1924594091aa1f7296ec..74f40bf0532e70eee995ab76bc19531d41de3a0c 100644 --- a/frontend/src/metabase/auth/components/AuthButton/index.ts +++ b/frontend/src/metabase/auth/components/AuthButton/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./AuthButton"; +export * from "./AuthButton"; diff --git a/frontend/src/metabase/auth/components/AuthLayout/AuthLayout.tsx b/frontend/src/metabase/auth/components/AuthLayout/AuthLayout.tsx index c3d4b299452b165da2176e7a47949ae0d35f81f7..e4ab86648c54568dac9c797c5bb94496d2de2e41 100644 --- a/frontend/src/metabase/auth/components/AuthLayout/AuthLayout.tsx +++ b/frontend/src/metabase/auth/components/AuthLayout/AuthLayout.tsx @@ -1,5 +1,7 @@ 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; diff --git a/frontend/src/metabase/auth/components/AuthLayout/index.ts b/frontend/src/metabase/auth/components/AuthLayout/index.ts index d2486700846e8f2b98799e8d24ea28f91105aea1..30e3bd9490dade6d5e9f4a45e3a164470ddde4c4 100644 --- a/frontend/src/metabase/auth/components/AuthLayout/index.ts +++ b/frontend/src/metabase/auth/components/AuthLayout/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./AuthLayout"; +export * from "./AuthLayout"; diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx index 7de72315d25180566449a9743362458685d295ff..cf8013c64b638d7eb16ea06eab09839554d911c2 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.tsx @@ -1,8 +1,12 @@ 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; diff --git a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx index 6ee770f67d4e7dda383eb525282079b2dcbd3bfb..397f588a2b945d1adc32ba4fb1a0220d8676b070 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/ForgotPassword/ForgotPassword.unit.spec.tsx @@ -1,60 +1,62 @@ -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); diff --git a/frontend/src/metabase/auth/components/ForgotPassword/index.ts b/frontend/src/metabase/auth/components/ForgotPassword/index.ts index 263142555dce09e67a8d6ceedc82ed67b8d96cb5..b3376b429bc420c7363490d8819a8bdcd0e92d7f 100644 --- a/frontend/src/metabase/auth/components/ForgotPassword/index.ts +++ b/frontend/src/metabase/auth/components/ForgotPassword/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ForgotPassword"; +export * from "./ForgotPassword"; diff --git a/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx index 940534d12be51753d88bc4c715a35e5240364083..e8edd83bdb5979f741be2c11462569b642a33715 100644 --- a/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx +++ b/frontend/src/metabase/auth/components/ForgotPasswordForm/ForgotPasswordForm.tsx @@ -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; diff --git a/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts b/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts index 4a89912858560fb6b3ec55d36ccdea57d85d761d..d594a39d2da0ad0cde77ddb727ee336aa92d6138 100644 --- a/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts +++ b/frontend/src/metabase/auth/components/ForgotPasswordForm/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ForgotPasswordForm"; +export * from "./ForgotPasswordForm"; diff --git a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx index 1c6ccd92f01958fd33307ef6d2a64328f942c9c1..edc824487afd44ff3f5ec324b82149d2480dd981 100644 --- a/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx +++ b/frontend/src/metabase/auth/components/GoogleButton/GoogleButton.tsx @@ -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; diff --git a/frontend/src/metabase/auth/components/GoogleButton/index.ts b/frontend/src/metabase/auth/components/GoogleButton/index.ts index 077d745d8ae34ac0711dc7e66e46a17c5a00244d..3d53a8c344c58eaef8734adf6bf888e39dc0f593 100644 --- a/frontend/src/metabase/auth/components/GoogleButton/index.ts +++ b/frontend/src/metabase/auth/components/GoogleButton/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./GoogleButton"; +export * from "./GoogleButton"; diff --git a/frontend/src/metabase/auth/components/Login/Login.tsx b/frontend/src/metabase/auth/components/Login/Login.tsx index 0571e567b5f7862f48578f98ac34c1e4fe4fdf2c..c565be27a4647dc2056300f5c47ebb5541972124 100644 --- a/frontend/src/metabase/auth/components/Login/Login.tsx +++ b/frontend/src/metabase/auth/components/Login/Login.tsx @@ -1,7 +1,10 @@ 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; diff --git a/frontend/src/metabase/auth/components/Login/Login.unit.spec.tsx b/frontend/src/metabase/auth/components/Login/Login.unit.spec.tsx index 8ef9d0e2f2ed80d22432c6f59470c8e291cc2abd..3d4f39f908bead914b14c476fd096ba672d9ae40 100644 --- a/frontend/src/metabase/auth/components/Login/Login.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/Login/Login.unit.spec.tsx @@ -1,66 +1,75 @@ -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); diff --git a/frontend/src/metabase/auth/components/Login/index.ts b/frontend/src/metabase/auth/components/Login/index.ts index 41a4e665dd05db6749bdd7b17524bc4571601daf..2b0a75c361244c8f62196a76e4b4102e5cc85c51 100644 --- a/frontend/src/metabase/auth/components/Login/index.ts +++ b/frontend/src/metabase/auth/components/Login/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./Login"; +export * from "./Login"; diff --git a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx index f9e94a47f3c0db06d22ea9cd598744a92497ff92..eaddfe8f95687c4730e205b48faa8a0191f96107 100644 --- a/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx +++ b/frontend/src/metabase/auth/components/LoginForm/LoginForm.tsx @@ -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; diff --git a/frontend/src/metabase/auth/components/LoginForm/index.ts b/frontend/src/metabase/auth/components/LoginForm/index.ts index a43cda62682fc642d8c6fd1285d786f80f8389ea..1cad6f418a35ee8307e98724cfbddce6b5b00628 100644 --- a/frontend/src/metabase/auth/components/LoginForm/index.ts +++ b/frontend/src/metabase/auth/components/LoginForm/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./LoginForm"; +export * from "./LoginForm"; diff --git a/frontend/src/metabase/auth/components/Logout/Logout.tsx b/frontend/src/metabase/auth/components/Logout/Logout.tsx index 0ce511f62309651cd86e2f52583dca0c50fa3d67..722a20a76608bec029d4c09af2d9732e071cfb79 100644 --- a/frontend/src/metabase/auth/components/Logout/Logout.tsx +++ b/frontend/src/metabase/auth/components/Logout/Logout.tsx @@ -1,15 +1,13 @@ 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; diff --git a/frontend/src/metabase/auth/components/Logout/Logout.unit.spec.tsx b/frontend/src/metabase/auth/components/Logout/Logout.unit.spec.tsx index 0c199146c4a9c62ddaf1ee76cbc8db331d471fe9..6b97a52ffc6dc106c0aeda956032826aa9661421 100644 --- a/frontend/src/metabase/auth/components/Logout/Logout.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/Logout/Logout.unit.spec.tsx @@ -1,13 +1,28 @@ 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()); }); }); diff --git a/frontend/src/metabase/auth/components/Logout/index.ts b/frontend/src/metabase/auth/components/Logout/index.ts index 52935ff6628d2b11e76d3372269164916ef5cde2..ca4c0a0303ebf8bc78b94ae87eaa64a35134d238 100644 --- a/frontend/src/metabase/auth/components/Logout/index.ts +++ b/frontend/src/metabase/auth/components/Logout/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./Logout"; +export * from "./Logout"; diff --git a/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.tsx b/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.tsx index b7724454464050c246e97562ecfad8c6e39f6edd..8ba15511e4602c86b3bb1f8620cbac0b6b1d0878 100644 --- a/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.tsx +++ b/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.tsx @@ -1,28 +1,22 @@ 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; diff --git a/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.unit.spec.tsx b/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.unit.spec.tsx index 385f5d9419e236e6d5e8fee687361198c6e48488..a43abc8ce187d266dc42be54101f929b9a030e44 100644 --- a/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/PasswordButton/PasswordButton.unit.spec.tsx @@ -1,28 +1,35 @@ 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, -}); diff --git a/frontend/src/metabase/auth/components/PasswordButton/index.ts b/frontend/src/metabase/auth/components/PasswordButton/index.ts index bec09e6cb728712021640bd078184ac4b6d1bcb7..f49cd1686893972accc59d2be3159bab22e27fcf 100644 --- a/frontend/src/metabase/auth/components/PasswordButton/index.ts +++ b/frontend/src/metabase/auth/components/PasswordButton/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./PasswordButton"; +export * from "./PasswordButton"; diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx index c062129d2df7735ddbd9f93c013c763cc91f59f2..66ffed0c6b6a57cb4bd6fa0fd750859d05eb09c8 100644 --- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx +++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.tsx @@ -1,30 +1,32 @@ import React, { useCallback } from "react"; import { t } from "ttag"; -import AuthButton from "../AuthButton"; -import LoginForm from "../LoginForm"; -import { AuthProvider, LoginData } from "../../types"; -import { ActionListItem, ActionList } from "./PasswordPanel.styled"; +import { useDispatch, useSelector } from "metabase/lib/redux"; +import { login } from "../../actions"; +import { + getExternalAuthProviders, + getHasSessionCookies, + getIsLdapEnabled, +} from "../../selectors"; +import { LoginData } from "../../types"; +import { AuthButton } from "../AuthButton"; +import { LoginForm } from "../LoginForm"; +import { ActionList, ActionListItem } from "./PasswordPanel.styled"; -export interface PasswordPanelProps { - providers?: AuthProvider[]; +interface PasswordPanelProps { redirectUrl?: string; - isLdapEnabled: boolean; - hasSessionCookies: boolean; - onLogin: (data: LoginData, redirectUrl?: string) => void; } -const PasswordPanel = ({ - providers = [], - redirectUrl, - isLdapEnabled, - hasSessionCookies, - onLogin, -}: PasswordPanelProps) => { +export const PasswordPanel = ({ redirectUrl }: PasswordPanelProps) => { + const providers = useSelector(getExternalAuthProviders); + const isLdapEnabled = useSelector(getIsLdapEnabled); + const hasSessionCookies = useSelector(getHasSessionCookies); + const dispatch = useDispatch(); + const handleSubmit = useCallback( async (data: LoginData) => { - await onLogin(data, redirectUrl); + await dispatch(login({ data, redirectUrl })).unwrap(); }, - [onLogin, redirectUrl], + [dispatch, redirectUrl], ); return ( @@ -49,6 +51,3 @@ const PasswordPanel = ({ </div> ); }; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default PasswordPanel; diff --git a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx index 35632b19f9e7bbfd2639ec0d94fd58edb1452652..9f13ecf689d4cea9d4abf9b69d2bf6cd98c8b2d2 100644 --- a/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/PasswordPanel/PasswordPanel.unit.spec.tsx @@ -1,19 +1,49 @@ import React from "react"; +import fetchMock from "fetch-mock"; import userEvent from "@testing-library/user-event"; -import { render, screen, waitFor } from "__support__/ui"; -import { AuthProvider } from "metabase/auth/types"; -import PasswordPanel, { PasswordPanelProps } from "./PasswordPanel"; +import MetabaseSettings from "metabase/lib/settings"; +import { + createMockSettingsState, + createMockState, +} from "metabase-types/store/mocks"; +import { setupLoginEndpoint } from "__support__/server-mocks"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { PasswordPanel } from "./PasswordPanel"; -const NO_REDIRECT_URL_PARAM = undefined; +const TEST_EMAIL = "user@example.test"; +const TEST_PASSWORD = "password"; + +interface SetupOpts { + isGoogleAuthEnabled?: boolean; +} + +const setup = ({ isGoogleAuthEnabled = false }: SetupOpts = {}) => { + const state = createMockState({ + settings: createMockSettingsState({ + "google-auth-enabled": isGoogleAuthEnabled, + }), + }); + + MetabaseSettings.set("google-auth-enabled", isGoogleAuthEnabled); + + setupLoginEndpoint(); + renderWithProviders(<PasswordPanel />, { storeInitialState: state }); +}; + +const cleanUp = () => { + MetabaseSettings.set("google-auth-enabled", false); +}; describe("PasswordPanel", () => { + afterEach(() => { + cleanUp(); + }); + it("should login successfully", async () => { - const props = getProps(); - const data = { username: "user@example.test", password: "password" }; + setup(); - render(<PasswordPanel {...props} />); - userEvent.type(screen.getByLabelText("Email address"), data.username); - userEvent.type(screen.getByLabelText("Password"), data.password); + userEvent.type(screen.getByLabelText("Email address"), TEST_EMAIL); + userEvent.type(screen.getByLabelText("Password"), TEST_PASSWORD); await waitFor(() => { expect(screen.getByRole("button", { name: "Sign in" })).toBeEnabled(); @@ -22,35 +52,14 @@ describe("PasswordPanel", () => { userEvent.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - expect(props.onLogin).toHaveBeenCalledWith( - { ...data, remember: true }, - NO_REDIRECT_URL_PARAM, - ); + expect(fetchMock.done("path:/api/session")).toBe(true); }); }); it("should render a link to reset the password and a list of auth providers", () => { - const props = getProps({ providers: [getAuthProvider()] }); - - render(<PasswordPanel {...props} />); + setup({ isGoogleAuthEnabled: true }); expect(screen.getByText(/forgotten my password/)).toBeInTheDocument(); expect(screen.getByText("Sign in with Google")).toBeInTheDocument(); }); }); - -const getProps = (opts?: Partial<PasswordPanelProps>): PasswordPanelProps => ({ - providers: [], - isLdapEnabled: false, - hasSessionCookies: false, - onLogin: jest.fn(), - ...opts, -}); - -const getAuthProvider = (opts?: Partial<AuthProvider>): AuthProvider => ({ - name: "google", - Button: AuthButtonMock, - ...opts, -}); - -const AuthButtonMock = () => <a href="/">Sign in with Google</a>; diff --git a/frontend/src/metabase/auth/components/PasswordPanel/index.ts b/frontend/src/metabase/auth/components/PasswordPanel/index.ts index fc11a70348556ccb0db81d477807fee9c6fc7fa5..797913ce56b5d982a38ef0ac38d745b5ced471d2 100644 --- a/frontend/src/metabase/auth/components/PasswordPanel/index.ts +++ b/frontend/src/metabase/auth/components/PasswordPanel/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./PasswordPanel"; +export * from "./PasswordPanel"; diff --git a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx index d3511320356040a96f5fcc899f006388f46885d6..c4cfa0876cfacd8593b534ce6625767f0f161fd7 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx +++ b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.tsx @@ -1,49 +1,53 @@ import React, { useCallback, useEffect, useState } from "react"; +import { replace } from "react-router-redux"; import { t } from "ttag"; +import { useDispatch } from "metabase/lib/redux"; +import { addUndo } from "metabase/redux/undo"; import Button from "metabase/core/components/Button"; import Link from "metabase/core/components/Link"; -import AuthLayout from "../../containers/AuthLayout"; -import ResetPasswordForm from "../ResetPasswordForm"; +import { AuthLayout } from "../AuthLayout"; +import { + resetPassword, + validatePassword, + validatePasswordToken, +} from "../../actions"; import { ResetPasswordData } from "../../types"; +import { ResetPasswordForm } from "../ResetPasswordForm"; import { InfoBody, InfoMessage, InfoTitle } from "./ResetPassword.styled"; -type ViewType = "none" | "form" | "success" | "expired"; +type ViewType = "none" | "form" | "expired"; -export interface ResetPasswordProps { +interface ResetPasswordQueryParams { token: string; - onResetPassword: (token: string, password: string) => void; - onValidatePassword: (password: string) => Promise<string | undefined>; - onValidatePasswordToken: (token: string) => void; - onShowToast: (toast: { message: string }) => void; - onRedirect: (url: string) => void; } -const ResetPassword = ({ - token, - onResetPassword, - onValidatePassword, - onValidatePasswordToken, - onShowToast, - onRedirect, +interface ResetPasswordProps { + params: ResetPasswordQueryParams; +} + +export const ResetPassword = ({ + params, }: ResetPasswordProps): JSX.Element | null => { + const { token } = params; const [view, setView] = useState<ViewType>("none"); + const dispatch = useDispatch(); const handleLoad = useCallback(async () => { try { - await onValidatePasswordToken(token); + await validatePasswordToken(token); setView("form"); } catch (error) { setView("expired"); } - }, [token, onValidatePasswordToken]); + }, [token]); const handlePasswordSubmit = useCallback( async ({ password }: ResetPasswordData) => { - await onResetPassword(token, password); - onRedirect("/"); - onShowToast({ message: t`You've updated your password.` }); + await dispatch(resetPassword({ token, password })).unwrap(); + dispatch(replace("/")); + dispatch(addUndo({ message: t`You've updated your password.` })); }, - [onResetPassword, token, onRedirect, onShowToast], + [token, dispatch], ); useEffect(() => { @@ -54,7 +58,7 @@ const ResetPassword = ({ <AuthLayout> {view === "form" && ( <ResetPasswordForm - onValidatePassword={onValidatePassword} + onValidatePassword={validatePassword} onSubmit={handlePasswordSubmit} /> )} @@ -76,6 +80,3 @@ const ResetPasswordExpired = (): JSX.Element => { </InfoBody> ); }; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default ResetPassword; diff --git a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx index 82529d56b07d5b4549d88bb0428a49e29c435029..92b2b5ff50023d45d63169cbde1d112bd5b7f738 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx +++ b/frontend/src/metabase/auth/components/ResetPassword/ResetPassword.unit.spec.tsx @@ -1,87 +1,66 @@ -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 ResetPassword, { ResetPasswordProps } from "./ResetPassword"; +import { createMockSettings, createMockUser } from "metabase-types/api/mocks"; +import { + setupCurrentUserEndpoint, + setupPasswordCheckEndpoint, + setupPasswordResetTokenEndpoint, + setupPropertiesEndpoints, + setupResetPasswordEndpoint, +} from "__support__/server-mocks"; +import { renderWithProviders, screen, waitFor } from "__support__/ui"; +import { ResetPassword } from "./ResetPassword"; + +interface SetupOpts { + isTokenValid?: boolean; +} -describe("ResetPassword", () => { - it("should show a form when token validations succeeds", async () => { - const props = getProps({ - onValidatePasswordToken: jest.fn().mockResolvedValue({}), - }); +const setup = ({ isTokenValid = true }: SetupOpts = {}) => { + setupPasswordResetTokenEndpoint({ valid: isTokenValid }); + setupResetPasswordEndpoint(); + setupPasswordCheckEndpoint(); + setupCurrentUserEndpoint(createMockUser()); + setupPropertiesEndpoints(createMockSettings()); + + renderWithProviders( + <> + <Route path="/" component={TestHome} /> + <Route path="/auth/reset_password/:token" component={ResetPassword} /> + </>, + { + withRouter: true, + initialRoute: "/auth/reset_password/token", + }, + ); +}; - render(<ResetPassword {...props} />); +const TestHome = () => <div>Home</div>; - await waitFor(() => { - expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); - }); - expect(screen.getByText("New password")).toBeInTheDocument(); +describe("ResetPassword", () => { + it("should show a form when token validations succeeds", async () => { + setup({ isTokenValid: true }); + expect(await screen.findByText("New password")).toBeInTheDocument(); }); it("should show an error message when token validation fails", async () => { - const props = getProps({ - onValidatePasswordToken: jest.fn().mockRejectedValue({}), - }); - - render(<ResetPassword {...props} />); - - await waitFor(() => { - expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); - }); - expect(screen.getByText(/that's an expired link/)).toBeInTheDocument(); + setup({ isTokenValid: false }); + expect( + await screen.findByText(/that's an expired link/), + ).toBeInTheDocument(); }); it("should show a success message when the form is submitted", async () => { - const props = getProps({ - onResetPassword: jest.fn().mockResolvedValue({}), - onValidatePassword: jest.fn().mockResolvedValue(undefined), - onValidatePasswordToken: jest.fn().mockResolvedValue({}), - }); - - render(<ResetPassword {...props} />); - - await waitFor(() => { - expect(props.onValidatePasswordToken).toHaveBeenCalledWith(props.token); - }); - expect(screen.getByText("New password")).toBeInTheDocument(); + setup({ isTokenValid: true }); + expect(await screen.findByText("New password")).toBeInTheDocument(); userEvent.type(screen.getByLabelText("Create a password"), "test"); userEvent.type(screen.getByLabelText("Confirm your password"), "test"); - await waitFor(() => { - expect(props.onValidatePassword).toHaveBeenCalledWith("test"); + expect(screen.getByText("Save new password")).toBeEnabled(); }); - expect(screen.getByText("Save new password")).toBeEnabled(); userEvent.click(screen.getByText("Save new password")); - - await waitFor(() => { - expect(props.onResetPassword).toHaveBeenCalledWith(props.token, "test"); - }); - expect(props.onRedirect).toHaveBeenCalledWith("/"); - expect(props.onShowToast).toHaveBeenCalledWith({ - message: "You've updated your password.", - }); + expect(await screen.findByText("Home")).toBeInTheDocument(); }); }); - -const getProps = (opts?: Partial<ResetPasswordProps>): ResetPasswordProps => { - return { - token: "token", - onResetPassword: jest.fn(), - onValidatePassword: jest.fn(), - onValidatePasswordToken: jest.fn(), - onShowToast: jest.fn(), - onRedirect: jest.fn(), - ...opts, - }; -}; - -interface AuthLayoutMockProps { - children?: ReactNode; -} - -const AuthLayoutMock = ({ children }: AuthLayoutMockProps) => { - return <div>{children}</div>; -}; - -jest.mock("../../containers/AuthLayout", () => AuthLayoutMock); diff --git a/frontend/src/metabase/auth/components/ResetPassword/index.ts b/frontend/src/metabase/auth/components/ResetPassword/index.ts index 86d695e57e9b4698f8b9d664fec1c8aee8c12ba2..795c2ccaf7557568e2d098beeaab00ba876ac761 100644 --- a/frontend/src/metabase/auth/components/ResetPassword/index.ts +++ b/frontend/src/metabase/auth/components/ResetPassword/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ResetPassword"; +export * from "./ResetPassword"; diff --git a/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx index 47fea0cf85c31645b27a6d0ced55fad3db47209b..5ba748830ba82bcdb37fc20518f0482f12caf654 100644 --- a/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx +++ b/frontend/src/metabase/auth/components/ResetPasswordForm/ResetPasswordForm.tsx @@ -34,7 +34,7 @@ interface ResetPasswordFormProps { onSubmit: (data: ResetPasswordData) => void; } -const ResetPasswordForm = ({ +export const ResetPasswordForm = ({ onValidatePassword, onSubmit, }: ResetPasswordFormProps): JSX.Element => { @@ -86,6 +86,3 @@ const ResetPasswordForm = ({ </div> ); }; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default ResetPasswordForm; diff --git a/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts b/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts index 08304903847209155f224980cb6be94af5b67e75..090b601ad708fa29c72edb5c05eafabe42531e70 100644 --- a/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts +++ b/frontend/src/metabase/auth/components/ResetPasswordForm/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ResetPasswordForm"; +export * from "./ResetPasswordForm"; diff --git a/frontend/src/metabase/auth/containers/AuthLayout/AuthLayout.tsx b/frontend/src/metabase/auth/containers/AuthLayout/AuthLayout.tsx deleted file mode 100644 index 5376dc2139655168fdd19756238a3ab214521225..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/AuthLayout/AuthLayout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { connect } from "react-redux"; -import { getSetting } from "metabase/selectors/settings"; -import type { State } from "metabase-types/store"; -import AuthLayout from "../../components/AuthLayout"; - -const mapStateToProps = (state: State) => ({ - showIllustration: getSetting(state, "show-lighthouse-illustration"), -}); - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps)(AuthLayout); diff --git a/frontend/src/metabase/auth/containers/AuthLayout/index.ts b/frontend/src/metabase/auth/containers/AuthLayout/index.ts deleted file mode 100644 index d2486700846e8f2b98799e8d24ea28f91105aea1..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/AuthLayout/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./AuthLayout"; diff --git a/frontend/src/metabase/auth/containers/ForgotPasswordApp/ForgotPasswordApp.tsx b/frontend/src/metabase/auth/containers/ForgotPasswordApp/ForgotPasswordApp.tsx deleted file mode 100644 index 376bb780004995fc8b3cf88d5909676f21545129..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/ForgotPasswordApp/ForgotPasswordApp.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from "react-redux"; -import MetabaseSettings from "metabase/lib/settings"; -import ForgotPassword from "../../components/ForgotPassword"; -import { forgotPassword } from "../../actions"; - -const canResetPassword = () => { - const isEmailConfigured = MetabaseSettings.isEmailConfigured(); - const isLdapEnabled = MetabaseSettings.isLdapEnabled(); - return isEmailConfigured && !isLdapEnabled; -}; - -const mapStateToProps = (state: any, props: any) => ({ - canResetPassword: canResetPassword(), - initialEmail: props.location.query.email, -}); - -const mapDispatchToProps = { - onResetPassword: forgotPassword, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, mapDispatchToProps)(ForgotPassword); diff --git a/frontend/src/metabase/auth/containers/ForgotPasswordApp/index.ts b/frontend/src/metabase/auth/containers/ForgotPasswordApp/index.ts deleted file mode 100644 index 2c8a60e5c75e106997a29da807d1800dbfa72084..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/ForgotPasswordApp/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ForgotPasswordApp"; diff --git a/frontend/src/metabase/auth/containers/GoogleButton/GoogleButton.tsx b/frontend/src/metabase/auth/containers/GoogleButton/GoogleButton.tsx deleted file mode 100644 index 774794360dda68fc9c65e43bc9e3ea2535a6195f..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/GoogleButton/GoogleButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from "react-redux"; -import { getSetting } from "metabase/selectors/settings"; -import type { State } from "metabase-types/store"; -import GoogleButton from "../../components/GoogleButton"; -import { loginGoogle } from "../../actions"; - -const mapStateToProps = (state: State) => ({ - clientId: getSetting(state, "google-auth-client-id"), - locale: getSetting(state, "site-locale"), -}); - -const mapDispatchToProps = { - onLogin: loginGoogle, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, mapDispatchToProps)(GoogleButton); diff --git a/frontend/src/metabase/auth/containers/GoogleButton/index.ts b/frontend/src/metabase/auth/containers/GoogleButton/index.ts deleted file mode 100644 index 077d745d8ae34ac0711dc7e66e46a17c5a00244d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/GoogleButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./GoogleButton"; diff --git a/frontend/src/metabase/auth/containers/LoginApp/LoginApp.tsx b/frontend/src/metabase/auth/containers/LoginApp/LoginApp.tsx deleted file mode 100644 index 231097f2022d82487aaffe36943c5c87df655736..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/LoginApp/LoginApp.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { connect } from "react-redux"; -import Login from "../../components/Login"; -import { getAuthProviders } from "../../selectors"; - -const mapStateToProps = (state: any, props: any) => ({ - providers: getAuthProviders(state), - providerName: props.params.provider, - redirectUrl: props.location.query.redirect, -}); - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps)(Login); diff --git a/frontend/src/metabase/auth/containers/LoginApp/index.ts b/frontend/src/metabase/auth/containers/LoginApp/index.ts deleted file mode 100644 index f5b3eca7e1f71bd24a959ad431baf3d3c7adb6cf..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/LoginApp/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./LoginApp"; diff --git a/frontend/src/metabase/auth/containers/LogoutApp/LogoutApp.tsx b/frontend/src/metabase/auth/containers/LogoutApp/LogoutApp.tsx deleted file mode 100644 index 7a0faba288d4efa1ae56ec78a0aa06f34ff1e015..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/LogoutApp/LogoutApp.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { connect } from "react-redux"; -import { logout } from "../../actions"; -import Logout from "../../components/Logout"; - -const mapDispatchToProps = { - onLogout: logout, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(null, mapDispatchToProps)(Logout); diff --git a/frontend/src/metabase/auth/containers/LogoutApp/index.ts b/frontend/src/metabase/auth/containers/LogoutApp/index.ts deleted file mode 100644 index e4a8be262cfb728bee444fe7e2f47c1a548451c6..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/LogoutApp/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./LogoutApp"; diff --git a/frontend/src/metabase/auth/containers/PasswordButton/PasswordButton.tsx b/frontend/src/metabase/auth/containers/PasswordButton/PasswordButton.tsx deleted file mode 100644 index 7943c8361a3ec1897b2230a8ca7a2567121ba511..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/PasswordButton/PasswordButton.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { connect } from "react-redux"; -import { getSetting } from "metabase/selectors/settings"; -import type { State } from "metabase-types/store"; -import PasswordButton from "../../components/PasswordButton"; - -const mapStateToProps = (state: State) => ({ - isLdapEnabled: getSetting(state, "ldap-enabled"), -}); - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, null)(PasswordButton); diff --git a/frontend/src/metabase/auth/containers/PasswordButton/index.ts b/frontend/src/metabase/auth/containers/PasswordButton/index.ts deleted file mode 100644 index bec09e6cb728712021640bd078184ac4b6d1bcb7..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/PasswordButton/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./PasswordButton"; diff --git a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx b/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx deleted file mode 100644 index b809ee32ef4cea9fdc9e01f7eb08b12bb54bcbff..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/PasswordPanel/PasswordPanel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { connect } from "react-redux"; -import { getSetting } from "metabase/selectors/settings"; -import { getExternalAuthProviders } from "metabase/auth/selectors"; -import type { State } from "metabase-types/store"; -import { login } from "../../actions"; -import PasswordPanel from "../../components/PasswordPanel"; - -const mapStateToProps = (state: State) => ({ - providers: getExternalAuthProviders(state), - isLdapEnabled: getSetting(state, "ldap-enabled"), - hasSessionCookies: getSetting(state, "session-cookies") ?? false, -}); - -const mapDispatchToProps = { - onLogin: login, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, mapDispatchToProps)(PasswordPanel); diff --git a/frontend/src/metabase/auth/containers/PasswordPanel/index.ts b/frontend/src/metabase/auth/containers/PasswordPanel/index.ts deleted file mode 100644 index fc11a70348556ccb0db81d477807fee9c6fc7fa5..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/PasswordPanel/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./PasswordPanel"; diff --git a/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx b/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx deleted file mode 100644 index a72e6801fe18abbd28c2b7c0534f920641eecec8..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/ResetPasswordApp/ResetPasswordApp.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from "react-redux"; -import { replace } from "react-router-redux"; -import { addUndo } from "metabase/redux/undo"; -import ResetPassword from "../../components/ResetPassword"; -import { - resetPassword, - validatePassword, - validatePasswordToken, -} from "../../actions"; - -const mapStateToProps = (state: any, props: any) => ({ - token: props.params.token, - onValidatePassword: validatePassword, -}); - -const mapDispatchToProps = { - onResetPassword: resetPassword, - onValidatePasswordToken: validatePasswordToken, - onShowToast: addUndo, - onRedirect: replace, -}; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default connect(mapStateToProps, mapDispatchToProps)(ResetPassword); diff --git a/frontend/src/metabase/auth/containers/ResetPasswordApp/index.ts b/frontend/src/metabase/auth/containers/ResetPasswordApp/index.ts deleted file mode 100644 index 2b9b38a54192732844f784abc0f8c560cf331e03..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/auth/containers/ResetPasswordApp/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./ResetPasswordApp"; diff --git a/frontend/src/metabase/auth/selectors.ts b/frontend/src/metabase/auth/selectors.ts index 3e591d6347a3096c6cbcdcf2a0180e585adb6fbe..67d9ca6a51ba05a955a1d64e7730a5e00264549d 100644 --- a/frontend/src/metabase/auth/selectors.ts +++ b/frontend/src/metabase/auth/selectors.ts @@ -1,21 +1,43 @@ import { createSelector } from "@reduxjs/toolkit"; - -import { getSettings } from "metabase/selectors/settings"; import { PLUGIN_AUTH_PROVIDERS } from "metabase/plugins"; +import { getSetting, getSettings } from "metabase/selectors/settings"; +import { AuthProvider } from "metabase/plugins/types"; +import { State } from "metabase-types/store"; -import type { AuthProvider } from "./types"; +const EMPTY_PROVIDERS: AuthProvider[] = []; -export const getAuthProviders = createSelector( - [getSettings], - (): AuthProvider[] => - PLUGIN_AUTH_PROVIDERS.reduce( - (providers: any, getProviders: (providers: any) => any) => - getProviders(providers), - [], - ), +export const getAuthProviders = createSelector([getSettings], () => + PLUGIN_AUTH_PROVIDERS.reduce( + (providers, getProviders) => getProviders(providers), + EMPTY_PROVIDERS, + ), ); export const getExternalAuthProviders = createSelector( [getAuthProviders], providers => providers.filter(provider => provider.name !== "password"), ); + +export const getIsEmailConfigured = (state: State) => { + return getSetting(state, "email-configured?"); +}; + +export const getIsLdapEnabled = (state: State) => { + return getSetting(state, "ldap-enabled"); +}; + +export const getHasSessionCookies = (state: State) => { + return getSetting(state, "session-cookies") ?? false; +}; + +export const getHasIllustration = (state: State) => { + return getSetting(state, "show-lighthouse-illustration"); +}; + +export const getSiteLocale = (state: State) => { + return getSetting(state, "site-locale"); +}; + +export const getGoogleClientId = (state: State) => { + return getSetting(state, "google-auth-client-id"); +}; diff --git a/frontend/src/metabase/auth/types.ts b/frontend/src/metabase/auth/types.ts index 56db8b5d314431615dfce35c97687f1151a908be..1b2fdc4c98fdab28754b7f54fb910be1cc968961 100644 --- a/frontend/src/metabase/auth/types.ts +++ b/frontend/src/metabase/auth/types.ts @@ -1,20 +1,3 @@ -import { ComponentType } from "react"; - -export interface AuthProvider { - name: string; - Button: ComponentType<AuthProviderButtonProps>; - Panel?: ComponentType<AuthProviderPanelProps>; -} - -export interface AuthProviderButtonProps { - isCard?: boolean; - redirectUrl?: string; -} - -export interface AuthProviderPanelProps { - redirectUrl?: string; -} - export interface LoginData { username: string; password: string; diff --git a/frontend/src/metabase/lib/urls/auth.ts b/frontend/src/metabase/lib/urls/auth.ts index 2fe58a88975f06f5f1a8a447c5feece17d6530a1..e7478513cdd8a3bcfc9caa6737ce5ced9ac9cc71 100644 --- a/frontend/src/metabase/lib/urls/auth.ts +++ b/frontend/src/metabase/lib/urls/auth.ts @@ -3,3 +3,9 @@ export const login = (redirectUrl?: string) => { ? `/auth/login?redirect=${encodeURIComponent(redirectUrl)}` : "/auth/login"; }; + +export const password = (redirectUrl?: string) => { + return redirectUrl + ? `/auth/login/password?redirect=${encodeURIComponent(redirectUrl)}` + : `/auth/login/password`; +}; diff --git a/frontend/src/metabase/plugins/builtin/auth/google.js b/frontend/src/metabase/plugins/builtin/auth/google.js index 75eba2180fab337112702493b871d8c1d549bb9b..20241f682727f6ca697e0e49c2fd88800d6bcc08 100644 --- a/frontend/src/metabase/plugins/builtin/auth/google.js +++ b/frontend/src/metabase/plugins/builtin/auth/google.js @@ -16,7 +16,7 @@ PLUGIN_AUTH_PROVIDERS.push(providers => { const googleProvider = { name: "google", // circular dependencies - Button: require("metabase/auth/containers/GoogleButton").default, + Button: require("metabase/auth/components/GoogleButton").GoogleButton, }; return MetabaseSettings.isGoogleAuthEnabled() diff --git a/frontend/src/metabase/plugins/builtin/auth/password.js b/frontend/src/metabase/plugins/builtin/auth/password.js index 05da06692a7eae37b799f20878dde191925b07b4..15f030d5d370737bd0a2e89453c5183910f1dfb9 100644 --- a/frontend/src/metabase/plugins/builtin/auth/password.js +++ b/frontend/src/metabase/plugins/builtin/auth/password.js @@ -4,8 +4,8 @@ PLUGIN_AUTH_PROVIDERS.push(providers => { const passwordProvider = { name: "password", // circular dependencies - Button: require("metabase/auth/containers/PasswordButton").default, - Panel: require("metabase/auth/containers/PasswordPanel").default, + Button: require("metabase/auth/components/PasswordButton").PasswordButton, + Panel: require("metabase/auth/components/PasswordPanel").PasswordPanel, }; return [...providers, passwordProvider]; diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index bab05eb3a81da0d19e07038db7cda4f47708e438..bb5b00b141ef16cc0e1f60ef6324d2eb2f389498 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -23,7 +23,7 @@ import type { import type { AdminPathKey, State } from "metabase-types/store"; import type Question from "metabase-lib/Question"; -import { PluginGroupManagersType } from "./types"; +import { GetAuthProviders, PluginGroupManagersType } from "./types"; // functions called when the application is started export const PLUGIN_APP_INIT_FUCTIONS = []; @@ -79,7 +79,7 @@ export const PLUGIN_ADMIN_USER_MENU_ITEMS = []; export const PLUGIN_ADMIN_USER_MENU_ROUTES = []; // authentication providers -export const PLUGIN_AUTH_PROVIDERS = [] as any; +export const PLUGIN_AUTH_PROVIDERS: GetAuthProviders[] = []; // Only show the password tab in account settings if these functions all return true. // Otherwise, the user is logged in via SSO and should hide first name, last name, and email field in profile settings metabase#23298. diff --git a/frontend/src/metabase/plugins/types.ts b/frontend/src/metabase/plugins/types.ts index d0ab96c45cab72f42b7d8b9a52b4b40664fe4463..2622f0baa46c697b924813b37bdd556da6af8c21 100644 --- a/frontend/src/metabase/plugins/types.ts +++ b/frontend/src/metabase/plugins/types.ts @@ -1,5 +1,23 @@ -import { Member, User } from "metabase-types/api"; -import { ConfirmationState } from "metabase/hooks/use-confirmation"; +import type { ComponentType } from "react"; +import type { Member, User } from "metabase-types/api"; +import type { ConfirmationState } from "metabase/hooks/use-confirmation"; + +export interface AuthProvider { + name: string; + Button: ComponentType<AuthProviderButtonProps>; + Panel?: ComponentType<AuthProviderPanelProps>; +} + +export interface AuthProviderButtonProps { + isCard?: boolean; + redirectUrl?: string; +} + +export interface AuthProviderPanelProps { + redirectUrl?: string; +} + +export type GetAuthProviders = (providers: AuthProvider[]) => AuthProvider[]; export type GetChangeMembershipConfirmation = ( currentUser: User, diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 6c1e9ecb91e8f5c93d146baa16744f38886535eb..811f20865f7aa1ac0a3df52e7b916957602546b3 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -14,10 +14,10 @@ import ModelMetabotApp from "metabase/metabot/containers/ModelMetabotApp"; import DatabaseMetabotApp from "metabase/metabot/containers/DatabaseMetabotApp"; // auth containers -import ForgotPasswordApp from "metabase/auth/containers/ForgotPasswordApp"; -import LoginApp from "metabase/auth/containers/LoginApp"; -import LogoutApp from "metabase/auth/containers/LogoutApp"; -import ResetPasswordApp from "metabase/auth/containers/ResetPasswordApp"; +import { ForgotPassword } from "metabase/auth/components/ForgotPassword"; +import { Login } from "metabase/auth/components/Login"; +import { Logout } from "metabase/auth/components/Logout"; +import { ResetPassword } from "metabase/auth/components/ResetPassword"; /* Dashboards */ import DashboardApp from "metabase/dashboard/containers/DashboardApp"; @@ -131,12 +131,12 @@ export const getRoutes = store => ( <Route path="/auth"> <IndexRedirect to="/auth/login" /> <Route component={IsNotAuthenticated}> - <Route path="login" title={t`Login`} component={LoginApp} /> - <Route path="login/:provider" title={t`Login`} component={LoginApp} /> + <Route path="login" title={t`Login`} component={Login} /> + <Route path="login/:provider" title={t`Login`} component={Login} /> </Route> - <Route path="logout" component={LogoutApp} /> - <Route path="forgot_password" component={ForgotPasswordApp} /> - <Route path="reset_password/:token" component={ResetPasswordApp} /> + <Route path="logout" component={Logout} /> + <Route path="forgot_password" component={ForgotPassword} /> + <Route path="reset_password/:token" component={ResetPassword} /> </Route> {/* MAIN */} diff --git a/frontend/test/__support__/server-mocks/index.ts b/frontend/test/__support__/server-mocks/index.ts index 800d5d5ad87b0c09771af3c73568486a8b146051..4c3d251b48cbe3eab4fe727c5fb0e4b8185f7e87 100644 --- a/frontend/test/__support__/server-mocks/index.ts +++ b/frontend/test/__support__/server-mocks/index.ts @@ -20,3 +20,5 @@ export * from "./setup"; export * from "./store"; export * from "./table"; export * from "./timeline"; +export * from "./user"; +export * from "./util"; diff --git a/frontend/test/__support__/server-mocks/session.ts b/frontend/test/__support__/server-mocks/session.ts index d5840c7c5a61f44ba0a90e9201ca5eb54c7ec054..0916477947b880318333aa0aee7462d359a7461c 100644 --- a/frontend/test/__support__/server-mocks/session.ts +++ b/frontend/test/__support__/server-mocks/session.ts @@ -1,6 +1,26 @@ import fetchMock from "fetch-mock"; -import { Settings } from "metabase-types/api"; +import { PasswordResetTokenInfo, Settings } from "metabase-types/api"; export function setupPropertiesEndpoints(settings: Settings) { fetchMock.get("path:/api/session/properties", settings); } + +export function setupLoginEndpoint() { + fetchMock.post("path:/api/session", 204); +} + +export function setupLogoutEndpoint() { + fetchMock.delete("path:/api/session", 204); +} + +export function setupForgotPasswordEndpoint() { + fetchMock.post("path:/api/session/forgot_password", 204); +} + +export function setupResetPasswordEndpoint() { + fetchMock.post("path:/api/session/reset_password", 204); +} + +export function setupPasswordResetTokenEndpoint(info: PasswordResetTokenInfo) { + fetchMock.get("path:/api/session/password_reset_token_valid", info); +} diff --git a/frontend/test/__support__/server-mocks/user.ts b/frontend/test/__support__/server-mocks/user.ts index 0eda1fcc68718a43ef073676cbe5ecdce6785459..548cc970f3170557cfbcf89719c3142db3fd221a 100644 --- a/frontend/test/__support__/server-mocks/user.ts +++ b/frontend/test/__support__/server-mocks/user.ts @@ -1,6 +1,10 @@ import fetchMock from "fetch-mock"; -import { UserListResult } from "metabase-types/api"; +import { User, UserListResult } from "metabase-types/api"; export function setupUsersEndpoints(users: UserListResult[]) { fetchMock.get("path:/api/user", users); } + +export function setupCurrentUserEndpoint(user: User) { + fetchMock.get("path:/api/user/current", user); +} diff --git a/frontend/test/__support__/server-mocks/util.ts b/frontend/test/__support__/server-mocks/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a47c6f3aa7fd4171f4d230fc222632d4897f65e --- /dev/null +++ b/frontend/test/__support__/server-mocks/util.ts @@ -0,0 +1,5 @@ +import fetchMock from "fetch-mock"; + +export function setupPasswordCheckEndpoint() { + fetchMock.post("path:/api/util/password_check", 204); +}