diff --git a/netbox/users/forms/__init__.py b/netbox/users/forms/__init__.py
new file mode 100644
index 000000000..1499f98b2
--- /dev/null
+++ b/netbox/users/forms/__init__.py
@@ -0,0 +1,4 @@
+from .bulk_edit import *
+from .bulk_import import *
+from .filtersets import *
+from .model_forms import *
diff --git a/netbox/users/forms/bulk_edit.py b/netbox/users/forms/bulk_edit.py
new file mode 100644
index 000000000..dd2dc6aa9
--- /dev/null
+++ b/netbox/users/forms/bulk_edit.py
@@ -0,0 +1,38 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
+from circuits.models import *
+from ipam.models import ASN
+from netbox.forms import NetBoxModelBulkEditForm
+from tenancy.models import Tenant
+from utilities.forms import add_blank_choice
+from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import DatePicker, NumberWithOptions
+
+__all__ = (
+ 'UserBulkEditForm',
+)
+
+
+class UserBulkEditForm(NetBoxModelBulkEditForm):
+ asns = DynamicModelMultipleChoiceField(
+ queryset=ASN.objects.all(),
+ label=_('ASNs'),
+ required=False
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ label=_('Comments')
+ )
+
+ model = Provider
+ fieldsets = (
+ (None, ('asns', 'description')),
+ )
+ nullable_fields = (
+ 'asns', 'description', 'comments',
+ )
diff --git a/netbox/users/forms/bulk_import.py b/netbox/users/forms/bulk_import.py
new file mode 100644
index 000000000..aa3718d24
--- /dev/null
+++ b/netbox/users/forms/bulk_import.py
@@ -0,0 +1,24 @@
+from django import forms
+
+from circuits.choices import CircuitStatusChoices
+from circuits.models import *
+from dcim.models import Site
+from django.utils.translation import gettext as _
+from netbox.forms import NetBoxModelImportForm
+from tenancy.models import Tenant
+from utilities.forms import BootstrapMixin
+from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
+
+__all__ = (
+ 'UserImportForm',
+)
+
+
+class UserImportForm(NetBoxModelImportForm):
+ slug = SlugField()
+
+ class Meta:
+ model = Provider
+ fields = (
+ 'name', 'slug', 'description', 'comments', 'tags',
+ )
diff --git a/netbox/users/forms/filtersets.py b/netbox/users/forms/filtersets.py
new file mode 100644
index 000000000..30b6bd832
--- /dev/null
+++ b/netbox/users/forms/filtersets.py
@@ -0,0 +1,55 @@
+from django import forms
+from django.utils.translation import gettext as _
+
+from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
+from circuits.models import *
+from dcim.models import Region, Site, SiteGroup
+from ipam.models import ASN
+from netbox.forms import NetBoxModelFilterSetForm
+from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
+from users.models import NetBoxUser
+from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField
+from utilities.forms.widgets import DatePicker, NumberWithOptions
+
+__all__ = (
+ 'UserFilterForm',
+)
+
+
+class UserFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
+ model = NetBoxUser
+ fieldsets = (
+ (None, ('q', 'filter_id',)),
+ ('Location', ('region_id', 'site_group_id', 'site_id')),
+ ('ASN', ('asn',)),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
+ )
+ region_id = DynamicModelMultipleChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ label=_('Region')
+ )
+ site_group_id = DynamicModelMultipleChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ label=_('Site group')
+ )
+ site_id = DynamicModelMultipleChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ query_params={
+ 'region_id': '$region_id',
+ 'site_group_id': '$site_group_id',
+ },
+ label=_('Site')
+ )
+ asn = forms.IntegerField(
+ required=False,
+ label=_('ASN (legacy)')
+ )
+ asn_id = DynamicModelMultipleChoiceField(
+ queryset=ASN.objects.all(),
+ required=False,
+ label=_('ASNs')
+ )
+ tag = TagFilterField(model)
diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py
new file mode 100644
index 000000000..265a66cc8
--- /dev/null
+++ b/netbox/users/forms/model_forms.py
@@ -0,0 +1,153 @@
+from django import forms
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm
+from django.contrib.postgres.forms import SimpleArrayField
+from django.utils.html import mark_safe
+from django.utils.translation import gettext as _
+
+from ipam.formfields import IPNetworkFormField
+from ipam.validators import prefix_validator
+from netbox.preferences import PREFERENCES
+from utilities.forms import BootstrapMixin
+from utilities.forms.widgets import DateTimePicker
+from utilities.utils import flatten_dict
+from users.models import *
+
+
+__all__ = (
+ 'LoginForm',
+ 'PasswordChangeForm',
+ 'TokenForm',
+ 'UserConfigForm',
+ 'UserForm',
+)
+
+
+class LoginForm(BootstrapMixin, AuthenticationForm):
+ pass
+
+
+class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm):
+ pass
+
+
+class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
+
+ def __new__(mcs, name, bases, attrs):
+
+ # Emulate a declared field for each supported user preference
+ preference_fields = {}
+ for field_name, preference in PREFERENCES.items():
+ description = f'{preference.description}
' if preference.description else ''
+ help_text = f'{description}{field_name}
'
+ field_kwargs = {
+ 'label': preference.label,
+ 'choices': preference.choices,
+ 'help_text': mark_safe(help_text),
+ 'coerce': preference.coerce,
+ 'required': False,
+ 'widget': forms.Select,
+ }
+ preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs)
+ attrs.update(preference_fields)
+
+ return super().__new__(mcs, name, bases, attrs)
+
+
+class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass):
+ fieldsets = (
+ ('User Interface', (
+ 'pagination.per_page',
+ 'pagination.placement',
+ 'ui.colormode',
+ )),
+ ('Miscellaneous', (
+ 'data_format',
+ )),
+ )
+ # List of clearable preferences
+ pk = forms.MultipleChoiceField(
+ choices=[],
+ required=False
+ )
+
+ class Meta:
+ model = UserConfig
+ fields = ()
+
+ def __init__(self, *args, instance=None, **kwargs):
+
+ # Get initial data from UserConfig instance
+ initial_data = flatten_dict(instance.data)
+ kwargs['initial'] = initial_data
+
+ super().__init__(*args, instance=instance, **kwargs)
+
+ # Compile clearable preference choices
+ self.fields['pk'].choices = (
+ (f'tables.{table_name}', '') for table_name in instance.data.get('tables', [])
+ )
+
+ def save(self, *args, **kwargs):
+
+ # Set UserConfig data
+ for pref_name, value in self.cleaned_data.items():
+ if pref_name == 'pk':
+ continue
+ self.instance.set(pref_name, value, commit=False)
+
+ # Clear selected preferences
+ for preference in self.cleaned_data['pk']:
+ self.instance.clear(preference)
+
+ return super().save(*args, **kwargs)
+
+ @property
+ def plugin_fields(self):
+ return [
+ name for name in self.fields.keys() if name.startswith('plugins.')
+ ]
+
+
+class TokenForm(BootstrapMixin, forms.ModelForm):
+ key = forms.CharField(
+ required=False,
+ help_text=_("If no key is provided, one will be generated automatically.")
+ )
+ allowed_ips = SimpleArrayField(
+ base_field=IPNetworkFormField(validators=[prefix_validator]),
+ required=False,
+ label=_('Allowed IPs'),
+ help_text=_('Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. '
+ 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64
'),
+ )
+
+ class Meta:
+ model = Token
+ fields = [
+ 'key', 'write_enabled', 'expires', 'description', 'allowed_ips',
+ ]
+ widgets = {
+ 'expires': DateTimePicker(),
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Omit the key field if token retrieval is not permitted
+ if self.instance.pk and not settings.ALLOW_TOKEN_RETRIEVAL:
+ del self.fields['key']
+
+
+class UserForm(BootstrapMixin, forms.ModelForm):
+
+ fieldsets = (
+ ('User', ('username', )),
+ )
+
+ class Meta:
+ model = NetBoxUser
+ fields = [
+ 'username',
+ ]