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')
),