This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new df145d145e3 Add Reset Password button to Edit User view (#70243)
df145d145e3 is described below

commit df145d145e3d71d5a75b85a8d5cfbe20f2637979
Author: Aaryan Mahajan <[email protected]>
AuthorDate: Tue Sep 22 03:23:41 2026 +0530

    Add Reset Password button to Edit User view (#70243)
    
    The Show User view already surfaced a Reset Password action, but the Edit 
User view
    had no equivalent, forcing admins back to Show User just to reset a 
password.
    
    CustomUserDBModelView now renders a dedicated edit template that extends 
FAB's
    edit.html and appends the existing resetpasswords action outside the model 
form.
    No new routes or permissions are introduced: link visibility follows read 
access on
    Users, exactly as on the Show User view, while performing the reset still 
requires
    read access on Passwords.
    
    closes: #37030
---
 .../providers/fab/auth_manager/views/user.py       | 20 ++++++
 .../appbuilder/general/model/user_edit.html        | 29 +++++++++
 .../fab/www/views/test_views_custom_user_views.py  | 74 +++++++++++++++++++++-
 3 files changed, 122 insertions(+), 1 deletion(-)

diff --git a/providers/fab/src/airflow/providers/fab/auth_manager/views/user.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/views/user.py
index 550bec7445f..4c2b99f5f6a 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/views/user.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/views/user.py
@@ -204,3 +204,23 @@ class CustomUserDBModelView(MultiResourceUserMixin, 
UserDBModelView):
         permissions.ACTION_CAN_EDIT,
         permissions.ACTION_CAN_DELETE,
     ]
+
+    edit_template = "appbuilder/general/model/user_edit.html"
+
+    @expose("/edit/<pk>", methods=["GET", "POST"])
+    @has_access
+    def edit(self, pk):
+        pk = self._deserialize_pk_if_composite(pk)
+        widgets = self._edit(pk)
+        if not widgets:
+            return self.post_edit_redirect()
+        return self.render_template(
+            self.edit_template,
+            title=self.edit_title,
+            widgets=widgets,
+            related_views=self._related_views,
+            # Surface the same "Reset Password" action already available on 
the Show User view.
+            actions={"resetpasswords": self.actions.get("resetpasswords")},
+            pk=pk,
+            modelview_name=self.__class__.__name__,
+        )
diff --git 
a/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/model/user_edit.html
 
b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/model/user_edit.html
new file mode 100644
index 00000000000..a6b141af30c
--- /dev/null
+++ 
b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/model/user_edit.html
@@ -0,0 +1,29 @@
+<!--
+ 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.
+-->
+{% extends "appbuilder/general/model/edit.html" %}
+{% import 'appbuilder/general/lib.html' as lib %}
+
+{% block edit_form %}
+  {{ super() }}
+  {% if actions %}
+  <div class="well well-sm">
+      {{ lib.render_action_links(actions, pk, modelview_name) }}
+  </div>
+  {% endif %}
+{% endblock %}
diff --git 
a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py 
b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
index 2cdc69bf834..08709a87fdc 100644
--- a/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
+++ b/providers/fab/tests/unit/fab/www/views/test_views_custom_user_views.py
@@ -34,7 +34,7 @@ from unit.fab.auth_manager.test_utils import (
     delete_role,
     delete_user,
 )
-from unit.fab.utils import check_content_in_response, client_with_login
+from unit.fab.utils import check_content_in_response, 
check_content_not_in_response, client_with_login
 
 pytestmark = pytest.mark.db_test
 
@@ -141,6 +141,78 @@ class TestSecurity:
         response = client.get(url.replace("{user.id}", 
str(user_with_access.id)), follow_redirects=True)
         check_content_in_response(expected_text, response)
 
+    def test_user_edit_view_shows_reset_password_action_with_access(self, app, 
client):
+        # Visibility of the action link is gated on "read" access to Users 
(the same
+        # rule the Show User view relies on); "read" on Passwords is what's 
required to
+        # actually perform the reset once the link is followed.
+        user_with_access = create_user(
+            app,
+            username="has_access",
+            role_name="role_has_access",
+            permissions=[
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_USER),
+                (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_USER),
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_PASSWORD),
+            ],
+        )
+        client = client_with_login(
+            app,
+            username="has_access",
+            password="has_access",
+        )
+        response = client.get(f"/users/edit/{user_with_access.id}", 
follow_redirects=True)
+        check_content_in_response("Reset Password", response)
+
+        response = 
client.post(f"/users/action/resetpasswords/{user_with_access.id}", 
follow_redirects=False)
+        assert response.status_code == 302
+        assert "/resetpassword/form" in response.location
+
+    def test_user_edit_view_hides_reset_password_action_without_access(self, 
app, client):
+        # No "read" access to Users means the action link is not visible, even 
though
+        # the user can still reach the edit page via "edit" access to Users.
+        user_with_access = create_user(
+            app,
+            username="has_access",
+            role_name="role_has_access",
+            permissions=[
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
+                (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_USER),
+            ],
+        )
+        client = client_with_login(
+            app,
+            username="has_access",
+            password="has_access",
+        )
+        response = client.get(f"/users/edit/{user_with_access.id}", 
follow_redirects=True)
+        check_content_not_in_response("Reset Password", response)
+
+    def 
test_user_edit_view_shows_reset_password_action_without_passwords_read_access(self,
 app, client):
+        # The link's visibility follows "read" on Users, not "read" on 
Passwords, so a user
+        # without the latter still sees the link, while following it is 
refused.
+        user_with_access = create_user(
+            app,
+            username="has_access",
+            role_name="role_has_access",
+            permissions=[
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_WEBSITE),
+                (permissions.ACTION_CAN_READ, permissions.RESOURCE_USER),
+                (permissions.ACTION_CAN_EDIT, permissions.RESOURCE_USER),
+            ],
+        )
+        client = client_with_login(
+            app,
+            username="has_access",
+            password="has_access",
+        )
+        response = client.get(f"/users/edit/{user_with_access.id}", 
follow_redirects=True)
+        check_content_in_response("Reset Password", response)
+
+        response = 
client.post(f"/users/action/resetpasswords/{user_with_access.id}", 
follow_redirects=False)
+        assert response.status_code == 302
+        assert "resetpassword" not in response.location
+
     def test_user_model_view_without_delete_access(self, app, client):
         user_to_delete = create_user(
             app,

Reply via email to