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(" ", "")

Reply via email to