This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e91ce19a606 fix(auth): drain flash messages on the login page (#44605)
e91ce19a606 is described below
commit e91ce19a6064d406803a3a07d6d84c2a9700a7d6
Author: 47th <[email protected]>
AuthorDate: Thu Oct 1 08:22:26 2026 +0530
fix(auth): drain flash messages on the login page (#44605)
---
superset-frontend/src/pages/Login/Login.test.tsx | 135 ++++++++++++++++++++++-
superset-frontend/src/pages/Login/index.tsx | 41 ++++---
superset-frontend/src/types/bootstrapTypes.ts | 1 +
superset/translations/messages.pot | 3 -
superset/views/auth.py | 11 +-
tests/unit_tests/views/test_auth_login.py | 77 +++++++++++++
6 files changed, 247 insertions(+), 21 deletions(-)
diff --git a/superset-frontend/src/pages/Login/Login.test.tsx
b/superset-frontend/src/pages/Login/Login.test.tsx
index 6365edf32c9..d41b5a7443a 100644
--- a/superset-frontend/src/pages/Login/Login.test.tsx
+++ b/superset-frontend/src/pages/Login/Login.test.tsx
@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen } from 'spec/helpers/testing-library';
+import {
+ createStore,
+ render,
+ screen,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import { Provider } from 'react-redux';
import getBootstrapData from 'src/utils/getBootstrapData';
import Login from './index';
@@ -36,6 +42,29 @@ jest.mock('src/utils/getBootstrapData', () => ({
default: jest.fn(() => defaultBootstrapData),
}));
+const mockAddDangerToast = jest.fn((text: string) => ({
+ type: 'MOCK_TOAST',
+ text,
+}));
+const mockAddInfoToast = jest.fn((text: string) => ({
+ type: 'MOCK_TOAST',
+ text,
+}));
+const mockAddSuccessToast = jest.fn((text: string) => ({
+ type: 'MOCK_TOAST',
+ text,
+}));
+const mockAddWarningToast = jest.fn((text: string) => ({
+ type: 'MOCK_TOAST',
+ text,
+}));
+jest.mock('src/components/MessageToasts/actions', () => ({
+ addDangerToast: (text: string) => mockAddDangerToast(text),
+ addInfoToast: (text: string) => mockAddInfoToast(text),
+ addSuccessToast: (text: string) => mockAddSuccessToast(text),
+ addWarningToast: (text: string) => mockAddWarningToast(text),
+}));
+
const mockEnsureAppRoot = jest.fn((...args: string[]) => args[0]);
jest.mock('src/utils/pathUtils', () => ({
ensureAppRoot: (...args: string[]) => mockEnsureAppRoot(...args),
@@ -44,6 +73,7 @@ jest.mock('src/utils/pathUtils', () => ({
const mockGetBootstrapData = getBootstrapData as jest.Mock;
beforeEach(() => {
+ jest.clearAllMocks();
mockGetBootstrapData.mockReturnValue(defaultBootstrapData);
mockEnsureAppRoot.mockClear();
mockEnsureAppRoot.mockImplementation((path: string) => path);
@@ -336,3 +366,106 @@ test('should use ensureAppRoot for all generated URLs
with deep application root
screen.getByRole('link', { name: /Sign in with Google/ }),
).toHaveAttribute('href', '/my-org/superset/login/google');
});
+
+// --- Auth flash messages ---
+
+test('should show flashed warnings as warning toasts', () => {
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [['warning', 'Invalid login. Please try again.']],
+ });
+
+ render(<Login />, { useRedux: true });
+
+ expect(mockAddWarningToast).toHaveBeenCalledWith(
+ 'Invalid login. Please try again.',
+ );
+ expect(mockAddDangerToast).not.toHaveBeenCalled();
+});
+
+test('should clear a seeded password on an error-category auth message', async
() => {
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [],
+ });
+ const { rerender } = render(
+ <Provider store={createStore()}>
+ <Login />
+ </Provider>,
+ );
+ const password = screen.getByTestId('password-input') as HTMLInputElement;
+ await userEvent.type(password, 'wrong-secret');
+ expect(password).toHaveValue('wrong-secret');
+
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [['error', 'Invalid login. Please try again.']],
+ });
+ rerender(
+ <Provider store={createStore()}>
+ <Login />
+ </Provider>,
+ );
+
+ expect(screen.getByTestId('password-input')).toHaveValue('');
+});
+
+test('should show every drained auth message', () => {
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [
+ ['warning', 'Invalid login. Please try again.'],
+ ['warning', 'Your session has ended. Please sign in again.'],
+ ],
+ });
+
+ render(<Login />, { useRedux: true });
+
+ expect(mockAddWarningToast).toHaveBeenCalledTimes(2);
+ expect(mockAddWarningToast).toHaveBeenCalledWith(
+ 'Your session has ended. Please sign in again.',
+ );
+});
+
+test('should not fabricate a login error without auth messages', () => {
+ render(<Login />, { useRedux: true });
+
+ expect(mockAddDangerToast).not.toHaveBeenCalled();
+ expect(mockAddWarningToast).not.toHaveBeenCalled();
+ expect(mockAddInfoToast).not.toHaveBeenCalled();
+ expect(mockAddSuccessToast).not.toHaveBeenCalled();
+});
+
+test('should map success and info categories to matching toasts', () => {
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [
+ ['success', 'Password changed.'],
+ ['message', 'Welcome back.'],
+ ],
+ });
+
+ render(<Login />, { useRedux: true });
+
+ expect(mockAddSuccessToast).toHaveBeenCalledWith('Password changed.');
+ expect(mockAddInfoToast).toHaveBeenCalledWith('Welcome back.');
+ expect(mockAddDangerToast).not.toHaveBeenCalled();
+ expect(mockAddWarningToast).not.toHaveBeenCalled();
+});
+
+test('should map danger and error categories to danger toasts', () => {
+ mockGetBootstrapData.mockReturnValue({
+ ...defaultBootstrapData,
+ auth_messages: [
+ ['danger', 'Login failed.'],
+ ['error', 'Something went wrong.'],
+ ],
+ });
+
+ render(<Login />, { useRedux: true });
+
+ expect(mockAddDangerToast).toHaveBeenCalledTimes(2);
+ expect(mockAddDangerToast).toHaveBeenCalledWith('Login failed.');
+ expect(mockAddDangerToast).toHaveBeenCalledWith('Something went wrong.');
+ expect(mockAddWarningToast).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/pages/Login/index.tsx
b/superset-frontend/src/pages/Login/index.tsx
index 44250511452..cf1de4d52b1 100644
--- a/superset-frontend/src/pages/Login/index.tsx
+++ b/superset-frontend/src/pages/Login/index.tsx
@@ -31,7 +31,12 @@ import {
} from '@superset-ui/core/components';
import { useState, useEffect, useMemo } from 'react';
import { capitalize } from 'lodash/fp';
-import { addDangerToast } from 'src/components/MessageToasts/actions';
+import {
+ addDangerToast,
+ addInfoToast,
+ addSuccessToast,
+ addWarningToast,
+} from 'src/components/MessageToasts/actions';
import { useDispatch } from 'react-redux';
import getBootstrapData from 'src/utils/getBootstrapData';
import { ensureAppRoot } from 'src/utils/navigationUtils';
@@ -115,26 +120,32 @@ export default function Login() {
const authRegistration: boolean =
bootstrapData.common.conf.AUTH_USER_REGISTRATION;
- // TODO: This is a temporary solution for showing login errors after form
submission.
- // Should be replaced with proper SPA-style authentication (JSON API with
error responses)
- // when Flask-AppBuilder is updated or we implement a custom login endpoint.
+ // Flashed auth messages (failed logins, session invalidation, forced
+ // password change) are drained by the login view into the bootstrap
+ // payload - surface them here, once, on mount.
+ const authMessages = bootstrapData.auth_messages ?? [];
useEffect(() => {
- const loginAttempted = sessionStorage.getItem('login_attempted');
-
- if (loginAttempted === 'true') {
- sessionStorage.removeItem('login_attempted');
- dispatch(addDangerToast(t('Invalid username or password')));
- // Clear password field for security
- form.setFieldsValue({ password: '' });
- }
+ authMessages.forEach(([category, message]) => {
+ if (category === 'success') {
+ dispatch(addSuccessToast(message));
+ } else if (category === 'info' || category === 'message') {
+ dispatch(addInfoToast(message));
+ } else if (category === 'warning') {
+ dispatch(addWarningToast(message));
+ } else {
+ dispatch(addDangerToast(message));
+ }
+ // Clear the password field on auth failures, independent of toast color
+ if (['warning', 'danger', 'error'].includes(category)) {
+ form.setFieldsValue({ password: '' });
+ }
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, form]);
const onFinish = (values: LoginForm) => {
setLoading(true);
- // Mark that we're attempting login (for error detection after redirect)
- sessionStorage.setItem('login_attempted', 'true');
-
// Use standard form submission for Flask-AppBuilder compatibility
SupersetClient.postForm(ensureAppRoot(loginEndpoint), values, '');
};
diff --git a/superset-frontend/src/types/bootstrapTypes.ts
b/superset-frontend/src/types/bootstrapTypes.ts
index 45d47030fdb..cdd563264d9 100644
--- a/superset-frontend/src/types/bootstrapTypes.ts
+++ b/superset-frontend/src/types/bootstrapTypes.ts
@@ -181,6 +181,7 @@ export interface CommonBootstrapData {
export interface BootstrapData {
user?: BootstrapUser;
common: CommonBootstrapData;
+ auth_messages?: [string, string][];
config?: {
GUEST_TOKEN_HEADER_NAME?: string;
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
diff --git a/superset/translations/messages.pot
b/superset/translations/messages.pot
index e38a61d782f..6ed7f38a306 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -7564,9 +7564,6 @@ msgstr ""
msgid "Invalid time range boundary for resample: %(value)s"
msgstr ""
-msgid "Invalid username or password"
-msgstr ""
-
msgid "Inverse"
msgstr ""
diff --git a/superset/views/auth.py b/superset/views/auth.py
index 123333a639e..def191dc555 100644
--- a/superset/views/auth.py
+++ b/superset/views/auth.py
@@ -18,7 +18,7 @@
import logging
from typing import Optional
-from flask import g, redirect
+from flask import g, get_flashed_messages, redirect
from flask_appbuilder import expose
from flask_appbuilder.const import LOGMSG_ERR_SEC_NO_REGISTER_HASH
from flask_appbuilder.security.decorators import no_cache
@@ -39,7 +39,14 @@ class SupersetAuthView(BaseSupersetView, AuthView):
if g.user is not None and g.user.is_authenticated:
return redirect(self.appbuilder.get_url_for_index)
- return super().render_app_template()
+ # Drain pending flash messages (failed logins, session
+ # invalidation, forced password change) so they render here
+ # instead of accumulating in the session. This must go through
+ # per-request extra_bootstrap_data, never the cached common
+ # bootstrap payload, so messages can't bleed between users.
+ return super().render_app_template(
+ {"auth_messages": get_flashed_messages(with_categories=True)}
+ )
class SupersetRegisterUserView(BaseSupersetView):
diff --git a/tests/unit_tests/views/test_auth_login.py
b/tests/unit_tests/views/test_auth_login.py
new file mode 100644
index 00000000000..eb90df2feff
--- /dev/null
+++ b/tests/unit_tests/views/test_auth_login.py
@@ -0,0 +1,77 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import os
+
+import jinja2
+import pytest
+
+
[email protected](autouse=True)
+def _register_real_templates(app) -> None:
+ """Point the unit-test app at the real template directory.
+
+ The unit-test ``app`` fixture builds ``SupersetApp(__name__)`` from the
+ conftest module, so Flask resolves ``templates/`` relative to the tests
+ directory instead of the installed ``superset`` package.
+ """
+ import superset
+
+ templates = os.path.join(os.path.dirname(superset.__file__), "templates")
+ app.jinja_loader = jinja2.ChoiceLoader(
+ [app.jinja_loader, jinja2.FileSystemLoader(templates)]
+ )
+
+
+def test_login_page_drains_flash_messages(client) -> None:
+ """Pending flash messages render on the login page and leave the
session."""
+ with client.session_transaction() as session:
+ session["_flashes"] = [("warning", "Invalid login. Please try again.")]
+
+ response = client.get("/login/")
+
+ assert response.status_code == 200
+ assert b"Invalid login. Please try again." in response.data
+ with client.session_transaction() as session:
+ assert "_flashes" not in session
+
+
+def test_login_page_does_not_accumulate_flash_messages(client) -> None:
+ """Messages from repeated failed logins are delivered once, not hoarded."""
+ with client.session_transaction() as session:
+ session["_flashes"] = [
+ ("warning", "Invalid login. Please try again."),
+ ("warning", "Invalid login. Please try again."),
+ ]
+
+ first = client.get("/login/")
+ assert first.data.count(b"Invalid login. Please try again.") == 2
+
+ second = client.get("/login/")
+ assert b"Invalid login. Please try again." not in second.data
+
+
+def test_login_page_without_flash_messages(client) -> None:
+ """A clean session renders the login page with an empty message list."""
+ from html import unescape
+
+ response = client.get("/login/")
+
+ assert response.status_code == 200
+ data = unescape(response.data.decode())
+ assert '"auth_messages":[]' in data.replace(" ", "")