From 1c22b1f017b2fe9ca52386565f2c1db6c9bf5fab Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Wed, 22 Jul 2026 19:03:21 +0400 Subject: [PATCH 1/4] Add Reset Password action to Edit User view The Show User view already surfaces a Reset Password button via FAB's show-widget actions block, but the Edit User view has no equivalent, so admins have to navigate back to Show User just to reset a password. This wires the same, already-registered resetpasswords action into the Edit User page by giving CustomUserDBModelView its own edit widget and template that render the action link, mirroring the existing Show User override pattern. closes: #37030 --- .../providers/fab/auth_manager/views/user.py | 27 ++++++++ .../general/widgets/user_edit_form.html | 64 +++++++++++++++++++ .../www/views/test_views_custom_user_views.py | 45 ++++++++++++- 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html 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 550bec7445f4c..8b41596344946 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 @@ -26,6 +26,7 @@ UserOAuthModelView, UserRemoteUserModelView, ) +from flask_appbuilder.widgets import FormVerticalWidget from wtforms.validators import DataRequired from airflow.providers.fab.www.security import permissions @@ -151,6 +152,12 @@ class CustomUserRemoteUserModelView(MultiResourceUserMixin, UserRemoteUserModelV ] +class UserEditFormWidget(FormVerticalWidget): + """Edit form widget that also renders single-item actions, e.g. the Reset Password button.""" + + template = "appbuilder/general/widgets/user_edit_form.html" + + class CustomUserDBModelView(MultiResourceUserMixin, UserDBModelView): """Customize permission names for FAB's builtin UserDBModelView.""" @@ -204,3 +211,23 @@ class CustomUserDBModelView(MultiResourceUserMixin, UserDBModelView): permissions.ACTION_CAN_EDIT, permissions.ACTION_CAN_DELETE, ] + + edit_widget = UserEditFormWidget + + @expose("/edit/", 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() + # Surface the same "Reset Password" action already available on the Show User view. + widgets["edit"].template_args["actions"] = {"resetpasswords": self.actions.get("resetpasswords")} + widgets["edit"].template_args["pk"] = pk + widgets["edit"].template_args["modelview_name"] = self.__class__.__name__ + return self.render_template( + self.edit_template, + title=self.edit_title, + widgets=widgets, + related_views=self._related_views, + ) diff --git a/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html new file mode 100644 index 0000000000000..e42246f97a649 --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html @@ -0,0 +1,64 @@ + +{# + Same as flask_appbuilder's appbuilder/general/widgets/form_vertical.html, extended with an + "actions" block so the Edit User view can surface single-item actions (e.g. Reset Password) + the same way appbuilder/general/widgets/show.html already does. +#} +{% import 'appbuilder/general/lib.html' as lib %} + +{% if form_action is defined %} +
+{% endif %} + + {{form.hidden_tag()}} + + {% if fieldsets %} + {% for fieldset_item in fieldsets %} + {% if fieldset_item[1].get('expanded') == None %} + {% set expanded = True %} + {% else %} + {% set expanded = fieldset_item[1].get('expanded') %} + {% endif %} + {% call lib.accordion_tag(loop.index,fieldset_item[0], expanded) %} + {% for item in fieldset_item[1].get('fields') %} + {% if item not in exclude_cols %} + {{ lib.render_field(form[item]) }} + {% endif %} + {% endfor %} + + {% endcall %} + {% endfor %} + {% else %} +
+ {% for col in include_cols %} + {% set field = form[col] %} + {% if field.name not in exclude_cols %} + {{ lib.render_field(field) }} + {% endif %} + {% endfor %} + {% endif %} +
+ {{ lib.render_form_controls() }} + {% if actions %} +
+ {{ lib.render_action_links(actions, pk, modelview_name) }} +
+ {% endif %} +
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 7e61cdff785ee..65fc65c64234f 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 @@ 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,49 @@ def test_user_model_view_with_access(self, url, permission, expected_text, app, 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) + + 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_model_view_without_delete_access(self, app, client): user_to_delete = create_user( app, From e3fea8dc3ce1f338b7e8944adb08cabf8ef4c94b Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Sun, 2 Aug 2026 01:10:38 +0530 Subject: [PATCH 2/4] Render the Edit User Reset Password action outside the form The previous template copied FAB's form_vertical.html and rendered the action link inside the model's own
. render_action_links emits its own , so the page ended up with a form nested inside another form, which is invalid HTML and only happened to work because browsers silently drop the inner element. Overriding the edit_form block in appbuilder/general/model/edit.html instead puts the action after the model form closes, drops the duplicated form markup, and keeps the page in sync with any future change to FAB's own template. --- .../appbuilder/general/model/user_edit.html | 29 +++++++++ .../general/widgets/user_edit_form.html | 64 ------------------- 2 files changed, 29 insertions(+), 64 deletions(-) create mode 100644 providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/model/user_edit.html delete mode 100644 providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html 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 0000000000000..a6b141af30cfd --- /dev/null +++ b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/model/user_edit.html @@ -0,0 +1,29 @@ + +{% extends "appbuilder/general/model/edit.html" %} +{% import 'appbuilder/general/lib.html' as lib %} + +{% block edit_form %} + {{ super() }} + {% if actions %} +
+ {{ lib.render_action_links(actions, pk, modelview_name) }} +
+ {% endif %} +{% endblock %} diff --git a/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html b/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html deleted file mode 100644 index e42246f97a649..0000000000000 --- a/providers/fab/src/airflow/providers/fab/www/templates/appbuilder/general/widgets/user_edit_form.html +++ /dev/null @@ -1,64 +0,0 @@ - -{# - Same as flask_appbuilder's appbuilder/general/widgets/form_vertical.html, extended with an - "actions" block so the Edit User view can surface single-item actions (e.g. Reset Password) - the same way appbuilder/general/widgets/show.html already does. -#} -{% import 'appbuilder/general/lib.html' as lib %} - -{% if form_action is defined %} - -{% endif %} - - {{form.hidden_tag()}} - - {% if fieldsets %} - {% for fieldset_item in fieldsets %} - {% if fieldset_item[1].get('expanded') == None %} - {% set expanded = True %} - {% else %} - {% set expanded = fieldset_item[1].get('expanded') %} - {% endif %} - {% call lib.accordion_tag(loop.index,fieldset_item[0], expanded) %} - {% for item in fieldset_item[1].get('fields') %} - {% if item not in exclude_cols %} - {{ lib.render_field(form[item]) }} - {% endif %} - {% endfor %} - - {% endcall %} - {% endfor %} - {% else %} -
- {% for col in include_cols %} - {% set field = form[col] %} - {% if field.name not in exclude_cols %} - {{ lib.render_field(field) }} - {% endif %} - {% endfor %} - {% endif %} -
- {{ lib.render_form_controls() }} - {% if actions %} -
- {{ lib.render_action_links(actions, pk, modelview_name) }} -
- {% endif %} -
From 5c6d0fdb64e34df08fccdd739dfa164fe9a1b9eb Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Sun, 2 Aug 2026 01:17:00 +0530 Subject: [PATCH 3/4] Drop the now-unused Edit User widget subclass The view previously reached into widgets["edit"].template_args to inject the Reset Password action; now that the action is rendered by the edit_form block instead of a widget, the view can pass it straight to render_template. --- .../providers/fab/auth_manager/views/user.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) 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 8b41596344946..4c2b99f5f6a34 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 @@ -26,7 +26,6 @@ UserOAuthModelView, UserRemoteUserModelView, ) -from flask_appbuilder.widgets import FormVerticalWidget from wtforms.validators import DataRequired from airflow.providers.fab.www.security import permissions @@ -152,12 +151,6 @@ class CustomUserRemoteUserModelView(MultiResourceUserMixin, UserRemoteUserModelV ] -class UserEditFormWidget(FormVerticalWidget): - """Edit form widget that also renders single-item actions, e.g. the Reset Password button.""" - - template = "appbuilder/general/widgets/user_edit_form.html" - - class CustomUserDBModelView(MultiResourceUserMixin, UserDBModelView): """Customize permission names for FAB's builtin UserDBModelView.""" @@ -212,7 +205,7 @@ class CustomUserDBModelView(MultiResourceUserMixin, UserDBModelView): permissions.ACTION_CAN_DELETE, ] - edit_widget = UserEditFormWidget + edit_template = "appbuilder/general/model/user_edit.html" @expose("/edit/", methods=["GET", "POST"]) @has_access @@ -221,13 +214,13 @@ def edit(self, pk): widgets = self._edit(pk) if not widgets: return self.post_edit_redirect() - # Surface the same "Reset Password" action already available on the Show User view. - widgets["edit"].template_args["actions"] = {"resetpasswords": self.actions.get("resetpasswords")} - widgets["edit"].template_args["pk"] = pk - widgets["edit"].template_args["modelview_name"] = self.__class__.__name__ 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__, ) From b2a25d6210b8fb679bd2d78f07ddfabce9038eb1 Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Mon, 21 Sep 2026 23:27:30 +0530 Subject: [PATCH 4/4] Pin Reset Password link visibility to Users read on the Edit User view The existing pair of tests varied Users read and Passwords read together, so they could not tell apart "link is gated on Users read" from "link is gated on Passwords read". Cover the seam explicitly: a user with Users read but without Passwords read sees the link, and following it is refused. --- .../www/views/test_views_custom_user_views.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 843fe67eb3067..08709a87fdc5f 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 @@ -164,6 +164,10 @@ def test_user_edit_view_shows_reset_password_action_with_access(self, app, clien 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. @@ -184,6 +188,31 @@ def test_user_edit_view_hides_reset_password_action_without_access(self, app, cl 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,