From ca63c8015d45396ce300cce365b3ecb1b6797d48 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Mon, 21 Jul 2025 11:48:53 +0100 Subject: [PATCH] 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 * comment, test * fix loginPage test * rejigger signin check, remove chrome from deps --- public/app/app.ts | 12 +- .../AppChrome/TopBar/SignInLink.test.tsx | 59 +++++++ .../AppChrome/TopBar/SignInLink.tsx | 14 +- .../core/components/Login/LoginPage.test.tsx | 3 +- .../app/core/components/Login/LoginPage.tsx | 144 +++++++++--------- public/app/core/navigation/GrafanaRoute.tsx | 13 +- public/app/core/navigation/types.ts | 6 + public/app/routes/routes.tsx | 5 + 8 files changed, 179 insertions(+), 77 deletions(-) create mode 100644 public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx diff --git a/public/app/app.ts b/public/app/app.ts index ea6400c5837..0e3ce53e238 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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); } } diff --git a/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx b/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx new file mode 100644 index 00000000000..4effc8dc94b --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/SignInLink.test.tsx @@ -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(); + 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(); + 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(); + 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(); + const link = getByText('Sign in'); + + expect(link).toHaveAttribute('href', '/subpath/login'); + }); + }); +}); diff --git a/public/app/core/components/AppChrome/TopBar/SignInLink.tsx b/public/app/core/components/AppChrome/TopBar/SignInLink.tsx index a7018533763..7c93f089741 100644 --- a/public/app/core/components/AppChrome/TopBar/SignInLink.tsx +++ b/public/app/core/components/AppChrome/TopBar/SignInLink.tsx @@ -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 ( - + Sign in ); diff --git a/public/app/core/components/Login/LoginPage.test.tsx b/public/app/core/components/Login/LoginPage.test.tsx index 0e2369b0a8d..3c7593637f5 100644 --- a/public/app/core/components/Login/LoginPage.test.tsx +++ b/public/app/core/components/Login/LoginPage.test.tsx @@ -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'; diff --git a/public/app/core/components/Login/LoginPage.tsx b/public/app/core/components/Login/LoginPage.tsx index 5d866750aa4..864bb707363 100644 --- a/public/app/core/components/Login/LoginPage.tsx +++ b/public/app/core/components/Login/LoginPage.tsx @@ -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 ( - - {({ - loginHint, - passwordHint, - disableLoginForm, - disableUserSignUp, - login, - passwordlessStart, - passwordlessConfirm, - showPasswordlessConfirmation, - isLoggingIn, - changePassword, - skipPasswordChange, - isChangingPassword, - showDefaultPasswordWarning, - loginErrorMessage, - }) => ( - - {!isChangingPassword && !showPasswordlessConfirmation && ( - - {loginErrorMessage && ( - - {loginErrorMessage} - - )} + + + {({ + loginHint, + passwordHint, + disableLoginForm, + disableUserSignUp, + login, + passwordlessStart, + passwordlessConfirm, + showPasswordlessConfirmation, + isLoggingIn, + changePassword, + skipPasswordChange, + isChangingPassword, + showDefaultPasswordWarning, + loginErrorMessage, + }) => ( + + {!isChangingPassword && !showPasswordlessConfirmation && ( + + {loginErrorMessage && ( + + {loginErrorMessage} + + )} - {!disableLoginForm && !config.auth.passwordlessEnabled && ( - - - {!config.auth.disableLogin && ( - - Forgot your password? - - )} - - - )} - {config.auth.passwordlessEnabled && ( - - )} - - {!disableUserSignUp && } - - )} + {!disableLoginForm && !config.auth.passwordlessEnabled && ( + + + {!config.auth.disableLogin && ( + + Forgot your password? + + )} + + + )} + {config.auth.passwordlessEnabled && ( + + )} + + {!disableUserSignUp && } + + )} - {config.auth.passwordlessEnabled && showPasswordlessConfirmation && ( - - - - )} + {config.auth.passwordlessEnabled && showPasswordlessConfirmation && ( + + + + )} - {isChangingPassword && !config.auth.passwordlessEnabled && ( - - skipPasswordChange()} - /> - - )} - - )} - + {isChangingPassword && !config.auth.passwordlessEnabled && ( + + skipPasswordChange()} + /> + + )} + + )} + + ); }; diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index 80c31728dcf..c5c751366dc 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -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) { 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 ; + } + } + const roles = route.roles ? route.roles() : []; if (roles?.length) { if (!roles.some((r: string) => contextSrv.hasRole(r))) { diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts index 6f1453f77b3..8fafbfa2a8b 100644 --- a/public/app/core/navigation/types.ts +++ b/public/app/core/navigation/types.ts @@ -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; } diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 41221a4e772..09cdc5d94a5 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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 ? () => : 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') ),