diff --git a/django/contrib/auth/forms.py b/django/contrib/auth/forms.py index ca7529ab5f..23d5bdafed 100644 --- a/django/contrib/auth/forms.py +++ b/django/contrib/auth/forms.py @@ -78,12 +78,12 @@ class UserCreationForm(forms.ModelForm): password1 = forms.CharField( label=_("Password"), strip=False, - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), help_text=password_validation.password_validators_help_text_html(), ) password2 = forms.CharField( label=_("Password confirmation"), - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), strip=False, help_text=_("Enter the same password as before, for verification."), ) @@ -96,7 +96,10 @@ class UserCreationForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self._meta.model.USERNAME_FIELD in self.fields: - self.fields[self._meta.model.USERNAME_FIELD].widget.attrs.update({'autofocus': True}) + self.fields[self._meta.model.USERNAME_FIELD].widget.attrs.update({ + 'autocomplete': 'username', + 'autofocus': True, + }) def clean_password2(self): password1 = self.cleaned_data.get("password1") @@ -163,11 +166,11 @@ class AuthenticationForm(forms.Form): Base class for authenticating users. Extend this to get a form that accepts username/password logins. """ - username = UsernameField(widget=forms.TextInput(attrs={'autofocus': True})) + username = UsernameField(widget=forms.TextInput(attrs={'autocomplete': 'username', 'autofocus': True})) password = forms.CharField( label=_("Password"), strip=False, - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}), ) error_messages = { @@ -235,7 +238,11 @@ class AuthenticationForm(forms.Form): class PasswordResetForm(forms.Form): - email = forms.EmailField(label=_("Email"), max_length=254) + email = forms.EmailField( + label=_("Email"), + max_length=254, + widget=forms.EmailInput(attrs={'autocomplete': 'email'}) + ) def send_mail(self, subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name=None): @@ -311,14 +318,14 @@ class SetPasswordForm(forms.Form): } new_password1 = forms.CharField( label=_("New password"), - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), strip=False, help_text=password_validation.password_validators_help_text_html(), ) new_password2 = forms.CharField( label=_("New password confirmation"), strip=False, - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), ) def __init__(self, user, *args, **kwargs): @@ -357,7 +364,7 @@ class PasswordChangeForm(SetPasswordForm): old_password = forms.CharField( label=_("Old password"), strip=False, - widget=forms.PasswordInput(attrs={'autofocus': True}), + widget=forms.PasswordInput(attrs={'autocomplete': 'current-password', 'autofocus': True}), ) field_order = ['old_password', 'new_password1', 'new_password2'] @@ -385,13 +392,13 @@ class AdminPasswordChangeForm(forms.Form): required_css_class = 'required' password1 = forms.CharField( label=_("Password"), - widget=forms.PasswordInput(attrs={'autofocus': True}), + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'autofocus': True}), strip=False, help_text=password_validation.password_validators_help_text_html(), ) password2 = forms.CharField( label=_("Password (again)"), - widget=forms.PasswordInput, + widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}), strip=False, help_text=_("Enter the same password as before, for verification."), ) diff --git a/docs/releases/3.0.txt b/docs/releases/3.0.txt index 1ba81c5643..f6a45ebfc9 100644 --- a/docs/releases/3.0.txt +++ b/docs/releases/3.0.txt @@ -76,6 +76,10 @@ Minor features to mirror the existing :meth:`~django.contrib.auth.models.User.get_group_permissions()` method. +* Added HTML ``autocomplete`` attribute to widgets of username, email, and + password fields in :mod:`django.contrib.auth.forms` for better interaction + with browser password managers. + :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/auth_tests/test_forms.py b/tests/auth_tests/test_forms.py index 19397ad5d6..f70a7f830d 100644 --- a/tests/auth_tests/test_forms.py +++ b/tests/auth_tests/test_forms.py @@ -265,6 +265,17 @@ class UserCreationFormTest(TestDataMixin, TestCase): form = UserCreationForm() self.assertEqual(form.fields['username'].widget.attrs.get('autocapitalize'), 'none') + def test_html_autocomplete_attributes(self): + form = UserCreationForm() + tests = ( + ('username', 'username'), + ('password1', 'new-password'), + ('password2', 'new-password'), + ) + for field_name, autocomplete in tests: + with self.subTest(field_name=field_name, autocomplete=autocomplete): + self.assertEqual(form.fields[field_name].widget.attrs['autocomplete'], autocomplete) + # To verify that the login form rejects inactive users, use an authentication # backend that allows them. @@ -492,6 +503,16 @@ class AuthenticationFormTest(TestDataMixin, TestCase): self.assertEqual(error.code, 'invalid_login') self.assertEqual(error.params, {'username': 'username'}) + def test_html_autocomplete_attributes(self): + form = AuthenticationForm() + tests = ( + ('username', 'username'), + ('password', 'current-password'), + ) + for field_name, autocomplete in tests: + with self.subTest(field_name=field_name, autocomplete=autocomplete): + self.assertEqual(form.fields[field_name].widget.attrs['autocomplete'], autocomplete) + class SetPasswordFormTest(TestDataMixin, TestCase): @@ -572,6 +593,16 @@ class SetPasswordFormTest(TestDataMixin, TestCase): for french_text in french_help_texts: self.assertIn(french_text, html) + def test_html_autocomplete_attributes(self): + form = SetPasswordForm(self.u1) + tests = ( + ('new_password1', 'new-password'), + ('new_password2', 'new-password'), + ) + for field_name, autocomplete in tests: + with self.subTest(field_name=field_name, autocomplete=autocomplete): + self.assertEqual(form.fields[field_name].widget.attrs['autocomplete'], autocomplete) + class PasswordChangeFormTest(TestDataMixin, TestCase): @@ -633,6 +664,11 @@ class PasswordChangeFormTest(TestDataMixin, TestCase): self.assertEqual(form.cleaned_data['new_password1'], data['new_password1']) self.assertEqual(form.cleaned_data['new_password2'], data['new_password2']) + def test_html_autocomplete_attributes(self): + user = User.objects.get(username='testclient') + form = PasswordChangeForm(user) + self.assertEqual(form.fields['old_password'].widget.attrs['autocomplete'], 'current-password') + class UserChangeFormTest(TestDataMixin, TestCase): @@ -916,6 +952,10 @@ class PasswordResetFormTest(TestDataMixin, TestCase): self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, [email]) + def test_html_autocomplete_attributes(self): + form = PasswordResetForm() + self.assertEqual(form.fields['email'].widget.attrs['autocomplete'], 'email') + class ReadOnlyPasswordHashTest(SimpleTestCase): @@ -997,3 +1037,14 @@ class AdminPasswordChangeFormTest(TestDataMixin, TestCase): form2 = AdminPasswordChangeForm(user, {'password1': 'test', 'password2': ''}) self.assertEqual(form2.errors['password2'], required_error) self.assertNotIn('password1', form2.errors) + + def test_html_autocomplete_attributes(self): + user = User.objects.get(username='testclient') + form = AdminPasswordChangeForm(user) + tests = ( + ('password1', 'new-password'), + ('password2', 'new-password'), + ) + for field_name, autocomplete in tests: + with self.subTest(field_name=field_name, autocomplete=autocomplete): + self.assertEqual(form.fields[field_name].widget.attrs['autocomplete'], autocomplete)