diff --git a/djblets/configforms/forms.py b/djblets/configforms/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..0b71d9030f8750474ed43480f7b11e77c0ad4d28
--- /dev/null
+++ b/djblets/configforms/forms.py
@@ -0,0 +1,103 @@
+from __future__ import unicode_literals
+
+from django import forms
+from django.template.context import RequestContext
+from django.template.loader import render_to_string
+from django.utils import six
+from django.utils.translation import ugettext_lazy as _
+
+
+class ConfigPageForm(forms.Form):
+    """Base class for a form on a ConfigPage.
+
+    ConfigPageForms belong to ConfigPages, and will be displayed when
+    navigating to that ConfigPage.
+
+    A simple form presents fields that can be filled out and posted. More
+    advanced forms can supply their own template or even their own
+    JavaScript models and views.
+    """
+    form_id = None
+    form_title = None
+
+    save_label = _('Save')
+
+    template_name = 'configforms/config_page_form.html'
+
+    css_bundle_names = []
+    js_bundle_names = []
+
+    js_model_class = None
+    js_view_class = None
+
+    form_target = forms.CharField(
+        required=False,
+        widget=forms.HiddenInput)
+
+    def __init__(self, page, request, user, *args, **kwargs):
+        super(ConfigPageForm, self).__init__(*args, **kwargs)
+        self.page = page
+        self.request = request
+        self.user = user
+        self.profile = user.get_profile()
+
+        self.fields['form_target'].initial = self.form_id
+        self.load()
+
+    def set_initial(self, field_values):
+        """Sets the initial fields for the form based on provided data.
+
+        This can be used during load() to fill in the fields based on
+        data from the database or another source.
+        """
+        for field, value in six.iteritems(field_values):
+            self.fields[field].initial = value
+
+    def is_visible(self):
+        """Returns whether the form should be visible.
+
+        This can be overridden to hide forms based on certain criteria.
+        """
+        return True
+
+    def get_js_model_data(self):
+        """Returns data to pass to the JavaScript Model during instantiation.
+
+        If js_model_class is provided, the data returned from this function
+        will be provided to the model when constructued.
+        """
+        return {}
+
+    def get_js_view_data(self):
+        """Returns data to pass to the JavaScript View during instantiation.
+
+        If js_view_class is provided, the data returned from this function
+        will be provided to the view when constructued.
+        """
+        return {}
+
+    def render(self):
+        """Renders the form."""
+        return render_to_string(
+            self.template_name,
+            RequestContext(self.request, {
+                'form': self,
+                'page': self.page,
+            }))
+
+    def load(self):
+        """Loads data for the form.
+
+        By default, this does nothing. Subclasses can override this to
+        load data into the fields based on data from the database or
+        from another source.
+        """
+        pass
+
+    def save(self):
+        """Saves the form data.
+
+        Subclasses can override this to save data from the fields into
+        the database.
+        """
+        raise NotImplementedError
diff --git a/djblets/configforms/pages.py b/djblets/configforms/pages.py
new file mode 100644
index 0000000000000000000000000000000000000000..40855c98042433cb105b90472d5252cca23e3906
--- /dev/null
+++ b/djblets/configforms/pages.py
@@ -0,0 +1,47 @@
+from __future__ import unicode_literals
+
+from django.template.context import RequestContext
+from django.template.loader import render_to_string
+
+
+class ConfigPage(object):
+    """Base class for a page of configuration forms.
+
+    Each ConfigPage is represented in the main page by an entry in the
+    navigation sidebar. When the user has navigated to that page, any
+    forms owned by the ConfigPage will be displayed.
+    """
+    page_id = None
+    page_title = None
+    form_classes = None
+    template_name = 'configforms/config_page.html'
+
+    def __init__(self, request, user):
+        self.request = request
+        self.forms = [
+            form_cls(self, request, user)
+            for form_cls in self.form_classes
+        ]
+
+    def is_visible(self):
+        """Returns whether the page should be visible.
+
+        Visible pages are shown in the sidebar and can be navigated to.
+
+        By default, a page is visible if at least one of its forms are
+        also visible.
+        """
+        for form in self.forms:
+            if form.is_visible():
+                return True
+
+        return False
+
+    def render(self):
+        """Renders the page as HTML."""
+        return render_to_string(
+            self.template_name,
+            RequestContext(self.request, {
+                'page': self,
+                'forms': self.forms,
+            }))
diff --git a/djblets/configforms/templates/configforms/config.html b/djblets/configforms/templates/configforms/config.html
new file mode 100644
index 0000000000000000000000000000000000000000..4c089e6a077ce0c80a2c07b167ebe50e924793df
--- /dev/null
+++ b/djblets/configforms/templates/configforms/config.html
@@ -0,0 +1,95 @@
+{% extends "base.html" %}
+{% load compressed djblets_deco djblets_js i18n %}
+
+{% block title %}{{page_title}}{% endblock %}
+
+{% block content %}
+<div class="config-forms-container"{% if pages_id %} id="{{pages_id}}"{% endif %}>
+{%  box "nav config-forms-side-nav" %}
+ <h1 class="title">{{nav_title}}</h1>
+ <div class="main">
+  <ul>
+{%   for page in pages %}
+{%    if page.is_visible %}
+   <li><a href="#{{page.page_id}}">{{page.page_title}}</a></li>
+{%    endif %}
+{%   endfor %}
+  </ul>
+ </div>
+{%  endbox %}
+
+<div class="config-forms-page-content">
+{%  if messages %}
+ <ul id="messages">
+{%   for message in messages %}
+  <li{% if message.tags %} class="{{message.tags}}"{% endif %}>{{message}}</li>
+{%   endfor %}
+ </ul>
+{%  endif %}
+
+{%  for page in pages %}
+{%   if page.is_visible %}
+ <div class="page" id="page_{{page.page_id}}">
+{{page.render|safe}}
+ </div>
+{%   endif %}
+{%  endfor %}
+{% endblock content %}
+
+{% block css %}
+{{block.super}}
+{%  compressed_css "djblets-config-forms" %}
+{%  for bundle_name in css_bundle_names %}
+{%   compressed_css bundle_name %}
+{%  endfor %}
+{%  for form in forms %}
+{%   for css_bundle_name in form.css_bundle_names %}
+{%    compressed_css css_bundle_name %}
+{%   endfor %}
+{%  endfor %}
+{% endblock css %}
+
+{% block scripts-post %}
+{{block.super}}
+{%  compressed_js "djblets-config-forms" %}
+{%  for bundle_name in js_bundle_names %}
+{%   compressed_js bundle_name %}
+{%  endfor %}
+{%  for form in forms %}
+{%   for js_bundle_name in form.js_bundle_names %}
+{%    compressed_js js_bundle_name %}
+{%   endfor %}
+{%  endfor %}
+
+<script>
+    $(document).ready(function() {
+        var pageView = new {{js_view_class}}({
+                el: $('#{{pages_id}}')
+            }),
+            formView;
+
+{%  spaceless %}
+{%   for form in forms %}
+{%    if form.js_view_class %}
+        formView = new {{form.js_view_class}}({
+{%     for key, value in form.get_js_view_data.items %}
+            {{key|json_dumps}}: {{value|json_dumps}},
+{%     endfor %}
+{%     if form.js_model_class %}
+            model: new {{form.js_model_class}}({
+{%      for key, value in form.get_js_model_data.items %}
+            {{key|json_dumps}}: {{value|json_dumps}}{% if not forloop.last %},{% endif %}
+{%      endfor %}
+            },
+{%     endif %}
+            el: $('#form_{{form.form_id}}')
+        });
+        formView.render()
+{%    endif %}
+{%   endfor %}
+{%  endspaceless %}
+
+        pageView.render();
+    });
+</script>
+{% endblock scripts-post %}
diff --git a/djblets/configforms/templates/configforms/config_page.html b/djblets/configforms/templates/configforms/config_page.html
new file mode 100644
index 0000000000000000000000000000000000000000..1660dd02cec26d2e000191d33e5407f1c97a3de6
--- /dev/null
+++ b/djblets/configforms/templates/configforms/config_page.html
@@ -0,0 +1,13 @@
+{% load djblets_deco %}
+
+{% for form in page.forms %}
+{%  box %}
+<h1 class="title">{{form.form_title}}</h1>
+<div class="main">
+ <form method="post" action=".#{{page.page_id}}" id="form_{{form.form_id}}">
+  {% csrf_token %}
+  {{form.render|safe}}
+ </form>
+</div>
+{%  endbox %}
+{% endfor %}
diff --git a/djblets/configforms/templates/configforms/config_page_form.html b/djblets/configforms/templates/configforms/config_page_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..aa48e0625c5754e3d6396ab03f43e7834439fe78
--- /dev/null
+++ b/djblets/configforms/templates/configforms/config_page_form.html
@@ -0,0 +1,30 @@
+{% load djblets_forms %}
+
+{% block pre_fields %}{% endblock %}
+
+{% for field in form %}
+{%  if field.is_hidden %}
+{{field}}
+{%  else %}
+{%   with field|form_field_has_label_first as label_first %}
+<div class="fields-row{% if not label_first %} checkbox-row{% endif %}">
+ <div class="field">
+{%    if label_first %}
+  {% label_tag field %}
+  {{field}}
+{%    else %}
+  {{field}}
+  {% label_tag field %}
+{%    endif %}
+  {{field.errors}}
+ </div>
+</div>
+{%   endwith %}
+{%  endif %}
+{% endfor %}
+
+{% block post_fields %}{% endblock %}
+
+{% if form.save_label %}
+<input type="submit" clas="btn" value="{{form.save_label}}" />
+{% endif %}
diff --git a/djblets/configforms/views.py b/djblets/configforms/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..06046e8c5079388d83fb44ef61f76e309fcaaa4c
--- /dev/null
+++ b/djblets/configforms/views.py
@@ -0,0 +1,87 @@
+from __future__ import unicode_literals
+
+from django.http import Http404, HttpResponseBadRequest, HttpResponseRedirect
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import csrf_protect
+from django.views.generic.base import TemplateView
+
+
+class ConfigPagesView(TemplateView):
+    """Base view for a set of configuration pages.
+
+    This will render the page for managing a set of configuration sub-pages.
+    Subclasses are expected to provide ``title`` and ``page_classes``.
+
+    To dynamically compute pages, implement a ``page_classes`` method and
+    decorate it with @property.
+    """
+    title = None
+    nav_title = None
+    pages_id = 'config_pages'
+    template_name = 'configforms/config.html'
+    page_classes = []
+
+    css_bundle_names = []
+    js_bundle_names = []
+
+    js_view_class = 'Djblets.Config.PagesView'
+
+    http_method_names = ['get', 'post']
+
+    @method_decorator(csrf_protect)
+    def dispatch(self, request, *args, **kwargs):
+        self.pages = [
+            page_cls(request, request.user)
+            for page_cls in self.page_classes
+        ]
+
+        forms = {}
+
+        # Store a mapping of form IDs to form instances, and check for
+        # duplicates.
+        for page in self.pages:
+            for form in page.forms:
+                # This should already be handled during form registration.
+                assert form.form_id not in forms, \
+                    'Duplicate form ID %s (on page %s)' % (
+                        form.form_id, page.page_id)
+
+                forms[form.form_id] = form
+
+        if request.method == 'POST':
+            form_id = request.POST.get('form_target')
+
+            if form_id is None:
+                return HttpResponseBadRequest()
+
+            if form_id not in forms:
+                return Http404
+
+            # Replace the form in the list with a new instantiation containing
+            # the form data. If we fail to save, this will ensure the error is
+            # shown on the page.
+            old_form = forms[form_id]
+            form_cls = old_form.__class__
+            form = form_cls(old_form.page, request, request.user, request.POST)
+            forms[form_id] = form
+
+            if form.is_valid():
+                form.save()
+
+                return HttpResponseRedirect(request.path)
+
+        self.forms = forms.values()
+
+        return super(ConfigPagesView, self).dispatch(request, *args, **kwargs)
+
+    def get_context_data(self):
+        return {
+            'page_title': self.title,
+            'nav_title': self.nav_title or self.title,
+            'pages_id': self.pages_id,
+            'pages': self.pages,
+            'css_bundle_names': self.css_bundle_names,
+            'js_bundle_names': self.js_bundle_names,
+            'js_view_class': self.js_view_class,
+            'forms': self.forms,
+        }
diff --git a/djblets/settings.py b/djblets/settings.py
index f15149e37021c33180b051067d8e282704648786..22c5bf0da1d9589b86d699371d8d28b50adf61c0 100644
--- a/djblets/settings.py
+++ b/djblets/settings.py
@@ -36,6 +36,17 @@ STATICFILES_FINDERS = (
 STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
 
 PIPELINE_JS = {
+    'djblets-config-forms': {
+        'source_filenames': (
+            'djblets/js/configForms/base.js',
+            'djblets/js/configForms/models/listItemModel.js',
+            'djblets/js/configForms/models/listModel.js',
+            'djblets/js/configForms/views/listItemView.js',
+            'djblets/js/configForms/views/listView.js',
+            'djblets/js/configForms/views/pagesView.js',
+        ),
+        'output_filename': 'djblets/js/config-forms.min.js',
+    },
     'djblets-datagrid': {
         'source_filenames': ('djblets/js/datagrid.js',),
         'output_filename': 'djblets/js/datagrid.min.js',
@@ -80,6 +91,12 @@ PIPELINE_CSS = {
         ),
         'output_filename': 'djblets/css/admin.min.css',
     },
+    'djblets-config-forms': {
+        'source_filenames': (
+            'djblets/css/config-forms.less',
+        ),
+        'output_filename': 'rb/css/config-forms.min.css',
+    },
     'djblets-datagrid': {
         'source_filenames': (
             'djblets/css/datagrid.less',
diff --git a/djblets/static/djblets/css/config-forms.less b/djblets/static/djblets/css/config-forms.less
new file mode 100644
index 0000000000000000000000000000000000000000..b04aa23b8bcbfeeeeebd6583111614214f800f24
--- /dev/null
+++ b/djblets/static/djblets/css/config-forms.less
@@ -0,0 +1,466 @@
+@config-forms-box-bg: #EEEEEE;
+@config-forms-box-padding: 1em;
+@config-forms-box-border-radius: 6px;
+
+
+.border-radius (...) {
+  -moz-border-radius: @arguments;
+  -webkit-border-radius: @arguments;
+  border-radius: @arguments;
+}
+
+
+.ellipsize() {
+  /* Ellipsize the text contents. */
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+
+.config-forms-container {
+  @side-nav-width: 17em;
+  @forms-width: 60em;
+  @container-padding: 1em;
+  @gap: 2em;
+
+  font-size: 9pt;
+  padding: @container-padding;
+  width: (@side-nav-width + @forms-width + @gap + (2 * @container-padding));
+  margin: 0 auto;
+
+  .box-container {
+    margin: 0;
+    width: auto;
+  }
+
+  .box {
+    background: @config-forms-box-bg;
+    margin: 0 0 2em 0;
+
+    .box-inner {
+      background: transparent;
+    }
+
+    .box-recessed {
+      margin: 0 -@config-forms-box-padding;
+    }
+
+    .main {
+      padding: @config-forms-box-padding;
+    }
+  }
+
+  .config-forms-side-nav {
+    float: left;
+    margin-top: 2;
+    width: @side-nav-width;
+
+    .main {
+      padding: 0;
+    }
+
+    ul {
+      list-style: none;
+      margin: 0;
+      padding: 0;
+
+      li {
+        border-bottom: 1px #D9D9D9 solid;
+
+        &:first-child {
+          border-top: 1px #D9D9D9 solid;
+        }
+
+        &:last-child {
+          &.active, &:hover {
+            .border-radius(0 0 @config-forms-box-border-radius
+                           @config-forms-box-border-radius);
+          }
+        }
+
+        &:hover {
+          background-color: darken(@config-forms-box-bg, 5%);
+        }
+
+        &.active {
+          background: #FEFEFE;
+
+          &:hover {
+            background: #FEFEFE;
+          }
+
+          a {
+            cursor: default;
+          }
+        }
+
+        a {
+          color: black;
+          display: block;
+          font-weight: normal;
+          outline: none;
+          padding: 0.5em 1em;
+          text-decoration: none;
+        }
+      }
+    }
+  }
+
+  .config-forms-page-content {
+    float: left;
+    margin-top: 0;
+    margin-left: @gap;
+    width: @forms-width;
+
+    form {
+      margin: 0 auto;
+
+      input[type=text],
+      input[type=password],
+      input[type=email] {
+        width: 30em;
+        max-width: 30em;
+        font-size: inherit;
+        padding: 0.3em 0.6em;
+      }
+
+      label, p {
+        color: #666666;
+      }
+
+      label {
+        display: block;
+        margin: 0.25em 0;
+      }
+
+      p {
+        font-size: inherit;
+        margin: 1em 0;
+      }
+
+      select {
+        font-size: inherit;
+      }
+
+      .buttons {
+        input[type=submit] {
+          text-align: center;
+          margin: 0 auto;
+        }
+      }
+
+      .fields-row {
+        margin: 0 0 1em 0;
+        position: relative;
+
+        &:after {
+          clear: both;
+          display: table;
+          content: "";
+          line-height: 0;
+        }
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        &.checkbox-row {
+          .field {
+            width: 100%;
+
+            input, label {
+              display: inline;
+              width: auto;
+            }
+
+            label {
+              margin-left: 0;
+            }
+
+            ul {
+              margin: 0 0 0 1em;
+            }
+          }
+        }
+
+        .field {
+          display: block;
+          float: left;
+          margin-right: 1em;
+
+          &:last-child {
+            margin-right: 0;
+          }
+
+          input {
+            width: 21em;
+
+            &[type=checkbox] {
+              width: auto;
+            }
+          }
+
+          ul {
+            list-style: none;
+            margin: 1em 0;
+            padding: 0;
+
+            &:last-child {
+              margin-bottom: 0;
+            }
+
+            li {
+              margin: 0.5em 0;
+
+              &:last-child {
+                label {
+                  margin-bottom: 0;
+                }
+              }
+
+              label {
+                color: inherit;
+              }
+            }
+          }
+
+          .hint {
+            display: block;
+            margin: 0.5em 0 0 0.5em;
+            font-size: 90%;
+          }
+        }
+      }
+
+      .search {
+        padding: 0;
+        margin: 0 0 @config-forms-box-padding 0;
+
+        input {
+          margin-left: 0.5em;
+          width: (@forms-width - 7em);
+          max-width: 100%;
+        }
+      }
+    }
+
+    #messages {
+      /*
+       * NOTE: Review Board defines these for the admin UI, but we can't
+       *       re-use them here, so redefine them.
+       */
+      @img_base: '../../admin/img';
+      @msg-bg-color: #FFC;
+      @msg-border-color: darken(@msg-bg-color, 50%);
+
+      margin: 0 0 1em 0;
+      padding: 0;
+
+      li {
+        background: @msg-bg-color url('@{img_base}/icon_success.gif') 5px .3em no-repeat;
+        border: 1px solid @msg-border-color;
+        color: #666;
+        display: block;
+        font-size: 12px;
+        margin: 0 0 3px 0;
+        padding: 4px 5px 4px 25px;
+
+        &.error {
+          background-image: url('@{img_base}/icon_error.gif');
+        }
+
+        &.warning {
+          background-image: url('@{img_base}/icon_alert.gif');
+        }
+      }
+    }
+
+    .page {
+      display: none;
+
+      &.active {
+        display: block;
+      }
+    }
+
+    .box {
+      .main {
+        h3 {
+          margin: 0;
+          padding: 0.5em 0.3em 0.3em 0.5em;
+          font-size: 10pt;
+
+          &.first {
+            margin-top: 0;
+          }
+        }
+
+        p {
+          &:first-child {
+            margin-top: 0;
+          }
+
+          &:last-child {
+            margin-bottom: 0;
+          }
+        }
+      }
+    }
+
+    .config-forms-list {
+      background: #FFFFFF;
+      padding: 0;
+      margin-bottom: 0;
+      border-top: 1px #AAAAAA solid;
+    }
+
+    table.config-forms-list {
+      border-collapse: collapse;
+      border-top: 0;
+      width: 100%;
+
+      thead th {
+        background: #F0F0F3;
+        border-bottom: 1px #AAAAAA solid;
+        border-bottom: 1px #AAAAAA solid;
+        border-right: 1px #CCCCCC solid;
+        text-align: left;
+        padding: 0.3em 0.3em 0.3em 1.5em;
+
+        &:last-child {
+          border-right: 0;
+        }
+      }
+    }
+
+    .config-forms-list-item,
+    table.config-forms-list td {
+      border-bottom: 1px #E9E9E9 solid;
+      padding: 0.3em 0.3em 0.3em 1.5em;
+      list-style: none;
+      line-height: 32px;
+      vertical-align: middle;
+      .ellipsize();
+    }
+
+    .config-forms-list-item {
+      &:hover {
+        background: #FAFAFA;
+      }
+
+      a {
+        font-weight: normal;
+        text-decoration: none;
+      }
+
+      .rb-icon {
+        vertical-align: top;
+        margin-right: 0.5em;
+      }
+
+      .btn {
+        float: right;
+        font-size: 12px;
+        height: 28px;
+        line-height: 28px;
+        margin: 1px 1px 1px 5px;
+        padding: 0 1em;
+        text-align: center;
+
+        .rb-icon {
+          vertical-align: middle;
+          margin-right: 0;
+        }
+      }
+
+      ul {
+        background: #FFFFF0;
+        border: 1px #BBBBB0 solid;
+        list-style: none;
+        margin: 0;
+        padding: 0 0.5em;
+        white-space: nowrap;
+
+        label {
+          display: inline;
+          margin-left: 0.2em;
+          vertical-align: middle;
+        }
+      }
+
+      &.disabled label {
+        color: #C0C0C0;
+      }
+    }
+
+    .config-forms-list-item-spinner {
+      width: 16px;
+      height: 28px;
+      display: inline-block;
+      margin: 1px;
+      float: right;
+      background-image: url("../../rb/images/spinner.gif");
+      background-position-y: 6px;
+      background-repeat: no-repeat;
+      visibility: hidden;
+    }
+
+    .config-forms-list-action-edit.btn {
+      padding-left: 0.5em;
+      padding-right: 0.5em;
+    }
+
+    .config-forms-list-add {
+      border: 0;
+      vertical-align: bottom;
+    }
+
+    .config-forms-list-actions {
+      margin: 1em 0 0 0em;
+
+      input {
+        margin-right: 0.5em;
+        width: 230px;
+      }
+    }
+
+    .config-forms-list-header-actions {
+      margin: 0 0 1em 0;
+    }
+
+    .config-forms-list-full {
+      margin-top: 1em;
+
+      p {
+        margin: 0;
+      }
+    }
+
+    .config-forms-subsection {
+      margin-left: 1em;
+      margin-top: 1em;
+    }
+  }
+}
+
+
+/*
+ * My Account page
+ */
+
+.config-forms-list-action-join,
+.config-forms-list-action-leave {
+  min-width: 3em;
+}
+
+.config-group-name {
+  display: inline-block;
+  min-width: 15em;
+  padding-right: 2em;
+}
+
+.config-group-display-name {
+  color: #A0A0A0;
+  font-size: 90%;
+}
diff --git a/djblets/static/djblets/js/configForms/base.js b/djblets/static/djblets/js/configForms/base.js
new file mode 100644
index 0000000000000000000000000000000000000000..42d15d54ea655c82bc339eafee9d73b92f738df7
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/base.js
@@ -0,0 +1,3 @@
+window.Djblets = window.Djblets || {};
+
+Djblets.Config = {};
diff --git a/djblets/static/djblets/js/configForms/models/listItemModel.js b/djblets/static/djblets/js/configForms/models/listItemModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..ffa6c75cc3f36c7b8260cedd2fc4d1ae4f953a46
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/models/listItemModel.js
@@ -0,0 +1,60 @@
+/*
+ * Base class for an item in a list for config forms.
+ *
+ * ListItems provide text representing the item, optionally linked. They
+ * can also provide zero or more actions that can be invoked on the item
+ * by the user.
+ *
+ * Actions can have 'id', 'type', 'label', 'enabled', 'propName', 'iconName',
+ * 'danger', and 'children' attributes.
+ *
+ * 'id' is a unique ID for the action. It is used when registering action
+ * handlers, and will also be appended to the class name for the action.
+ *
+ * 'type' is optional, but if set to 'checkbox', a checkbox will be presented.
+ *
+ * 'propName' is used only for checkbox actions. It specifies the attribute
+ * on the model that will be set to reflect the checkbox.
+ *
+ * 'iconName' specifies the name of the icon to display next to the action.
+ * This is the "iconname" part of "rb-icon-iconname".
+ *
+ * 'danger' indicates that the action will cause some permanent,
+ * undoable change. This is used only for buttons.
+ *
+ * 'children' indicates the action is a menu action that has sub-actions.
+ *
+ * As a convenience, if showRemove is true, this will provide a default
+ * action for removing the item.
+ */
+Djblets.Config.ListItem = Backbone.Model.extend({
+    defaults: {
+        text: null,
+        editURL: null,
+        showRemove: false,
+        canRemove: true,
+        loading: false,
+        removeLabel: gettext('Remove')
+    },
+
+    /*
+     * Initializes the item.
+     *
+     * If showRemove is true, this will populate a default Remove action
+     * for removing the item.
+     */
+    initialize: function(options) {
+        options = options || {};
+
+        this.actions = options.actions || [];
+
+        if (this.get('showRemove')) {
+            this.actions.push({
+                id: 'delete',
+                label: this.get('removeLabel'),
+                danger: true,
+                enabled: this.get('canRemove')
+            });
+        }
+    }
+});
diff --git a/djblets/static/djblets/js/configForms/models/listModel.js b/djblets/static/djblets/js/configForms/models/listModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..a4622cfeaac6bf735ee4f14cf9535c2a6a8b780c
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/models/listModel.js
@@ -0,0 +1,4 @@
+/*
+ * Base class for a list used for config forms.
+ */
+Djblets.Config.List = Backbone.Model.extend();
diff --git a/djblets/static/djblets/js/configForms/models/tests/listItemModelTests.js b/djblets/static/djblets/js/configForms/models/tests/listItemModelTests.js
new file mode 100644
index 0000000000000000000000000000000000000000..9053b9fe35665db635d638bb1601d3d6ec9d8fb8
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/models/tests/listItemModelTests.js
@@ -0,0 +1,22 @@
+describe('configForms/models/ListItem', function() {
+    describe('Default actions', function() {
+        describe('showRemove', function() {
+            it('true', function() {
+                var listItem = new Djblets.Config.ListItem({
+                    showRemove: true
+                });
+
+                expect(listItem.actions.length).toBe(1);
+                expect(listItem.actions[0].id).toBe('delete');
+            });
+
+            it('false', function() {
+                var listItem = new Djblets.Config.ListItem({
+                    showRemove: false
+                });
+
+                expect(listItem.actions.length).toBe(0);
+            });
+        });
+    });
+});
diff --git a/djblets/static/djblets/js/configForms/views/listItemView.js b/djblets/static/djblets/js/configForms/views/listItemView.js
new file mode 100644
index 0000000000000000000000000000000000000000..6a932e98d8278decee1210441c55f3c3ca17ddd4
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/views/listItemView.js
@@ -0,0 +1,172 @@
+/*
+ * Displays a list item for a config page.
+ *
+ * The list item will show information on the item and any actions that can
+ * be invoked.
+ *
+ * By default, this will show the text from the ListItem model, linking it
+ * if the model has an editURL attribute. This can be customized by subclasses
+ * by overriding `template`.
+ */
+Djblets.Config.ListItemView = Backbone.View.extend({
+    tagName: 'li',
+    className: 'config-forms-list-item',
+
+    actionHandlers: {},
+
+    template: _.template([
+        '<% if (editURL) { %>',
+        ' <a href="<%- editURL %>"><%- text %></a>',
+        '<% } else { %>',
+        ' <%- text %>',
+        '<% } %>'
+    ].join('')),
+
+    /*
+     * Initializes the view.
+     */
+    initialize: function() {
+        this.model.on('actionsChanged', this.render, this);
+    },
+
+    /* Renders the view.
+     *
+     * This will be called every time the list of actions change for
+     * the item.
+     */
+    render: function() {
+        this.$el
+            .empty()
+            .append(this.template(this.model.attributes));
+        this.addActions(this.$el);
+
+        return this;
+    },
+
+    /*
+     * Removes the item.
+     *
+     * This will fade out the item, and then remove it from view.
+     */
+    remove: function() {
+        this.$el.fadeOut('normal',
+                         _.bind(Backbone.View.prototype.remove, this));
+    },
+
+    /*
+     * Adds all registered actions to the view.
+     */
+    addActions: function($parentEl) {
+        _.each(this.model.actions, function(action) {
+            var $action = this._buildActionEl(action)
+                    .appendTo($parentEl);
+
+            if (action.children) {
+                if (action.label) {
+                    $action.append(' &#9662;');
+                }
+
+                $action.click(_.bind(function() {
+                    /*
+                     * Show the dropdown after we let this event propagate.
+                     */
+                    _.defer(_.bind(this._showActionDropdown, this,
+                                   action, $action));
+                }, this));
+            }
+        }, this);
+    },
+
+    /*
+     * Shows a dropdown for a menu action.
+     */
+    _showActionDropdown: function(action, $action) {
+        var actionPos = $action.position(),
+            $pane = $('<ul/>')
+                .move(actionPos.left + $action.getExtents('m', 'l'),
+                      actionPos.top + $action.outerHeight(),
+                      'absolute')
+                .click(function(e) {
+                    /* Don't let a click on the dropdown close it. */
+                    e.stopPropagation();
+                });
+
+        _.each(action.children, function(childAction) {
+            $('<li/>')
+                .append(this._buildActionEl(childAction))
+                .appendTo($pane);
+        }, this);
+
+        $pane.appendTo($action.parent());
+
+        /* Any click outside this dropdown should close it. */
+        $(document).one('click', function() {
+            $pane.remove();
+        });
+    },
+
+    /*
+     * Builds the element for an action.
+     *
+     * If the action's type is "checkbox", a checkbox will be shown. Otherwise,
+     * the action will be shown as a button.
+     */
+    _buildActionEl: function(action) {
+        var actionHandlerName = (action.enabled !== false
+                                 ? this.actionHandlers[action.id]
+                                 : null),
+            checkboxID,
+            $action,
+            $result;
+
+        if (action.type === 'checkbox') {
+            checkboxID = _.uniqueId('action_check');
+            $action = $('<input/>')
+                .attr({
+                    type: "checkbox",
+                    id: checkboxID
+                });
+
+            $result = $('<span/>')
+                .append($action)
+                .append($('<label/>')
+                    .attr('for', checkboxID)
+                    .text(action.label));
+
+            if (action.propName) {
+                $action.bindProperty('checked', this.model, action.propName);
+            }
+
+            if (actionHandlerName) {
+                $action.change(_.bind(this[actionHandlerName], this));
+            }
+        } else {
+            $action = $result = $('<a class="btn"/>')
+                .text(action.label || '');
+
+            if (action.iconName) {
+                $action.append($('<span/>')
+                    .addClass('rb-icon rb-icon-' + action.iconName));
+            }
+
+            if (actionHandlerName) {
+                $action.click(_.bind(this[actionHandlerName], this));
+            }
+        }
+
+        if (action.id) {
+            $action.addClass('config-forms-list-action-' + action.id);
+        }
+
+        if (action.danger) {
+            $action.addClass('danger');
+        }
+
+        if (action.enabled === false) {
+            $action.attr('disabled', 'disabled');
+            $result.addClass('disabled');
+        }
+
+        return $result;
+    }
+});
diff --git a/djblets/static/djblets/js/configForms/views/listView.js b/djblets/static/djblets/js/configForms/views/listView.js
new file mode 100644
index 0000000000000000000000000000000000000000..999e4358436515159bab443d7fed508693b3978d
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/views/listView.js
@@ -0,0 +1,70 @@
+/*
+ * Displays a list of items.
+ *
+ * This will render each item in a list, and update that list when the
+ * items in the collection changes.
+ *
+ * It can also filter the displayed list of items.
+ *
+ * If loading the list through the API, this will display a loading indicator
+ * until the items have been loaded.
+ */
+Djblets.Config.ListView = Backbone.View.extend({
+    tagName: 'ul',
+    className: 'config-forms-list',
+
+    /*
+     * Initializes the view.
+     */
+    initialize: function(options) {
+        var collection = this.model.collection;
+
+        this.ItemView = options.ItemView || Djblets.Config.ListItemView;
+
+        this.listenTo(collection, 'add', this._addItem);
+        this.listenTo(collection, 'remove', this._removeItem);
+        this.listenTo(collection, 'reset', this.render);
+    },
+
+    /*
+     * Returns the body element.
+     *
+     * This can be overridden by subclasses if the list items should be
+     * rendered to a child element of this view.
+     */
+    getBody: function() {
+        return this.$el;
+    },
+
+    /*
+     * Renders the list of items.
+     *
+     * This will loop through all items and render each one.
+     */
+    render: function() {
+        this.$listBody = this.getBody().empty();
+        this.model.collection.each(this._addItem, this);
+
+        return this;
+    },
+
+    /*
+     * Creates a view for an item and adds it.
+     */
+    _addItem: function(item) {
+        var view = new this.ItemView({
+            model: item
+        });
+
+        this.$listBody.append(view.render().$el);
+    },
+
+    /*
+     * Handler for when an item is removed from the collection.
+     *
+     * Removes the element from the list.
+     */
+    _removeItem: function(item, collection, options) {
+        this.$listBody.children().eq(options.index).remove();
+    }
+});
diff --git a/djblets/static/djblets/js/configForms/views/pagesView.js b/djblets/static/djblets/js/configForms/views/pagesView.js
new file mode 100644
index 0000000000000000000000000000000000000000..922ba39f2e61e6acdde8a64e4af172090e135928
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/views/pagesView.js
@@ -0,0 +1,74 @@
+/*
+ * Manages a collection of configuration pages.
+ *
+ * The primary job of this view is to handle sub-page navigation.
+ * The actual page will contain several "pages" that are shown or hidden
+ * depending on what the user has clicked on the sidebar.
+ */
+Djblets.Config.PagesView = Backbone.View.extend({
+    /*
+     * Initializes the view.
+     *
+     * This will set up the router for handling page navigation.
+     */
+    initialize: function() {
+        this.router = new Backbone.Router({
+            routes: {
+                ':pageID': 'page'
+            }
+        });
+        this.listenTo(this.router, 'route:page', this._onPageChanged);
+
+        this._$activeNav = null;
+        this._$activePage = null;
+        this._preserveMessages = true;
+    },
+
+    /*
+     * Renders the view.
+     *
+     * This will set the default page to be shown, and instruct Backbone
+     * to begin handling the routing.
+     */
+    render: function() {
+        this._$pageNavs = this.$('.config-forms-side-nav li');
+        this._$pages = this.$('.config-forms-page-content > .page');
+
+        this._$activeNav = this._$pageNavs.eq(0).addClass('active');
+        this._$activePage = this._$pages.eq(0).addClass('active');
+
+        Backbone.history.start({
+            root: window.location
+        });
+
+        return this;
+    },
+
+    /*
+     * Handler for when the page changed.
+     *
+     * The sidebar will be updated to reflect the current active page,
+     * and the page will be shown.
+     *
+     * If navigating pages manually, any messages provided by the backend
+     * form will be removed. We don't do this the first time there's a
+     * navigation, as this will be called when first rendering the view.
+     */
+    _onPageChanged: function(pageID) {
+        this._$activeNav.removeClass('active');
+        this._$activePage.removeClass('active');
+
+        this._$activeNav =
+            this._$pageNavs.filter(':has(a[href=#' + pageID + '])')
+                .addClass('active');
+
+        this._$activePage = $('#page_' + pageID)
+            .addClass('active');
+
+        if (!this._preserveMessages) {
+            $('#messages').remove();
+        }
+
+        this._preserveMessages = false;
+    }
+});
diff --git a/djblets/static/djblets/js/configForms/views/tests/listItemViewTests.js b/djblets/static/djblets/js/configForms/views/tests/listItemViewTests.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ee07131db8d8899bc9c048580cb8b704b9d44a3
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/views/tests/listItemViewTests.js
@@ -0,0 +1,232 @@
+describe('configForms/views/ListItemView', function() {
+    describe('Rendering', function() {
+        describe('Item display', function() {
+            it('With editURL', function() {
+                var item = new Djblets.Config.ListItem({
+                        editURL: 'http://example.com/',
+                        text: 'Label'
+                    }),
+                    itemView = new Djblets.Config.ListItemView({
+                        model: item
+                    });
+
+                itemView.render();
+                expect(itemView.$el.html().strip()).toBe(
+                    '<a href="http://example.com/">Label</a>');
+            });
+
+            it('Without editURL', function() {
+                var item = new Djblets.Config.ListItem({
+                        text: 'Label'
+                    }),
+                    itemView = new Djblets.Config.ListItemView({
+                        model: item
+                    });
+
+                itemView.render();
+                expect(itemView.$el.html().strip()).toBe('Label');
+            });
+        });
+
+        describe('Actions', function() {
+            describe('Buttons', function() {
+                it('Simple', function() {
+                    var item = new Djblets.Config.ListItem({
+                            text: 'Label',
+                            actions: [
+                                {
+                                    id: 'mybutton',
+                                    label: 'Button'
+                                }
+                            ]
+                        }),
+                        itemView = new Djblets.Config.ListItemView({
+                            model: item
+                        }),
+                        $button;
+
+                    itemView.render();
+
+                    $button = itemView.$('.btn');
+                    expect($button.length).toBe(1);
+                    expect($button.text()).toBe('Button');
+                    expect($button.hasClass(
+                        'config-forms-list-action-mybutton')).toBe(true);
+                    expect($button.hasClass('rb-icon')).toBe(false);
+                    expect($button.hasClass('danger')).toBe(false);
+                });
+
+                it('Danger', function() {
+                    var item = new Djblets.Config.ListItem({
+                            text: 'Label',
+                            actions: [
+                                {
+                                    id: 'mybutton',
+                                    label: 'Button',
+                                    danger: true
+                                }
+                            ]
+                        }),
+                        itemView = new Djblets.Config.ListItemView({
+                            model: item
+                        }),
+                        $button;
+
+                    itemView.render();
+
+                    $button = itemView.$('.btn');
+                    expect($button.length).toBe(1);
+                    expect($button.text()).toBe('Button');
+                    expect($button.hasClass(
+                        'config-forms-list-action-mybutton')).toBe(true);
+                    expect($button.hasClass('rb-icon')).toBe(false);
+                    expect($button.hasClass('danger')).toBe(true);
+                });
+
+                it('Icon names', function() {
+                    var item = new Djblets.Config.ListItem({
+                            text: 'Label',
+                            actions: [
+                                {
+                                    id: 'mybutton',
+                                    label: 'Button',
+                                    danger: false,
+                                    iconName: 'foo'
+                                }
+                            ]
+                        }),
+                        itemView = new Djblets.Config.ListItemView({
+                            model: item
+                        }),
+                        $button;
+
+                    itemView.render();
+
+                    $button = itemView.$('.btn');
+                    expect($button.length).toBe(1);
+                    expect($button.text()).toBe('Button');
+                    expect($button.hasClass(
+                        'config-forms-list-action-mybutton')).toBe(true);
+                    expect($button.hasClass('rb-icon')).toBe(true);
+                    expect($button.hasClass('rb-icon-foo')).toBe(true);
+                    expect($button.hasClass('danger')).toBe(false);
+                });
+            });
+
+            it('Checkboxes', function() {
+                var item = new Djblets.Config.ListItem({
+                        text: 'Label',
+                        checkboxAttr: false,
+                        actions: [
+                            {
+                                id: 'mycheckbox',
+                                type: 'checkbox',
+                                label: 'Checkbox',
+                                propName: 'checkboxAttr'
+                            }
+                        ]
+                    }),
+                    itemView = new Djblets.Config.ListItemView({
+                        model: item
+                    });
+
+                itemView.render();
+
+                expect(itemView.$('input[type=checkbox]').length).toBe(1);
+                expect(itemView.$('label').length).toBe(1);
+            });
+
+            it('Menus', function() {
+                var item = new Djblets.Config.ListItem({
+                        text: 'Label',
+                        actions: [
+                            {
+                                id: 'mymenu',
+                                label: 'Menu',
+                                children: [
+                                    {
+                                        id: 'mymenuitem',
+                                        label: 'My menu item'
+                                    }
+                                ]
+                            }
+                        ]
+                    }),
+                    itemView = new Djblets.Config.ListItemView({
+                        model: item
+                    }),
+                    $button;
+
+                itemView.render();
+
+                $button = itemView.$('.btn');
+                expect($button.length).toBe(1);
+                expect($button.text()).toBe('Menu ▾');
+            });
+        });
+    });
+
+    describe('Action handlers', function() {
+        it('Buttons', function() {
+            var item = new Djblets.Config.ListItem({
+                    text: 'Label',
+                    actions: [
+                        {
+                            id: 'mybutton',
+                            label: 'Button'
+                        }
+                    ]
+                }),
+                itemView = new Djblets.Config.ListItemView({
+                    model: item
+                }),
+                $button;
+
+            itemView.actionHandlers.mybutton = '_onMyButtonClick';
+            itemView._onMyButtonClick = function() {};
+            spyOn(itemView, '_onMyButtonClick');
+
+            itemView.render();
+
+            $button = itemView.$('.btn');
+            expect($button.length).toBe(1);
+            $button.click();
+
+            expect(itemView._onMyButtonClick).toHaveBeenCalled();
+        });
+
+        it('Checkboxes', function() {
+            var item = new Djblets.Config.ListItem({
+                    text: 'Label',
+                    checkboxAttr: false,
+                    actions: [
+                        {
+                            id: 'mycheckbox',
+                            type: 'checkbox',
+                            label: 'Checkbox',
+                            propName: 'checkboxAttr'
+                        }
+                    ]
+                }),
+                itemView = new Djblets.Config.ListItemView({
+                    model: item
+                }),
+                $checkbox;
+
+            itemView.actionHandlers.mybutton = '_onMyButtonClick';
+            itemView._onMyButtonClick = function() {};
+            spyOn(itemView, '_onMyButtonClick');
+
+            itemView.render();
+
+            $checkbox = itemView.$('input[type=checkbox]');
+            expect($checkbox.length).toBe(1);
+            expect($checkbox.prop('checked')).toBe(false);
+            $checkbox
+                .prop('checked', true)
+                .triggerHandler('change');
+
+            expect(item.get('checkboxAttr')).toBe(true);
+        });
+    });
+});
diff --git a/djblets/static/djblets/js/configForms/views/tests/listViewTests.js b/djblets/static/djblets/js/configForms/views/tests/listViewTests.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c99f21a09a576b08d08807a702a347ee1c0fe5c
--- /dev/null
+++ b/djblets/static/djblets/js/configForms/views/tests/listViewTests.js
@@ -0,0 +1,73 @@
+describe('configForms/views/ListView', function() {
+    describe('Manages items', function() {
+        var collection,
+            list,
+            listView;
+
+        beforeEach(function() {
+            collection = new Backbone.Collection(
+                [
+                    {text: 'Item 1'},
+                    {text: 'Item 2'},
+                    {text: 'Item 3'}
+                ], {
+                    model: Djblets.Config.ListItem
+                });
+
+            list = new Djblets.Config.List({}, {
+                collection: collection
+            });
+
+            listView = new Djblets.Config.ListView({
+                model: list
+            });
+            listView.render();
+        });
+
+        it('On render', function() {
+            var $items;
+
+            $items = listView.$('li');
+            expect($items.length).toBe(3);
+            expect($items.eq(0).text().strip()).toBe('Item 1');
+            expect($items.eq(1).text().strip()).toBe('Item 2');
+            expect($items.eq(2).text().strip()).toBe('Item 3');
+        });
+
+        it('On add', function() {
+            var $items;
+
+            collection.add({
+                text: 'Item 4'
+            });
+
+            $items = listView.$('li');
+            expect($items.length).toBe(4);
+            expect($items.eq(3).text().strip()).toBe('Item 4');
+        });
+
+        it('On remove', function() {
+            var $items;
+
+            collection.remove(collection.at(0));
+
+            $items = listView.$('li');
+            expect($items.length).toBe(2);
+            expect($items.eq(0).text().strip()).toBe('Item 2');
+        });
+
+        it('On reset', function() {
+            var $items;
+
+            collection.reset([
+                {text: 'Foo'},
+                {text: 'Bar'}
+            ]);
+
+            $items = listView.$('li');
+            expect($items.length).toBe(2);
+            expect($items.eq(0).text().strip()).toBe('Foo');
+            expect($items.eq(1).text().strip()).toBe('Bar');
+        });
+    });
+});
