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,