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 { 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);
},
);
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 { 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;
isEmbedded?: boolean;
redirectUrl?: string;
onLogin: (redirectUrl?: string) => void;
}
const SSOButton = ({
export const SsoButton = ({
isCard,
isEmbedded,
redirectUrl,
onLogin,
}: SSOButtonProps): JSX.Element => {
}: SsoButtonProps): JSX.Element => {
const isEmbedded = isWithinIframe();
const dispatch = useDispatch();
const handleLogin = useCallback(() => {
onLogin(redirectUrl);
}, [onLogin, redirectUrl]);
dispatch(loginSSO(redirectUrl));
}, [dispatch, redirectUrl]);
useEffect(() => {
isEmbedded && handleLogin();
if (isEmbedded) {
handleLogin();
}
}, [isEmbedded, handleLogin]);
return (
......@@ -29,6 +33,3 @@ const SSOButton = ({
</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
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 => {
......
......@@ -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));
......
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 {
......
......@@ -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";
......
......@@ -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";
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 { 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;
}
};
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;
......
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;
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();
});
......
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./AuthButton";
export * 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