diff --git a/djblets/avatars/__init__.py b/djblets/avatars/__init__.py
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5783b9aff98c51803292fc33f12d1da8611d584a 100644
--- a/djblets/avatars/__init__.py
+++ b/djblets/avatars/__init__.py
@@ -0,0 +1,6 @@
+"""Avatar support for Djblets."""
+
+from __future__ import unicode_literals
+
+
+default_app_config = 'djblets.avatars.apps.AvatarsAppConfig'
diff --git a/djblets/avatars/apps.py b/djblets/avatars/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..1d7df56b421ced69aa02810f1e598b3ef13b80e9
--- /dev/null
+++ b/djblets/avatars/apps.py
@@ -0,0 +1,14 @@
+"""The app configuration for djblets.avatars."""
+
+from __future__ import unicode_literals
+
+try:
+    from django.apps import AppConfig
+except ImportError:
+    # Django < 1.7
+    AppConfig = object
+
+
+class AvatarsAppConfig(AppConfig):
+    name = 'djblets.avatars'
+    label = 'djblets_avatars'
diff --git a/djblets/avatars/forms.py b/djblets/avatars/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..d91da4f5c0fb64c3df4d99cb032b7fd0d97af37f
--- /dev/null
+++ b/djblets/avatars/forms.py
@@ -0,0 +1,284 @@
+"""Forms for Djblets' avatar support."""
+
+from __future__ import unicode_literals
+
+from django import forms
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _
+
+from djblets.configforms.forms import ConfigPageForm
+from djblets.registries.errors import ItemLookupError
+
+
+class AvatarServiceConfigForm(ConfigPageForm):
+    """An avatar service configuration form."""
+
+    js_view_class = 'Djblets.Avatars.ServiceSettingsFormView'
+    template_name = 'avatars/service_form.html'
+
+    #: The avatar service ID of the associated service.
+    avatar_service_id = None
+
+    def __init__(self, configuration, *args, **kwargs):
+        """Initialize the configuration form.
+
+        Args:
+            configuration (dict):
+                The current configuration
+
+            *args (tuple):
+                Additional positional arguments for the superclass constructor.
+
+            **kwargs (dict):
+                Additional keyword arguments for the superclass constructor.
+        """
+        super(AvatarServiceConfigForm, self).__init__(*args, **kwargs)
+        self.fields.pop('form_target')
+        self.configuration = configuration
+
+    def get_extra_context(self):
+        """Return extra rendering context.
+
+        Returns:
+            dict:
+            Extra rendering context.
+        """
+        return {
+            'avatar_service_id': self.avatar_service_id,
+            'configuration': self.configuration,
+        }
+
+
+class AvatarSettingsForm(ConfigPageForm):
+    """The avatar settings form.
+
+    This allows users to select the avatar service they wish to use and, if
+    necessary, configure it (e.g., by uploading an avatar).
+    """
+
+    form_id = 'avatar'
+    form_title = _('Avatar')
+
+    js_view_class = 'Djblets.Avatars.SettingsFormView'
+    js_model_class = 'Djblets.Avatars.Settings'
+    template_name = 'avatars/settings_form.html'
+
+    #: The avatar service registry. Subclasses must override this.
+    avatar_service_registry = None
+
+    avatar_service_id = forms.ChoiceField(
+        label=_('Avatar Service'),
+        required=True)
+
+    @property
+    def is_multipart(self):
+        """Whether or not the form is multi-part.
+
+        The form is multi-part when there is an enabled avatar service that has
+        a multi-part configuration form.
+
+        Returns:
+            bool:
+            Whether or not the form is multi-part.
+        """
+        for service in self.avatar_service_registry.configurable_services:
+            if service.config_form_class.is_multipart:
+                return True
+
+        return False
+
+    @property
+    def js_bundle_names(self):
+        """Yield the bundle names necessary.
+
+        Each avatar service can specify a configuration form that may
+        specify JS bundles. Since those forms are not registered through the
+        page, we must add them this way.
+
+        Yields:
+            unicode: The names of the JS bundles to load on the page.
+        """
+        yield 'djblets-utils'
+        yield 'djblets-avatars-config'
+
+        for service in self.avatar_service_registry.configurable_services:
+            for bundle in service.config_form_class.js_bundle_names:
+                yield bundle
+
+    @property
+    def css_bundle_names(self):
+        """Yield the CSS bundle names.
+
+        Each avatar service can specify a configuration form that may
+        specify CSS bundles. Since those forms are not registered through the
+        page, we must add them this way.
+
+        Yields:
+            unicode: The names of the CSS bundles to load on the page.
+        """
+        yield 'djblets-avatars-config'
+
+        for service in self.avatar_service_registry.configurable_services:
+            for bundle in service.config_form_class.css_bundle_names:
+                yield bundle
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the form."""
+        super(AvatarSettingsForm, self).__init__(*args, **kwargs)
+
+        self.settings_manager = \
+            self.avatar_service_registry.settings_manager_class(self.user)
+
+        avatar_service_id = self.fields['avatar_service_id']
+        avatar_service_id.choices = [
+            (service.avatar_service_id, service.name)
+            for service in self.avatar_service_registry.enabled_services
+        ]
+        avatar_service = self.avatar_service_registry.for_user(self.user)
+
+        avatar_service_id.initial = avatar_service.avatar_service_id
+
+    def clean_avatar_service_id(self):
+        """Clean the avatar_service_id field.
+
+        This ensures that the value corresponds to a valid and enabled
+        avatar service.
+
+        Returns:
+            unicode:
+            The avatar service ID.
+
+        Raises:
+            django.core.exceptions.ValidationError:
+                Raised when the avatar service ID is invalid.
+        """
+        avatar_service_id = self.cleaned_data['avatar_service_id']
+
+        if (not self.avatar_service_registry.has_service(avatar_service_id) or
+            not self.avatar_service_registry.is_enabled(avatar_service_id)):
+            raise ValidationError(_('Invalid service ID'))
+
+        return avatar_service_id
+
+    def clean(self):
+        """Clean the form.
+
+        This will clean the avatar service configuration form of the selected
+        avatar service (if it is configurable) and raise an exception if it is
+        not valid.
+
+        This will cache any sub-form errors so that they can be rendered to the
+        user when rendering the form.
+
+        Returns:
+            dict:
+            The form's cleaned data.
+
+        Raises:
+            ValidationError:
+                Raised when the form for the selected avatar service is
+                invalid.
+        """
+        self.cleaned_data = super(AvatarSettingsForm, self).clean()
+        avatar_service_id = self.cleaned_data['avatar_service_id']
+        avatar_service = self.avatar_service_registry.get_avatar_service(
+            avatar_service_id)
+
+        if avatar_service.is_configurable:
+            config = self.settings_manager.configuration_for(avatar_service_id)
+            self._subform = avatar_service.config_form_class(
+                config, self.page, self.request, self.user,
+                data=self.request.POST, files=self.request.FILES)
+
+            if not self._subform.is_valid():
+                raise ValidationError(
+                    _('Invalid avatar service configuration.'))
+            else:
+                self.request._subform_errors = None
+
+        return self.cleaned_data
+
+    def save(self):
+        """Save the avatar settings.
+
+        This method attempts to save
+        """
+        try:
+            old_avatar_service = (
+                self.avatar_service_registry
+                .get_avatar_service(
+                    self.settings_manager.avatar_service_id)
+            )
+        except ItemLookupError:
+            old_avatar_service = None
+
+        if old_avatar_service and old_avatar_service.is_configurable:
+            old_avatar_service.cleanup(self.user)
+            self.settings_manager.configuration.pop(
+                old_avatar_service.avatar_service_id)
+
+        avatar_service_id = self.cleaned_data['avatar_service_id']
+        new_avatar_service = (
+            self.avatar_service_registry
+            .get_avatar_service(avatar_service_id)
+        )
+        self.settings_manager.avatar_service_id = avatar_service_id
+
+        if new_avatar_service.is_configurable:
+            self.settings_manager.configuration[avatar_service_id] = \
+                self._subform.save()
+
+        self.settings_manager.save()
+
+    def get_extra_context(self):
+        """Return the extra context for rendering the form.
+
+        Returns:
+            dict:
+            The extra rendering context.
+        """
+        service = self.avatar_service_registry.for_user(self.user)
+
+        config_forms = {}
+
+        for service in self.avatar_service_registry.enabled_services:
+            if service.is_configurable:
+                config = self.settings_manager.configuration_for(
+                    service.avatar_service_id)
+
+                config_forms[service.avatar_service_id] = \
+                    service.config_form_class(config, self.page, self.request,
+                                              self.user)
+
+        # We previously cached the selected avatar service's configuration form
+        # in clean(). Now, we update the errors dictionary of our newly created
+        # sub-form so that validation errors can be displayed to the user.
+        if hasattr(self, '_subform'):
+            config_forms[self._subform.avatar_service_id].errors.update(
+                self._subform.errors)
+
+        return {
+            'current_avatar_service': service,
+            'avatar_services': self.avatar_service_registry.enabled_services,
+            'forms': config_forms,
+        }
+
+    def get_js_model_data(self):
+        """Return the JS model data for the form.
+
+        Returns:
+            dict:
+            A dictionary of the model data for the form.
+        """
+        service = self.avatar_service_registry.for_user(self.user)
+
+        return {
+            'configuration': self.settings_manager.configuration,
+            'serviceID': service.avatar_service_id,
+            'services': {
+                service.avatar_service_id: {
+                    'isConfigurable': service.is_configurable,
+                }
+                for service in self.avatar_service_registry.enabled_services
+            },
+        }
diff --git a/djblets/avatars/registry.py b/djblets/avatars/registry.py
index 0cbf4cf778018a895fe814a7c0d19187bcba44b5..d8c446c69a94e76ba0fa4c5865bcc7b4380478fb 100644
--- a/djblets/avatars/registry.py
+++ b/djblets/avatars/registry.py
@@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _
 from djblets.avatars.errors import (AvatarServiceNotFoundError,
                                     DisabledServiceError)
 from djblets.avatars.services.gravatar import GravatarService
+from djblets.avatars.settings import AvatarSettingsManager
 from djblets.registries.errors import ItemLookupError
 from djblets.registries.registry import (ALREADY_REGISTERED,
                                          ATTRIBUTE_REGISTERED, DEFAULT_ERRORS,
@@ -85,6 +86,9 @@ class AvatarServiceRegistry(Registry):
         GravatarService,
     ]
 
+    #: The settings manager for avatar services.
+    settings_manager_class = AvatarSettingsManager
+
     def __init__(self):
         """Initialize the avatar service registry."""
         super(AvatarServiceRegistry, self).__init__()
@@ -110,6 +114,22 @@ class AvatarServiceRegistry(Registry):
         return self.get('avatar_service_id', avatar_service_id)
 
     @property
+    def configurable_services(self):
+        """Yield the enabled services that have configuration forms.
+
+        Yields:
+            tuple:
+            djblets.avatars.forms.AvatarServiceConfigForm:
+            The enabled services that have configuration forms.
+        """
+        self.populate()
+        return (
+            service
+            for service in self.enabled_services
+            if service.is_configurable
+        )
+
+    @property
     def enabled_services(self):
         """Return the enabled services.
 
@@ -386,27 +406,34 @@ class AvatarServiceRegistry(Registry):
         siteconfig.set(self.DEFAULT_SERVICE_KEY, self._default_service_id)
         siteconfig.save()
 
-    def get_or_default(self, service_id=None):
-        """Return either the requested avatar service or the default.
+    def for_user(self, user, service_id=None):
+        """Return the requested avatar service for the given user.
 
-        If the requested service is unregistered or disabled, the default
-        avatar service will be returned (which may be ``None`` if there is no
-        default).
+        The following options will be tried:
+
+            * the requested avatar service (if it is enabled);
+            * the user's chosen avatar service (if it is enabled); or
+            * the default avatar service (which may be ``None``).
 
         Args:
+            user (django.contrib.auth.models.User):
+                The user to retrieve the avatar service for.
+
             service_id (unicode, optional):
                 The unique identifier of the service that is to be retrieved.
                 If this is ``None``, the default service will be used.
 
         Returns:
             djblets.avatars.services.base.AvatarService:
-            Either the requested avatar service, if it is both registered and
-            enabled, or the default avatar service. If there is no default
-            avatar service, this will return ``None``.
+            An avatar service, or ``None`` if one could not be found.
         """
-        if (service_id is not None and
-            self.has_service(service_id) and
-            self.is_enabled(service_id)):
-            return self.get_avatar_service(service_id)
+        settings_manager = self.settings_manager_class(user)
+        user_service_id = settings_manager.avatar_service_id
+
+        for sid in (service_id, user_service_id):
+            if (sid is not None and
+                self.has_service(sid) and
+                self.is_enabled(sid)):
+                return self.get_avatar_service(sid)
 
         return self.default_service
diff --git a/djblets/avatars/services/base.py b/djblets/avatars/services/base.py
index 588200af09ecc020a0b8e8c058e6bde2c0de8080..1da85708b9292b83eb895195e75bf87e65ba17b5 100644
--- a/djblets/avatars/services/base.py
+++ b/djblets/avatars/services/base.py
@@ -4,6 +4,8 @@ from __future__ import unicode_literals
 
 from django.template.loader import render_to_string
 
+from djblets.avatars.settings import AvatarSettingsManager
+
 
 class AvatarService(object):
     """A service that provides avatar support.
@@ -13,6 +15,16 @@ class AvatarService(object):
     :py:meth:`get_avatar_urls` method.
     """
 
+    def __init__(self, settings_manager_class=AvatarSettingsManager):
+        """Initialize the avatar service.
+
+        Args:
+            settings_manager_class (type):
+                The :py:class`AvatarSettingsManager` subclass to use for
+                managing settings.
+        """
+        self._settings_manager_class = settings_manager_class
+
     #: The avatar service's ID.
     #:
     #: This must be unique for every avatar service subclass.
@@ -24,6 +36,22 @@ class AvatarService(object):
     #: The template for rendering the avatar as HTML.
     template_name = 'avatars/avatar.html'
 
+    #: An optional form to provide per-user configuration for the service.
+    #:
+    #: This should be a sub-class of
+    # :py:class:`djblets.avatars.forms.AvatarServiceConfigForm`.
+    config_form_class = None
+
+    @property
+    def is_configurable(self):
+        """Whether or not the service is configurable.
+
+        Returns:
+            bool:
+            Whether or not the service is configurable.
+        """
+        return self.config_form_class is not None
+
     def get_avatar_urls(self, request, user, size):
         """Render the avatar URLs for the given user.
 
@@ -132,3 +160,15 @@ class AvatarService(object):
             'username': user.get_full_name() or user.username,
             'size': size,
         })
+
+    def cleanup(self, user):
+        """Clean up state when a user no longer uses this service.
+
+        Subclasses may use this to clean up database state or remove files. By
+        default, this method does nothing.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user who is no longer using the service.
+        """
+        pass
diff --git a/djblets/avatars/services/file_upload.py b/djblets/avatars/services/file_upload.py
new file mode 100644
index 0000000000000000000000000000000000000000..466752bb143169b9cd31aabdc46f35fa4f2e5005
--- /dev/null
+++ b/djblets/avatars/services/file_upload.py
@@ -0,0 +1,118 @@
+"""An avatar service for providing uploaded images."""
+
+from __future__ import unicode_literals
+
+from django.core.exceptions import ValidationError
+from django.core.files.storage import DefaultStorage
+from django.forms import forms
+from django.utils.translation import ugettext_lazy as _
+
+from djblets.avatars.forms import AvatarServiceConfigForm
+from djblets.avatars.services.base import AvatarService
+
+
+class FileUploadAvatarForm(AvatarServiceConfigForm):
+    """The UploadAvatarService configuration form."""
+
+    avatar_service_id = 'file-upload'
+
+    js_view_class = 'Djblets.Avatars.FileUploadSettingsFormView'
+    template_name = 'avatars/services/file_upload_form.html'
+
+    avatar_upload = forms.FileField(label=_('File'), required=True)
+
+    MAX_FILE_SIZE = 1 * 1024 * 1024
+    is_multipart = True
+
+    def clean_file(self):
+        """Ensure the uploaded file is an image of an appropriate size.
+
+        Returns:
+            django.core.files.UploadedFile:
+            The uploaded file, if it is valid.
+
+        Raises:
+            django.core.exceptions.ValidationError:
+                Raised if the file is too large or the incorrect MIME type.
+        """
+        f = self.cleaned_data['avatar_upload']
+
+        if f.size > self.MAX_FILE_SIZE:
+            raise ValidationError(_('The file is too large.'))
+
+        content_type = f.content_type.split('/')[0]
+
+        if content_type != 'image':
+            raise ValidationError(_('Only images are supported.'))
+
+        return f
+
+    def save(self):
+        """Save the file and return the configuration.
+
+        Returns:
+            dict:
+            The avatar service configuration.
+        """
+        storage = DefaultStorage()
+        file_path = self.cleaned_data['avatar_upload'].name
+        file_path = storage.get_valid_name(file_path)
+
+        with storage.open(file_path, 'wb') as f:
+            f.write(self.cleaned_data['avatar_upload'].read())
+
+        return {
+            'absolute_url': storage.url(file_path),
+            'file_path': file_path,
+        }
+
+
+class FileUploadService(AvatarService):
+    """An avatar service for uploaded images."""
+
+    avatar_service_id = 'file-upload'
+    name = _('File Upload')
+
+    config_form_class = FileUploadAvatarForm
+
+    def get_avatar_urls_uncached(self, user, size):
+        """Return the avatar URLs for the requested user.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user whose avatar URLs are to be fetched.
+
+            size (int):
+                The size (in pixels) the avatar is to be rendered at.
+
+        Returns
+            dict:
+            A dictionary containing the URLs of the user's avatars at normal-
+            and high-DPI.
+        """
+        settings_manager = self._settings_manager_class(user)
+        configuration = \
+            settings_manager.configuration_for(self.avatar_service_id)
+
+        if not configuration:
+            return {}
+
+        return {
+            '1x': configuration['absolute_url'],
+        }
+
+    def cleanup(self, user):
+        """Clean up the uploaded file.
+
+        This will delete the uploaded file from the storage.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user.
+        """
+        settings_manager = self._settings_manager_class(user)
+        configuration = settings_manager.configuration_for(
+            self.avatar_service_id)
+
+        storage = DefaultStorage()
+        storage.delete(configuration['file_path'])
diff --git a/djblets/avatars/settings.py b/djblets/avatars/settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..8ad8048bb5aed5afe3b252d4e6c2c12988f291c6
--- /dev/null
+++ b/djblets/avatars/settings.py
@@ -0,0 +1,90 @@
+"""Settings managers for avatar service registries."""
+
+from __future__ import unicode_literals
+
+
+class AvatarSettingsManager(object):
+    """The settings manager is responsible for loading and saving settings.
+
+    Each user can have different avatar configuration and the settings
+    manager is responsible for loading and saving per-user configuration for
+    services.
+    """
+
+    def __init__(self, user):
+        """Initialize the settings manager.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user.
+        """
+        self.user = user
+
+    @property
+    def avatar_service_id(self):
+        """The service ID for the user's selected avatar service.
+
+        Returns:
+            unicode:
+            The avatar service ID for the user's selected avatar service, or
+            ``None`` if they have not selected one.
+        """
+        raise NotImplementedError('%s does not implement avatar_service_id'
+                                  % type(self).__name__)
+
+    @avatar_service_id.setter
+    def avatar_service_id(self, avatar_service_id):
+        """Set the avatar service ID for the user.
+
+        Args:
+            avatar_service_id (unicode):
+                The ID of the :py:class:`avatar service
+                <djblets.avatars.services.base.AvatarService>` to set.
+        """
+        raise NotImplementedError('%s does not implement avatar_service_id'
+                                  % type(self).__name__)
+
+    @property
+    def configuration(self):
+        """The user's configuration for the service.
+
+        This must be implemented in a subclasses.
+
+        Returns:
+            dict:
+            The user's configuration.
+        """
+        raise NotImplementedError('%s does not implement configuration'
+                                  % type(self).__name__)
+
+    @configuration.setter
+    def configuration(self, settings):
+        """Set the user's configuration for the service.
+
+        This must be implemented in a subclass.
+
+        Args:
+            settings (dict):
+                The settings to save.
+        """
+        raise NotImplementedError('%s does not implement configuration'
+                                  % type(self).__name__)
+
+    def configuration_for(self, avatar_service_id):
+        """Get the configuration for the requested avatar service.
+
+        Args:
+            avatar_service_id (unicode):
+                The ID of the :py:class:`avatar service
+                <djblets.avatars.services.base.AvatarService>` to retrieve
+                configuration for.
+        """
+        raise NotImplementedError('%s does not implement configuration_for'
+                                  % type(self).__name__)
+
+    def save(self):
+        """Save the configuration.
+
+        This must be implemented in a subclass.
+        """
+        raise NotImplementedError('%s does not implement save()')
diff --git a/djblets/avatars/templates/avatars/service_form.html b/djblets/avatars/templates/avatars/service_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..1b13c028e3af6087961ba0190c5004c423c28042
--- /dev/null
+++ b/djblets/avatars/templates/avatars/service_form.html
@@ -0,0 +1,17 @@
+{% load djblets_forms djblets_utils i18n %}
+
+{% block pre_fields %}{% endblock %}
+
+{{form.non_field_errors}}
+
+{% for field in form %}
+{%  if field.is_hidden %}
+{{field}}
+{%  endif %}
+{% endfor %}
+
+{% for field in form %}
+{%  if not field.is_hidden %}
+{% label_tag field %} {{field}} {{field.errors}}
+{%  endif %}
+{% endfor %}
diff --git a/djblets/avatars/templates/avatars/services/file_upload_form.html b/djblets/avatars/templates/avatars/services/file_upload_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..9ef97142c1cc9315131729352d5f92c425167068
--- /dev/null
+++ b/djblets/avatars/templates/avatars/services/file_upload_form.html
@@ -0,0 +1,24 @@
+{% load avatars djblets_forms djblets_utils i18n %}
+
+{% block pre_fields %}{% endblock %}
+
+{{form.non_field_errors}}
+
+{% for field in form %}
+{%  if field.is_hidden %}
+{{field}}
+{%  endif %}
+{% endfor %}
+
+<div class="avatar-preview">
+{% if configuration.absolute_url %}
+ <img src="{% avatar_url request.user 128 '1x' avatar_service_id %}" alt="{% trans "Current avatar" %}">
+{% else %}
+ <div class="avatar-missing"></div>
+{% endif %}
+ <p class="caption">{% trans "Avatar Preview" %}</p>
+</div>
+
+{% label_tag form.avatar_upload %}
+{{form.avatar_upload}}
+{{form.avatar_upload.errors}}
diff --git a/djblets/avatars/templates/avatars/settings_form.html b/djblets/avatars/templates/avatars/settings_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..ccedf3bf2565e0c920775053b581e198de78c334
--- /dev/null
+++ b/djblets/avatars/templates/avatars/settings_form.html
@@ -0,0 +1,41 @@
+{% load djblets_forms djblets_utils i18n %}
+
+{% block pre_fields %}{% endblock %}
+
+{{form.non_field_errors}}
+
+{% for field in form %}
+{%  if field.is_hidden %}
+{{field}}
+{%  endif %}
+{% endfor %}
+
+<div class="fields-row">
+ <div class="field">
+  {% label_tag form.avatar_service_id %}
+  {{form.avatar_service_id}}
+ </div>
+</div>
+
+{% for service_id, form in forms.items %}
+<div class="avatar-service-fields" data-avatar-service-id="{{service_id}}">
+ {{form.render|safe}}
+</div>
+{% endfor %}
+
+<script>
+$().ready(function() {
+    Djblets.Avatars.SettingsFormView.ready.then(function() {
+{% for service_id, form in forms.items %}
+        Djblets.Avatars.SettingsFormView.addConfigForm('{{service_id}}', {{form.js_view_class}});
+{% endfor %}
+        Djblets.Avatars.SettingsFormView.instance.renderForms();
+    });
+});
+</script>
+
+{% block post_fields %}{% endblock %}
+
+{% if form.save_label %}
+<input type="submit" class="btn" value="{{form.save_label}}" />
+{% endif %}
diff --git a/djblets/static/djblets/css/avatars.less b/djblets/static/djblets/css/avatars.less
new file mode 100644
index 0000000000000000000000000000000000000000..dda0c9767a12673254bb93f81b622808eb24fffb
--- /dev/null
+++ b/djblets/static/djblets/css/avatars.less
@@ -0,0 +1,36 @@
+.avatar-missing::before {
+  background-color: white;
+  border: 1px solid black;
+  border-radius: 50%;
+  content: '?';
+  display: block;
+  font-family: 'monospace';
+  font-size: 128px;
+  height: 128px;
+  width: 128px;
+  text-align: center;
+}
+
+.avatar-preview {
+  width: 130px;
+
+  .caption {
+    text-align: center;
+  }
+
+  img {
+    height: 128px;
+    width: 128px;
+    border: 1px solid black;
+    border-radius: 50%;
+  }
+}
+
+.avatar-service-configuration {
+  display: none;
+
+}
+
+.avatar-service-fields {
+  display: none;
+}
diff --git a/djblets/static/djblets/js/avatars/base.js b/djblets/static/djblets/js/avatars/base.js
new file mode 100644
index 0000000000000000000000000000000000000000..24e39598b898dd38b21cb49c3c3c325cb0d0e350
--- /dev/null
+++ b/djblets/static/djblets/js/avatars/base.js
@@ -0,0 +1 @@
+Djblets.Avatars = {};
diff --git a/djblets/static/djblets/js/avatars/models/avatarSettingsModel.es6.js b/djblets/static/djblets/js/avatars/models/avatarSettingsModel.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..83cf8d7588c87121b5271b712ef5bcd020686d82
--- /dev/null
+++ b/djblets/static/djblets/js/avatars/models/avatarSettingsModel.es6.js
@@ -0,0 +1,24 @@
+/**
+ * Settings for the avatar configuration form.
+ *
+ * Model attributes:
+ *     configuration (object):
+ *         A mapping of each service ID (`string``) to its configuration object
+ *         (``object``).
+ *
+ *     serviceID (string):
+ *         The currently selected service ID.
+ *
+ *     services (object):
+ *         A mapping of each service ID (``string``) to its properties
+ *         (``object``), such as  whether or not is is configurable.
+ */
+Djblets.Avatars.Settings = Backbone.Model.extend({
+    defaults() {
+        return {
+            configuration: {},
+            serviceID: null,
+            services: {}
+        };
+    }
+});
diff --git a/djblets/static/djblets/js/avatars/views/avatarServiceSettingsFormView.es6.js b/djblets/static/djblets/js/avatars/views/avatarServiceSettingsFormView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..cbc97ee1fea274a348ec7036f1cc03e351eaa68e
--- /dev/null
+++ b/djblets/static/djblets/js/avatars/views/avatarServiceSettingsFormView.es6.js
@@ -0,0 +1,18 @@
+/**
+ * A base class for avatar service settings forms.
+ *
+ * Subclasses should override this to provide additional behaviour for
+ * previews, etc.
+ */
+Djblets.Avatars.ServiceSettingsFormView = Backbone.View.extend({
+    /**
+     * Validate the form.
+     *
+     * Returns:
+     *     boolean:
+     *     Whether or not the form is valid.
+     */
+    validate() {
+        return true;
+    }
+});
diff --git a/djblets/static/djblets/js/avatars/views/avatarSettingsFormView.es6.js b/djblets/static/djblets/js/avatars/views/avatarSettingsFormView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..21084c86cf1734d38fad1cb640dc60a362feeb02
--- /dev/null
+++ b/djblets/static/djblets/js/avatars/views/avatarSettingsFormView.es6.js
@@ -0,0 +1,145 @@
+{
+
+
+const [readyPromise, resolve] = Promise.withResolver();
+
+
+/**
+ * A form for managing the settings of avatar services.
+ *
+ * This form lets you select the avatar service you wish to use, as well as
+ * configure the settings for that avatar service.
+ */
+Djblets.Avatars.SettingsFormView = Backbone.View.extend({
+    events: {
+        'change select[name="avatar_service_id"]': '_onServiceChanged',
+        'submit': '_onSubmit'
+    },
+
+    /**
+     * Initialize the form.
+     */
+    initialize() {
+        console.assert(Djblets.Avatars.SettingsFormView.instance === null);
+        Djblets.Avatars.SettingsFormView.instance = this;
+        this._configForms = new Map();
+
+        this._$config = this.$('.avatar-service-configuration');
+
+        const services = this.model.get('services');
+        this.listenTo(this.model, 'change:serviceID',
+                      () => this._showHideForms());
+
+        /*
+         * The promise continuations will only be executed once the stack is
+         * unwound.
+         */
+        resolve();
+    },
+
+    /**
+     * Validate the current form upon submission.
+     *
+     * Args:
+     *     e (Event):
+     *         The form submission event.
+     */
+    _onSubmit(e) {
+        const serviceID = this.model.get('serviceID');
+        const currentForm = this._configForms.get(serviceID);
+
+        if (currentForm && !currentForm.validate()) {
+            e.preventDefault();
+        }
+    },
+
+    /**
+     * Render the child forms.
+     *
+     * This will show the for the currently selected service if it has one.
+     *
+     * Returns:
+     *     Djblets.Avatars.SettingsFormView:
+     *     This view (for chaining).
+     */
+    renderForms() {
+        for (const form of this._configForms.values()) {
+            form.render();
+        }
+
+        const serviceID = this.model.get('serviceID');
+        this._showHideForms(true);
+
+        return this;
+    },
+
+    /**
+     * Show or hide the configuration form.
+     */
+    _showHideForms() {
+        const services = this.model.get('services');
+        const serviceID = this.model.get('serviceID');
+        const currentForm = this._configForms.get(serviceID);
+        const previousID = this.model.previous('serviceID');
+        const previousForm = previousID
+            ? this._configForms.get(previousID)
+            : undefined;
+
+        if (previousForm && currentForm) {
+            previousForm.$el.hide();
+            currentForm.$el.show();
+        } else if (previousForm) {
+            previousForm.$el.hide();
+            this._$config.hide();
+        } else if (currentForm) {
+            currentForm.$el.show();
+            this._$config.show();
+        }
+
+    },
+
+    /**
+     * Handle the service being changed.
+     *
+     * Args:
+     *     e (Event):
+     *         The change event.
+     */
+    _onServiceChanged(e) {
+        const $target = $(e.target);
+        this.model.set('serviceID', $target.val());
+    }
+}, {
+    /**
+     * The form instance.
+     */
+    instance: null,
+
+    /**
+     * Add a configuration form to the instance.
+     *
+     * Args:
+     *     serviceID (string):
+     *         The unique ID for the avatar service.
+     *
+     *     formClass (constructor):
+     *         The view to use for the form.
+     */
+    addConfigForm(serviceID, formClass) {
+        Djblets.Avatars.SettingsFormView.instance._configForms.set(
+            serviceID,
+            new formClass({
+                el: $(`[data-avatar-service-id="${serviceID}"]`),
+                model: Djblets.Avatars.SettingsFormView.instance.model
+            }));
+    },
+
+    /**
+     * A promise that is resolved when the avatar services form has been
+     * initialized.
+     */
+    ready: readyPromise
+});
+
+
+}
diff --git a/djblets/static/djblets/js/avatars/views/fileUploadSettingsFormView.es6.js b/djblets/static/djblets/js/avatars/views/fileUploadSettingsFormView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..c30ff6326b7e09f99d38ee5851ec923f1cc8e886
--- /dev/null
+++ b/djblets/static/djblets/js/avatars/views/fileUploadSettingsFormView.es6.js
@@ -0,0 +1,82 @@
+{
+
+
+const allowedMimeTypes = [
+    'image/png', 'image/jpeg', 'image/gif'
+];
+
+
+/**
+ * A file upload avatar settings form.
+ *
+ * This form provides a preview of the uploaded avatar.
+ */
+Djblets.Avatars.FileUploadSettingsFormView = Djblets.Avatars.ServiceSettingsFormView.extend({
+    events: {
+        'change #id_avatar_upload': '_onFileChanged'
+    },
+
+
+    /**
+     * Validate the form.
+     *
+     * If a file is selected, ensure it is has the correct MIME type.
+     */
+    validate() {
+        const file = this.$('#id_avatar_upload')[0].files[0];
+
+        if (!file) {
+            alert(gettext('You must choose a file.'));
+            return false;
+        }
+
+        if (!allowedMimeTypes.some(el => el === file.type)) {
+            alert(gettext('Invalid file format'));
+            return false;
+        }
+    },
+
+    /**
+     * Render the form.
+     *
+     * Returns:
+     *     Djblets.Avatars.FileUploadSettingsFormView:
+     *     This view (for chaining).
+     */
+    render() {
+        this._$preview = this.$('.avatar-preview');
+
+        return this;
+    },
+
+    /**
+     * Handle to the selected file being changed.
+     *
+     * This will update the preview image.
+     *
+     * Args:
+     *     e (Event):
+     *         The change event.
+     */
+    _onFileChanged(e) {
+        const file = e.target.files[0];
+
+        if (file) {
+            const reader = new FileReader();
+            reader.addEventListener('load', () => {
+                this._$preview
+                    .children()
+                    .eq(0)
+                    .replaceWith(
+                        $('<img />')
+                            .attr('src', reader.result)
+                    );
+            });
+
+            reader.readAsDataURL(file);
+        }
+    }
+});
+
+
+}
diff --git a/djblets/static/djblets/js/utils/promise.es6.js b/djblets/static/djblets/js/utils/promise.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..3b5a71dcfd7c658cf47d37f509c8643300781bd5
--- /dev/null
+++ b/djblets/static/djblets/js/utils/promise.es6.js
@@ -0,0 +1,15 @@
+/**
+ * Return a new promise with its resolver.
+ *
+ * Returns:
+ *     array:
+ *     An array of the promise and its resolver.
+ */
+Promise.withResolver = function withResolver() {
+    let resolver;
+    const promise = new Promise((resolve, reject) => {
+        resolver = resolve;
+    });
+
+    return [promise, resolver];
+};
diff --git a/djblets/staticbundles.py b/djblets/staticbundles.py
index 3afa1dbbecb72f50c51954bf8562bb4a22366fb8..56a56983f11ab676d61ed4b443d8b13652a40242 100644
--- a/djblets/staticbundles.py
+++ b/djblets/staticbundles.py
@@ -1,4 +1,14 @@
 PIPELINE_JAVASCRIPT = {
+    'djblets-avatars-config': {
+        'source_filenames': (
+            'djblets/js/avatars/base.js',
+            'djblets/js/avatars/models/avatarSettingsModel.es6.js',
+            'djblets/js/avatars/views/avatarServiceSettingsFormView.es6.js',
+            'djblets/js/avatars/views/avatarSettingsFormView.es6.js',
+            'djblets/js/avatars/views/fileUploadSettingsFormView.es6.js',
+        ),
+        'output_filename': 'djblets/js/avatars-config.min.js',
+    },
     'djblets-config-forms': {
         'source_filenames': (
             'djblets/js/configForms/base.js',
@@ -58,10 +68,22 @@ PIPELINE_JAVASCRIPT = {
         ),
         'output_filename': 'djblets/js/tests.min.js',
     },
+    'djblets-utils': {
+        'source_filenames': (
+            'djblets/js/utils/promise.es6.js',
+        ),
+        'output_filename': 'djblets/js/utils.min.js',
+    },
 }
 
 
 PIPELINE_STYLESHEETS = {
+    'djblets-avatars-config': {
+        'source_filenames': (
+            'djblets/css/avatars.less',
+        ),
+        'output_filename': 'djblets/css/avatars-config.min.css',
+    },
     'djblets-admin': {
         'source_filenames': (
             'djblets/css/admin.less',
