FS: Frontend redirect to /login (#108225)
* Add frontend redirect to login * Make Login topnav link do a page transition to login page * Force AppChromeState update when route changes to get new isChromeless state * Add base url to SignInLink, and add tests * wrap login page in <Page /> * comment, test * fix loginPage test * rejigger signin check, remove chrome from deps
This commit is contained in:
+7
-5
@@ -213,16 +213,17 @@ export class GrafanaApp {
|
||||
getPanelPluginFromCache: syncGetPanelPlugin,
|
||||
});
|
||||
|
||||
if (config.featureToggles.useSessionStorageForRedirection) {
|
||||
handleRedirectTo();
|
||||
}
|
||||
|
||||
// Login redirect requires locationUtil to be initialized
|
||||
locationUtil.initialize({
|
||||
config,
|
||||
getTimeRangeForUrl: getTimeSrv().timeRangeForUrl,
|
||||
getVariablesUrlParams: getVariablesUrlParams,
|
||||
});
|
||||
|
||||
if (config.featureToggles.useSessionStorageForRedirection) {
|
||||
handleRedirectTo();
|
||||
}
|
||||
|
||||
// intercept anchor clicks and forward it to custom history instead of relying on browser's history
|
||||
document.addEventListener('click', interceptLinkClicks);
|
||||
|
||||
@@ -464,7 +465,8 @@ function handleRedirectTo(): void {
|
||||
// In this case there should be a request to the backend
|
||||
window.location.replace(decodedRedirectTo);
|
||||
} else {
|
||||
locationService.replace(decodedRedirectTo);
|
||||
const stripped = locationUtil.stripBaseFromUrl(decodedRedirectTo);
|
||||
locationService.replace(stripped);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import { locationUtil, type GrafanaConfig } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { SignInLink } from './SignInLink';
|
||||
|
||||
jest.mock('app/core/services/context_srv', () => ({
|
||||
contextSrv: {
|
||||
setRedirectToUrl: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SignInLink', () => {
|
||||
it('should render a link to the login page', () => {
|
||||
const { getByText } = render(<SignInLink />);
|
||||
const link = getByText('Sign in');
|
||||
|
||||
expect(link).toHaveAttribute('href', '/?forceLogin=true');
|
||||
expect(link).toHaveAttribute('target', '_self');
|
||||
});
|
||||
|
||||
describe('with multiTenantFrontend toggle enabled', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.multiTenantFrontend = true;
|
||||
});
|
||||
|
||||
it('should render a link to the login page', () => {
|
||||
const { getByText } = render(<SignInLink />);
|
||||
const link = getByText('Sign in');
|
||||
|
||||
expect(link).toHaveAttribute('href', '/login');
|
||||
expect(link).not.toHaveAttribute('target', '_self');
|
||||
});
|
||||
|
||||
it('remember the redirect url for after login', () => {
|
||||
const { getByText } = render(<SignInLink />);
|
||||
const link = getByText('Sign in');
|
||||
|
||||
link.click();
|
||||
|
||||
expect(contextSrv.setRedirectToUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the app base url when served from a subpath', () => {
|
||||
locationUtil.initialize({
|
||||
config: { appSubUrl: '/subpath' } as GrafanaConfig,
|
||||
getTimeRangeForUrl: () => ({ from: 'now-1d', to: 'now' }),
|
||||
getVariablesUrlParams: () => ({}),
|
||||
});
|
||||
|
||||
const { getByText } = render(<SignInLink />);
|
||||
const link = getByText('Sign in');
|
||||
|
||||
expect(link).toHaveAttribute('href', '/subpath/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,32 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2, locationUtil, textUtil } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
export function SignInLink() {
|
||||
const femt = Boolean(config.featureToggles.multiTenantFrontend);
|
||||
const location = useLocation();
|
||||
const styles = useStyles2(getStyles);
|
||||
let loginUrl = textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' }));
|
||||
let loginUrl = femt
|
||||
? locationUtil.assureBaseUrl('/login')
|
||||
: textUtil.sanitizeUrl(locationUtil.getUrlForPartial(location, { forceLogin: 'true' }));
|
||||
|
||||
// Fix for loginUrl starting with "//" which is a scheme relative URL
|
||||
if (loginUrl.startsWith('//')) {
|
||||
loginUrl = loginUrl.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
const handleOnClick = useCallback(() => {
|
||||
contextSrv.setRedirectToUrl();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<a className={styles.link} href={loginUrl} target="_self">
|
||||
<a className={styles.link} onClick={handleOnClick} href={loginUrl} target={femt ? undefined : '_self'}>
|
||||
<Trans i18nKey="app-chrome.top-bar.sign-in">Sign in</Trans>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from 'test/test-utils';
|
||||
|
||||
import * as runtimeMock from '@grafana/runtime';
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
// Components
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, LinkButton, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Branding } from 'app/core/components/Branding/Branding';
|
||||
|
||||
import { ChangePassword } from '../ForgottenPassword/ChangePassword';
|
||||
import { Page } from '../Page/Page';
|
||||
|
||||
import LoginCtrl from './LoginCtrl';
|
||||
import { LoginForm } from './LoginForm';
|
||||
@@ -24,76 +25,83 @@ const LoginPage = () => {
|
||||
document.title = Branding.AppTitle;
|
||||
|
||||
return (
|
||||
<LoginCtrl>
|
||||
{({
|
||||
loginHint,
|
||||
passwordHint,
|
||||
disableLoginForm,
|
||||
disableUserSignUp,
|
||||
login,
|
||||
passwordlessStart,
|
||||
passwordlessConfirm,
|
||||
showPasswordlessConfirmation,
|
||||
isLoggingIn,
|
||||
changePassword,
|
||||
skipPasswordChange,
|
||||
isChangingPassword,
|
||||
showDefaultPasswordWarning,
|
||||
loginErrorMessage,
|
||||
}) => (
|
||||
<LoginLayout isChangingPassword={isChangingPassword}>
|
||||
{!isChangingPassword && !showPasswordlessConfirmation && (
|
||||
<InnerBox>
|
||||
{loginErrorMessage && (
|
||||
<Alert className={styles.alert} severity="error" title={t('login.error.title', 'Login failed')}>
|
||||
{loginErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
<Page layout={PageLayoutType.Custom}>
|
||||
<LoginCtrl>
|
||||
{({
|
||||
loginHint,
|
||||
passwordHint,
|
||||
disableLoginForm,
|
||||
disableUserSignUp,
|
||||
login,
|
||||
passwordlessStart,
|
||||
passwordlessConfirm,
|
||||
showPasswordlessConfirmation,
|
||||
isLoggingIn,
|
||||
changePassword,
|
||||
skipPasswordChange,
|
||||
isChangingPassword,
|
||||
showDefaultPasswordWarning,
|
||||
loginErrorMessage,
|
||||
}) => (
|
||||
<LoginLayout isChangingPassword={isChangingPassword}>
|
||||
{!isChangingPassword && !showPasswordlessConfirmation && (
|
||||
<InnerBox>
|
||||
{loginErrorMessage && (
|
||||
<Alert className={styles.alert} severity="error" title={t('login.error.title', 'Login failed')}>
|
||||
{loginErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!disableLoginForm && !config.auth.passwordlessEnabled && (
|
||||
<LoginForm onSubmit={login} loginHint={loginHint} passwordHint={passwordHint} isLoggingIn={isLoggingIn}>
|
||||
<Stack justifyContent="flex-end">
|
||||
{!config.auth.disableLogin && (
|
||||
<LinkButton
|
||||
className={styles.forgottenPassword}
|
||||
fill="text"
|
||||
href={`${config.appSubUrl}/user/password/send-reset-email`}
|
||||
>
|
||||
<Trans i18nKey="login.forgot-password">Forgot your password?</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
</LoginForm>
|
||||
)}
|
||||
{config.auth.passwordlessEnabled && (
|
||||
<PasswordlessLoginForm onSubmit={passwordlessStart} isLoggingIn={isLoggingIn}></PasswordlessLoginForm>
|
||||
)}
|
||||
<LoginServiceButtons />
|
||||
{!disableUserSignUp && <UserSignup />}
|
||||
</InnerBox>
|
||||
)}
|
||||
{!disableLoginForm && !config.auth.passwordlessEnabled && (
|
||||
<LoginForm
|
||||
onSubmit={login}
|
||||
loginHint={loginHint}
|
||||
passwordHint={passwordHint}
|
||||
isLoggingIn={isLoggingIn}
|
||||
>
|
||||
<Stack justifyContent="flex-end">
|
||||
{!config.auth.disableLogin && (
|
||||
<LinkButton
|
||||
className={styles.forgottenPassword}
|
||||
fill="text"
|
||||
href={`${config.appSubUrl}/user/password/send-reset-email`}
|
||||
>
|
||||
<Trans i18nKey="login.forgot-password">Forgot your password?</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
</LoginForm>
|
||||
)}
|
||||
{config.auth.passwordlessEnabled && (
|
||||
<PasswordlessLoginForm onSubmit={passwordlessStart} isLoggingIn={isLoggingIn}></PasswordlessLoginForm>
|
||||
)}
|
||||
<LoginServiceButtons />
|
||||
{!disableUserSignUp && <UserSignup />}
|
||||
</InnerBox>
|
||||
)}
|
||||
|
||||
{config.auth.passwordlessEnabled && showPasswordlessConfirmation && (
|
||||
<InnerBox>
|
||||
<PasswordlessConfirmation
|
||||
onSubmit={passwordlessConfirm}
|
||||
isLoggingIn={isLoggingIn}
|
||||
></PasswordlessConfirmation>
|
||||
</InnerBox>
|
||||
)}
|
||||
{config.auth.passwordlessEnabled && showPasswordlessConfirmation && (
|
||||
<InnerBox>
|
||||
<PasswordlessConfirmation
|
||||
onSubmit={passwordlessConfirm}
|
||||
isLoggingIn={isLoggingIn}
|
||||
></PasswordlessConfirmation>
|
||||
</InnerBox>
|
||||
)}
|
||||
|
||||
{isChangingPassword && !config.auth.passwordlessEnabled && (
|
||||
<InnerBox>
|
||||
<ChangePassword
|
||||
showDefaultPasswordWarning={showDefaultPasswordWarning}
|
||||
onSubmit={changePassword}
|
||||
onSkip={() => skipPasswordChange()}
|
||||
/>
|
||||
</InnerBox>
|
||||
)}
|
||||
</LoginLayout>
|
||||
)}
|
||||
</LoginCtrl>
|
||||
{isChangingPassword && !config.auth.passwordlessEnabled && (
|
||||
<InnerBox>
|
||||
<ChangePassword
|
||||
showDefaultPasswordWarning={showDefaultPasswordWarning}
|
||||
onSubmit={changePassword}
|
||||
onSkip={() => skipPasswordChange()}
|
||||
/>
|
||||
</InnerBox>
|
||||
)}
|
||||
</LoginLayout>
|
||||
)}
|
||||
</LoginCtrl>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Suspense, useEffect, useLayoutEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
|
||||
import { config, locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime';
|
||||
import { ErrorBoundary } from '@grafana/ui';
|
||||
|
||||
import { useGrafana } from '../context/GrafanaContext';
|
||||
@@ -62,6 +62,17 @@ export function GrafanaRoute(props: Props) {
|
||||
|
||||
export function GrafanaRouteWrapper({ route }: Pick<Props, 'route'>) {
|
||||
const location = useLocation();
|
||||
|
||||
// Perform login check in the frontend now
|
||||
if (config.featureToggles.multiTenantFrontend) {
|
||||
const routeRequiresSignin = !route.allowAnonymous && !config.anonymousEnabled;
|
||||
if (routeRequiresSignin && !contextSrv.isSignedIn) {
|
||||
contextSrv.setRedirectToUrl();
|
||||
|
||||
return <Navigate replace to="/login" />;
|
||||
}
|
||||
}
|
||||
|
||||
const roles = route.roles ? route.roles() : [];
|
||||
if (roles?.length) {
|
||||
if (!roles.some((r: string) => contextSrv.hasRole(r))) {
|
||||
|
||||
@@ -20,4 +20,10 @@ export interface RouteDescriptor {
|
||||
routeName?: string;
|
||||
chromeless?: boolean;
|
||||
sensitive?: boolean;
|
||||
|
||||
/**
|
||||
* Allow the route to be access by anonymous users.
|
||||
* Currently only used if the `multiTenantFrontend` feature toggle is enabled.
|
||||
*/
|
||||
allowAnonymous?: boolean;
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
// LOGIN / SIGNUP
|
||||
{
|
||||
path: '/login',
|
||||
allowAnonymous: true,
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "LoginPage" */ 'app/core/components/Login/LoginPage')
|
||||
),
|
||||
@@ -413,6 +414,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
allowAnonymous: true,
|
||||
component: config.disableUserSignUp
|
||||
? () => <Navigate replace to="/login" />
|
||||
: SafeDynamicImport(() => import(/* webpackChunkName "SignupPage"*/ 'app/core/components/Signup/SignupPage')),
|
||||
@@ -421,6 +423,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/user/password/send-reset-email',
|
||||
allowAnonymous: true,
|
||||
chromeless: true,
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
@@ -429,6 +432,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/user/password/reset',
|
||||
allowAnonymous: true,
|
||||
component: SafeDynamicImport(
|
||||
() =>
|
||||
import(
|
||||
@@ -477,6 +481,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/sandbox/test',
|
||||
allowAnonymous: true, // purposefully to allow testing
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "TestStuffPage"*/ 'app/features/sandbox/TestStuffPage')
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user