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:
Josh Hunt
2025-07-21 10:48:53 +00:00
committed by GitHub
parent fde2267387
commit ca63c8015d
8 changed files with 179 additions and 77 deletions
+7 -5
View File
@@ -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';
+76 -68
View File
@@ -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>
);
};
+12 -1
View File
@@ -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))) {
+6
View File
@@ -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;
}
+5
View File
@@ -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')
),