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 211 additions and 121 deletions
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 { trackLoginSSO } from "./analytics";
import { getSSOUrl } from "./utils"; import { getSSOUrl } from "./utils";
interface ThunkConfig {
state: State;
}
export const LOGIN_SSO = "metabase-enterprise/auth/LOGIN_SSO"; export const LOGIN_SSO = "metabase-enterprise/auth/LOGIN_SSO";
export const loginSSO = createThunkAction( export const loginSSO = createAsyncThunk<void, string | undefined, ThunkConfig>(
LOGIN_SSO, LOGIN_SSO,
(redirectUrl?: string) => async () => { (redirectUrl: string | undefined, { getState }) => {
trackLoginSSO(); trackLoginSSO();
window.location.href = getSSOUrl(redirectUrl);
const siteUrl = getSetting(getState(), "site-url");
window.location.href = getSSOUrl(siteUrl, redirectUrl);
}, },
); );
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();
});
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./SSOButton";
import React, { useCallback, useEffect } from "react"; import React, { useCallback, useEffect } from "react";
import { t } from "ttag"; import { t } from "ttag";
import AuthButton from "metabase/auth/components/AuthButton"; import { isWithinIframe } from "metabase/lib/dom";
import { useDispatch } from "metabase/lib/redux";
import { AuthButton } from "metabase/auth/components/AuthButton";
import { loginSSO } from "../../actions";
export interface SSOButtonProps { interface SsoButtonProps {
isCard?: boolean; isCard?: boolean;
isEmbedded?: boolean;
redirectUrl?: string; redirectUrl?: string;
onLogin: (redirectUrl?: string) => void;
} }
const SSOButton = ({ export const SsoButton = ({
isCard, isCard,
isEmbedded,
redirectUrl, redirectUrl,
onLogin, }: SsoButtonProps): JSX.Element => {
}: SSOButtonProps): JSX.Element => { const isEmbedded = isWithinIframe();
const dispatch = useDispatch();
const handleLogin = useCallback(() => { const handleLogin = useCallback(() => {
onLogin(redirectUrl); dispatch(loginSSO(redirectUrl));
}, [onLogin, redirectUrl]); }, [dispatch, redirectUrl]);
useEffect(() => { useEffect(() => {
isEmbedded && handleLogin(); if (isEmbedded) {
handleLogin();
}
}, [isEmbedded, handleLogin]); }, [isEmbedded, handleLogin]);
return ( return (
...@@ -29,6 +33,3 @@ const SSOButton = ({ ...@@ -29,6 +33,3 @@ const SSOButton = ({
</AuthButton> </AuthButton>
); );
}; };
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default SSOButton;
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`);
});
});
});
export * from "./SsoButton";
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);
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./SSOButton";
...@@ -17,7 +17,7 @@ import SessionTimeoutSetting from "metabase-enterprise/auth/components/SessionTi ...@@ -17,7 +17,7 @@ import SessionTimeoutSetting from "metabase-enterprise/auth/components/SessionTi
import { createSessionMiddleware } from "../auth/middleware/session-middleware"; import { createSessionMiddleware } from "../auth/middleware/session-middleware";
import SettingsSAMLForm from "./components/SettingsSAMLForm"; import SettingsSAMLForm from "./components/SettingsSAMLForm";
import SettingsJWTForm from "./components/SettingsJWTForm"; import SettingsJWTForm from "./components/SettingsJWTForm";
import SSOButton from "./containers/SSOButton"; import { SsoButton } from "./components/SsoButton";
import JwtAuthCard from "./containers/JwtAuthCard"; import JwtAuthCard from "./containers/JwtAuthCard";
import SamlAuthCard from "./containers/SamlAuthCard"; import SamlAuthCard from "./containers/SamlAuthCard";
...@@ -217,7 +217,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => ({ ...@@ -217,7 +217,7 @@ PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => ({
const SSO_PROVIDER = { const SSO_PROVIDER = {
name: "sso", name: "sso",
Button: SSOButton, Button: SsoButton,
}; };
PLUGIN_AUTH_PROVIDERS.push(providers => { PLUGIN_AUTH_PROVIDERS.push(providers => {
......
...@@ -37,7 +37,7 @@ export const createSessionMiddleware = ( ...@@ -37,7 +37,7 @@ export const createSessionMiddleware = (
if (isLoggedIn) { if (isLoggedIn) {
// get the redirect url before refreshing the session because after the refresh the url will be reset // get the redirect url before refreshing the session because after the refresh the url will be reset
const redirectUrl = getRedirectUrl(); const redirectUrl = getRedirectUrl();
await store.dispatch(refreshSession()); await store.dispatch(refreshSession())?.unwrap();
if (redirectUrl !== null) { if (redirectUrl !== null) {
store.dispatch(replace(redirectUrl)); store.dispatch(replace(redirectUrl));
......
import Settings from "metabase/lib/settings"; export const getSSOUrl = (siteUrl: string, redirectUrl?: string): string => {
export const getSSOUrl = (redirectUrl?: string): string => {
const siteUrl = Settings.get("site-url");
if (redirectUrl) { if (redirectUrl) {
return `${siteUrl}/auth/sso?redirect=${encodeURIComponent(redirectUrl)}`; return `${siteUrl}/auth/sso?redirect=${encodeURIComponent(redirectUrl)}`;
} else { } else {
......
...@@ -21,6 +21,7 @@ export * from "./query"; ...@@ -21,6 +21,7 @@ export * from "./query";
export * from "./revision"; export * from "./revision";
export * from "./schema"; export * from "./schema";
export * from "./segment"; export * from "./segment";
export * from "./session";
export * from "./settings"; export * from "./settings";
export * from "./setup"; export * from "./setup";
export * from "./slack"; export * from "./slack";
......
...@@ -15,10 +15,12 @@ export * from "./parameters"; ...@@ -15,10 +15,12 @@ export * from "./parameters";
export * from "./query"; export * from "./query";
export * from "./schema"; export * from "./schema";
export * from "./segment"; export * from "./segment";
export * from "./table"; export * from "./series";
export * from "./timeline"; export * from "./session";
export * from "./settings"; export * from "./settings";
export * from "./setup"; export * from "./setup";
export * from "./snippets"; export * from "./snippets";
export * from "./store"; export * from "./store";
export * from "./table";
export * from "./timeline";
export * from "./user"; export * from "./user";
import { PasswordResetTokenInfo } from "metabase-types/api";
export const createMockPasswordResetTokenInfo = (
opts?: Partial<PasswordResetTokenInfo>,
): PasswordResetTokenInfo => ({
valid: false,
...opts,
});
export interface PasswordResetTokenInfo {
valid: boolean;
}
import { createAsyncThunk } from "@reduxjs/toolkit";
import { push } from "react-router-redux"; import { push } from "react-router-redux";
import { getIn } from "icepick"; import { getIn } from "icepick";
import { SessionApi, UtilApi } from "metabase/services"; import { SessionApi, UtilApi } from "metabase/services";
import { getSetting } from "metabase/selectors/settings";
import MetabaseSettings from "metabase/lib/settings"; import MetabaseSettings from "metabase/lib/settings";
import { createThunkAction } from "metabase/lib/redux";
import { loadLocalization } from "metabase/lib/i18n"; import { loadLocalization } from "metabase/lib/i18n";
import { deleteSession } from "metabase/lib/auth"; import { deleteSession } from "metabase/lib/auth";
import * as Urls from "metabase/lib/urls"; import * as Urls from "metabase/lib/urls";
...@@ -18,82 +19,125 @@ import { ...@@ -18,82 +19,125 @@ import {
} from "./analytics"; } from "./analytics";
import { LoginData } from "./types"; import { LoginData } from "./types";
interface ThunkConfig {
state: State;
}
export const REFRESH_LOCALE = "metabase/user/REFRESH_LOCALE"; export const REFRESH_LOCALE = "metabase/user/REFRESH_LOCALE";
export const refreshLocale = createThunkAction( export const refreshLocale = createAsyncThunk<void, void, ThunkConfig>(
REFRESH_LOCALE, REFRESH_LOCALE,
() => async (dispatch: any, getState: () => State) => { async (_, { getState }) => {
const userLocale = getUser(getState())?.locale; const userLocale = getUser(getState())?.locale;
const siteLocale = MetabaseSettings.get("site-locale"); const siteLocale = getSetting(getState(), "site-locale");
await loadLocalization(userLocale ?? siteLocale ?? "en"); await loadLocalization(userLocale ?? siteLocale ?? "en");
}, },
); );
export const REFRESH_SESSION = "metabase/auth/REFRESH_SESSION"; export const REFRESH_SESSION = "metabase/auth/REFRESH_SESSION";
export const refreshSession = createThunkAction( export const refreshSession = createAsyncThunk<void, void, ThunkConfig>(
REFRESH_SESSION, REFRESH_SESSION,
() => async (dispatch: any) => { async (_, { dispatch }) => {
await Promise.all([ await Promise.all([
dispatch(refreshCurrentUser()), dispatch(refreshCurrentUser()),
dispatch(refreshSiteSettings()), dispatch(refreshSiteSettings()),
]); ]);
await dispatch(refreshLocale()); await dispatch(refreshLocale()).unwrap();
}, },
); );
interface LoginPayload {
data: LoginData;
redirectUrl?: string;
}
export const LOGIN = "metabase/auth/LOGIN"; export const LOGIN = "metabase/auth/LOGIN";
export const login = createThunkAction( export const login = createAsyncThunk<void, LoginPayload, ThunkConfig>(
LOGIN, LOGIN,
(data: LoginData, redirectUrl = "/") => async ({ data, redirectUrl = "/" }, { dispatch, rejectWithValue }) => {
async (dispatch: any) => { try {
await SessionApi.create(data); await SessionApi.create(data);
await dispatch(refreshSession()); await dispatch(refreshSession()).unwrap();
trackLogin(); trackLogin();
dispatch(push(redirectUrl)); dispatch(push(redirectUrl));
}, } catch (error) {
return rejectWithValue(error);
}
},
); );
interface LoginGooglePayload {
credential: string;
redirectUrl?: string;
}
export const LOGIN_GOOGLE = "metabase/auth/LOGIN_GOOGLE"; export const LOGIN_GOOGLE = "metabase/auth/LOGIN_GOOGLE";
export const loginGoogle = createThunkAction( export const loginGoogle = createAsyncThunk<
void,
LoginGooglePayload,
ThunkConfig
>(
LOGIN_GOOGLE, LOGIN_GOOGLE,
(token: string, redirectUrl = "/") => async ({ credential, redirectUrl = "/" }, { dispatch, rejectWithValue }) => {
async (dispatch: any) => { try {
await SessionApi.createWithGoogleAuth({ token }); await SessionApi.createWithGoogleAuth({ token: credential });
await dispatch(refreshSession()); await dispatch(refreshSession()).unwrap();
trackLoginGoogle(); trackLoginGoogle();
dispatch(push(redirectUrl)); dispatch(push(redirectUrl));
}, } catch (error) {
return rejectWithValue(error);
}
},
); );
export const LOGOUT = "metabase/auth/LOGOUT"; export const LOGOUT = "metabase/auth/LOGOUT";
export const logout = createThunkAction(LOGOUT, (redirectUrl: string) => { export const logout = createAsyncThunk<void, string | undefined, ThunkConfig>(
return async (dispatch: any) => { LOGOUT,
await deleteSession(); async (redirectUrl, { dispatch, rejectWithValue }) => {
await dispatch(clearCurrentUser()); try {
await dispatch(refreshLocale()); await deleteSession();
trackLogout(); await dispatch(clearCurrentUser());
await dispatch(refreshLocale()).unwrap();
dispatch(push(Urls.login(redirectUrl))); trackLogout();
window.location.reload(); // clears redux state and browser caches 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 FORGOT_PASSWORD = "metabase/auth/FORGOT_PASSWORD";
export const forgotPassword = createThunkAction( export const forgotPassword = createAsyncThunk(
FORGOT_PASSWORD, FORGOT_PASSWORD,
(email: string) => async () => { async (email: string, { rejectWithValue }) => {
await SessionApi.forgot_password({ email }); 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 RESET_PASSWORD = "metabase/auth/RESET_PASSWORD";
export const resetPassword = createThunkAction( export const resetPassword = createAsyncThunk<
void,
ResetPasswordPayload,
ThunkConfig
>(
RESET_PASSWORD, RESET_PASSWORD,
(token: string, password: string) => async (dispatch: any) => { async ({ token, password }, { dispatch, rejectWithValue }) => {
await SessionApi.reset_password({ token, password }); try {
await dispatch(refreshSession()); await SessionApi.reset_password({ token, password });
trackPasswordReset(); await dispatch(refreshSession()).unwrap();
trackPasswordReset();
} catch (error) {
return rejectWithValue(error);
}
}, },
); );
...@@ -110,15 +154,11 @@ export const validatePassword = async (password: string) => { ...@@ -110,15 +154,11 @@ export const validatePassword = async (password: string) => {
} }
}; };
export const VALIDATE_PASSWORD_TOKEN = "metabase/auth/VALIDATE_TOKEN"; export const validatePasswordToken = async (token: string) => {
export const validatePasswordToken = createThunkAction( const result = await SessionApi.password_reset_token_valid({ token });
VALIDATE_PASSWORD_TOKEN, const valid = getIn(result, ["valid"]);
(token: string) => async () => {
const result = await SessionApi.password_reset_token_valid({ token });
const valid = getIn(result, ["valid"]);
if (!valid) { if (!valid) {
throw result; throw result;
} }
}, };
);
import styled from "@emotion/styled"; import styled from "@emotion/styled";
import { color } from "metabase/lib/colors"; import { color } from "metabase/lib/colors";
import Icon from "metabase/components/Icon";
import Link from "metabase/core/components/Link"; import Link from "metabase/core/components/Link";
export const TextLink = styled(Link)` export const TextLink = styled(Link)`
...@@ -22,10 +21,6 @@ export const CardLink = styled(TextLink)` ...@@ -22,10 +21,6 @@ export const CardLink = styled(TextLink)`
border-radius: 6px; border-radius: 6px;
`; `;
export const CardIcon = styled(Icon)`
margin-right: 0.5rem;
`;
export const CardText = styled.span` export const CardText = styled.span`
font-weight: 700; font-weight: 700;
line-height: 1rem; line-height: 1rem;
......
import React, { ReactNode } from "react"; 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; link?: string;
icon?: string;
isCard?: boolean; isCard?: boolean;
children?: ReactNode; children?: ReactNode;
onClick?: () => void; onClick?: () => void;
} }
const AuthButton = ({ export const AuthButton = ({
link = "", link = "",
icon,
isCard, isCard,
children, children,
onClick, onClick,
}: AuthButtonProps): JSX.Element => { }: AuthButtonProps): JSX.Element => {
return isCard ? ( return isCard ? (
<CardLink to={link} onClick={onClick}> <CardLink to={link} onClick={onClick}>
{icon && <CardIcon name={icon} />}
<CardText>{children}</CardText> <CardText>{children}</CardText>
</CardLink> </CardLink>
) : ( ) : (
...@@ -27,6 +24,3 @@ const AuthButton = ({ ...@@ -27,6 +24,3 @@ const AuthButton = ({
</TextLink> </TextLink>
); );
}; };
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default AuthButton;
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { renderWithProviders, screen } from "__support__/ui";
import AuthButton from "./AuthButton"; import { AuthButton } from "./AuthButton";
interface SetupOpts {
isCard?: boolean;
}
const setup = ({ isCard }: SetupOpts = {}) => {
renderWithProviders(<AuthButton isCard={isCard}>Sign in</AuthButton>);
};
describe("AuthButton", () => { describe("AuthButton", () => {
it("should render a card", () => { it("should render a card", () => {
render( setup({ isCard: true });
<AuthButton icon="google" isCard={true}>
Sign in
</AuthButton>,
);
expect(screen.getByText("Sign in")).toBeInTheDocument(); expect(screen.getByText("Sign in")).toBeInTheDocument();
expect(screen.getByLabelText("google icon")).toBeInTheDocument();
}); });
it("should render a link", () => { it("should render a link", () => {
render(<AuthButton>Sign in</AuthButton>); setup();
expect(screen.getByText("Sign in")).toBeInTheDocument(); expect(screen.getByText("Sign in")).toBeInTheDocument();
}); });
......
// eslint-disable-next-line import/no-default-export -- deprecated usage export * from "./AuthButton";
export { default } from "./AuthButton";
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