diff --git a/.gitignore b/.gitignore
index bd7887f030ea28378e31da406d88f105ded4cc90..b97d25ec5fe024f6be25f9a852656d78112c5f2d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@
 # Files
 .coverage
 .noseids
+package-requirements.txt
 reviewboard/settings_local.py
 reviewboard.log
 settings_local.py
@@ -42,6 +43,7 @@ pickle
 search-index/
 temp
 .idea
+.local-packages
 .npm-workspaces
 bak
 reviewboard/scmtools/testdata/hg_repo/.hg/cache/branch2-served
diff --git a/MANIFEST.in b/MANIFEST.in
index f45659049fda7b69d300d9f299c1c6cf90ad5db8..daa737927f64d4c72e492cb0e6ca4e179cc3c9ac 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,6 +6,9 @@ prune docs/*/_build
 prune docs/manual/data/htdocs/media/uploaded/
 prune docs/manual/extending/coderef/python
 
+# Make sure any logs are out.
+prune */logs
+
 # Specifically clear out content from static/ and add what we know we want.
 prune reviewboard/htdocs/static
 graft reviewboard/htdocs/static/admin
@@ -17,13 +20,42 @@ prune reviewboard/htdocs/media
 include reviewboard/htdocs/media/ext/.gitignore
 
 include AUTHORS
+include CONTRIBUTING
 include COPYING
 include INSTALL
+include Makefile
 include NEWS
 include README.md
-include *-requirements.txt
+include .gitignore
+include .reviewboardrc
+
+global-exclude .*.sw[op] *.py[co] __pycache__ .DS_Store .noseids
+global-exclude .mypy_cache
+prune .mypy_cache
+prune **/.mypy_cache
 
 exclude docs/manual/docs.db
 exclude settings_local.py
 
-global-exclude .*.sw[op] *.py[co] __pycache__ .DS_Store .noseids
+# Python builds
+include build-backend.py
+include *-requirements.txt
+
+# Unit testing
+include conftest.py
+graft */testdata
+graft tests
+prune tests/static
+
+# Media builds
+include .storybook/*.css
+include .storybook/*.ts*
+include .browserslistrc
+include .eslintrc.yaml
+include babel.config.json
+include package-lock.json
+include package.json
+include reviewboard/package.json
+include rollup.config.mjs
+include tsconfig.json
+include vite.config.mjs
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..4d26dc7e1a96894d4f2b47b74d56d424b7b8cdf4
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,10 @@
+PYTHON=python3
+PIP=${PYTHON} -m pip
+
+
+develop:
+	${PIP} install -e .
+	${PIP} install -r dev-requirements.txt
+
+
+.PHONY: develop
diff --git a/build-backend.py b/build-backend.py
new file mode 100644
index 0000000000000000000000000000000000000000..21f76a4c1fa70ba01b4e6ecd5a91a6657c48e80b
--- /dev/null
+++ b/build-backend.py
@@ -0,0 +1,416 @@
+"""Python build backend for Review Board.
+
+This is a specialization of the setuptools build backend, making the following
+custom changes:
+
+1. Including all of Review Board's dependencies as build-time dependencies.
+
+   We execute code within Review Board as part of the build process, meaning
+   that we need most (if not all) of the dependencies at build time. To play
+   it safe, we simply include them all.
+
+2. Introspecting reviewboard/dependencies.py for package metadata.
+
+   Setuptools allows for dynamic dependencies, but only when including it
+   via a requirements.txt-formatted file. We temporarily generate one of those
+   for Setuptools when building the metadata.
+
+   (Note that we have no other place to inject this, as pyproject.toml's
+   dependencies, even if empty/not specified, override anything we could set
+   anywhere else.)
+
+3. Building media and i18n files.
+
+   When building wheels or source distributions, we run our media-building
+   scripts, ensuring they get included in the resulting files.
+
+Version Added:
+    7.1
+
+
+Editable Installs
+-----------------
+
+By default, this build backend will pull down the latest versions of Djblets
+and any other build related dependencies in order to build the package. This
+is the case whether you're building a package or setting up an editable
+install (:command:`pip install -e .`).
+
+If you need to set up an editable install against in-development builds of
+Djblets, Django-Pipeline, or other packages, you will need to set up symlinks
+to your local packages in :file:`.local-packages/`. For example:
+
+.. code-block:: console
+
+   $ cd .local-packages
+   $ ln -s ~/src/djblets djblets
+
+This must match the package name as listed in the dependencies (but is
+case-insensitive).
+
+
+Static Media
+------------
+
+As part of packaging or setting up editable installs, this build backend will
+regenerate static media files. As part of this process, it will set up symlinks
+to the applicable source trees in :file:`.npm-workspaces/`.
+
+If you are building a package out of your tree, any existing symlinks in
+this directory may be overridden, pointing to packages in the temporary build
+environment. Editable installs should exhibit this issue.
+
+When you're building packages, always do so from a clean clone of the tree.
+"""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from typing import TYPE_CHECKING
+
+from setuptools import build_meta as _build_meta
+
+from reviewboard.dependencies import (build_dependency_list,
+                                      package_dependencies,
+                                      package_only_dependencies)
+
+if TYPE_CHECKING:
+    from setuptools.build_meta import _ConfigSettings
+
+
+LOCAL_PACKAGES_DIR = '.local-packages'
+
+
+def get_requires_for_build_editable(
+    config_settings: (_ConfigSettings | None) = None,
+) -> list[str]:
+    """Return build-time requirements for editable builds.
+
+    This will return the standard Review Board dependencies, along with any
+    pyproject-specified build-time dependencies.
+
+    If any local dependencies are found in the :file:`.local-packages`
+    directory at the root of the tree, they will be used instead of
+    downloading from PyPI.
+
+    Args:
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        list of str:
+        The list of build-time dependencies.
+    """
+    _write_dependencies()
+
+    local_paths: dict[str, str] = {}
+
+    if os.path.exists(LOCAL_PACKAGES_DIR):
+        for name in os.listdir(LOCAL_PACKAGES_DIR):
+            local_paths[name.lower()] = os.path.abspath(
+                os.readlink(os.path.join(LOCAL_PACKAGES_DIR, name)))
+
+    dependencies = build_dependency_list(
+        package_dependencies,
+        local_packages=local_paths)
+
+    return [
+        *dependencies,
+        *_build_meta.get_requires_for_build_wheel(config_settings)
+    ]
+
+
+def get_requires_for_build_sdist(
+    config_settings: (_ConfigSettings | None) = None,
+) -> list[str]:
+    """Return build-time requirements for source distributions.
+
+    This will return the standard Review Board dependencies, along with any
+    pyproject-specified build-time dependencies.
+
+    Args:
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        list of str:
+        The list of build-time dependencies.
+    """
+    _write_dependencies()
+
+    return [
+        *build_dependency_list(package_dependencies),
+        *_build_meta.get_requires_for_build_wheel(config_settings)
+    ]
+
+
+def get_requires_for_build_wheel(
+    config_settings: (_ConfigSettings | None) = None,
+) -> list[str]:
+    """Return build-time requirements for wheel distributions.
+
+    This will return the standard Review Board dependencies, along with any
+    pyproject-specified build-time dependencies.
+
+    Args:
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        list of str:
+        The list of build-time dependencies.
+    """
+    _write_dependencies()
+
+    return [
+        *build_dependency_list(package_dependencies),
+        *_build_meta.get_requires_for_build_wheel(config_settings)
+    ]
+
+
+def prepare_metadata_for_build_editable(
+    metadata_directory: str,
+    config_settings: (_ConfigSettings | None) = None,
+) -> str:
+    """Prepare metadata for an editable build.
+
+    This will write out Review Board's dependencies to a temporary file so
+    that pyproject.toml can locate it.
+
+    Args:
+        metadata_directory (str):
+            The target directory for metadata.
+
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        str:
+        The basename for the generated ``.dist-info`` directory.
+    """
+    _write_dependencies()
+
+    return _build_meta.prepare_metadata_for_build_editable(
+        metadata_directory,
+        config_settings)
+
+
+def prepare_metadata_for_build_wheel(
+    metadata_directory: str,
+    config_settings: (_ConfigSettings | None) = None,
+) -> str:
+    """Prepare metadata for a wheel distribution.
+
+    This will write out Review Board's dependencies to a temporary file so
+    that pyproject.toml can locate it.
+
+    Args:
+        metadata_directory (str):
+            The target directory for metadata.
+
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        str:
+        The basename for the generated ``.dist-info`` directory.
+    """
+    _rebuild_npm_workspaces()
+    _write_dependencies()
+
+    return _build_meta.prepare_metadata_for_build_wheel(
+        metadata_directory,
+        config_settings)
+
+
+def build_editable(
+    wheel_directory: str,
+    config_settings: (_ConfigSettings | None) = None,
+    metadata_directory: (str | None) = None,
+) -> str:
+    """Build an editable environment.
+
+    This will build the static media and i18n files needed by Djblets, and
+    then let Setuptools build the editable environment.
+
+    Args:
+        wheel_directory (str):
+            The directory where the editable wheel will be placed.
+
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+        metadata_directory (str, optional):
+            The directory where metadata would be stored.
+
+    Returns:
+        str:
+        The basename for the generated source distribution file.
+    """
+    _rebuild_npm_workspaces()
+    _build_data_files(collect_static=False)
+
+    return _build_meta.build_editable(
+        wheel_directory,
+        {
+            'editable_mode': 'compat',
+            **(config_settings or {})
+        },
+        metadata_directory)
+
+
+def build_sdist(
+    sdist_directory: str,
+    config_settings: (_ConfigSettings | None) = None,
+) -> str:
+    """Build a source distribution.
+
+    This will build the static media and i18n files needed by Review Board,
+    and then let Setuptools build the distribution.
+
+    Args:
+        sdist_directory (str):
+            The directory where the source distribution will be placed.
+
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        str:
+        The basename for the generated source distribution file.
+    """
+    _rebuild_npm_workspaces()
+    _build_data_files()
+
+    return _build_meta.build_sdist(sdist_directory,
+                                   config_settings)
+
+
+def build_wheel(
+    wheel_directory: str,
+    config_settings: (_ConfigSettings | None) = None,
+    metadata_directory: (str | None) = None,
+) -> str:
+    """Build a wheel.
+
+    This will build the static media and i18n files needed by Review Board,
+    and then let Setuptools build the distribution.
+
+    Args:
+        wheel_directory (str):
+            The directory where the wheel will be placed.
+
+        config_settings (dict, optional):
+            Configuration settings to pass to Setuptools.
+
+    Returns:
+        str:
+        The basename for the generated wheel file.
+    """
+    _rebuild_npm_workspaces()
+    _build_data_files()
+
+    return _build_meta.build_wheel(wheel_directory,
+                                   config_settings,
+                                   metadata_directory)
+
+
+def _rebuild_npm_workspaces() -> None:
+    """Rebuild the links under .npm-workspaces for static media building.
+
+    This will look up the module paths for Djblets and Review Board and
+    link them so that JavaScript and CSS build infrastructure can import
+    files correctly.
+    """
+    root_dir = os.path.abspath(os.path.join(__file__, '..'))
+    npm_workspaces_dir = os.path.join(root_dir, '.npm-workspaces')
+
+    if not os.path.exists(npm_workspaces_dir):
+        os.mkdir(npm_workspaces_dir, 0o755)
+
+    from importlib import import_module
+
+    for mod_name in ('djblets', 'reviewboard'):
+        try:
+            mod = import_module(mod_name)
+        except ImportError:
+            raise RuntimeError(
+                f'Missing the dependency for {mod_name}, which is needed to '
+                f'compile static media.'
+            )
+
+        mod_path = mod.__file__
+        assert mod_path
+
+        symlink_path = os.path.join(npm_workspaces_dir, mod_name)
+
+        # Unlink this unconditionally, so we don't have to worry about things
+        # like an existing dangling symlink that shows as non-existent.
+        try:
+            os.unlink(symlink_path)
+        except FileNotFoundError:
+            pass
+
+        os.symlink(os.path.dirname(mod_path), symlink_path)
+
+
+def _write_dependencies() -> None:
+    """Write all dependencies to a file.
+
+    This will write to :file:`package-requirements.txt`, so that
+    :file:`pyproject.toml` can reference it.
+    """
+    dependencies = build_dependency_list({
+        **package_dependencies,
+        **package_only_dependencies,
+    })
+
+    with open('package-requirements.txt', 'w', encoding='utf-8') as fp:
+        for dependency in dependencies:
+            fp.write(f'{dependency}\n')
+
+
+def _build_data_files(
+    *,
+    collect_static: bool = True,
+) -> None:
+    """Build static media and i18n data files.
+
+    Args:
+        collect_static (bool, optional):
+            Whether to run a ``collectstatic`` operation to build media
+            files.
+
+            If ``False``, support for building static media will still be
+            installed.
+
+    Raises:
+        RuntimeError:
+            There was an error building the media or i18n files.
+    """
+    media_env: dict[str, str] = os.environ.copy()
+
+    if not collect_static:
+        media_env['RUN_COLLECT_STATIC'] = '0'
+
+    # Build the static media.
+    retcode = subprocess.call(
+        [
+            sys.executable,
+            os.path.join('contrib', 'internal', 'build-media.py'),
+        ],
+        env=media_env)
+
+    if retcode != 0:
+        raise RuntimeError('Failed to build media files')
+
+    # Build the i18n files.
+    retcode = subprocess.call([
+        sys.executable,
+        os.path.join('contrib', 'internal', 'build-i18n.py'),
+    ])
+
+    if retcode != 0:
+        raise RuntimeError('Failed to build i18n files')
diff --git a/contrib/internal/build-i18n.py b/contrib/internal/build-i18n.py
index ec3c3f6724227c6cda79d81592817bbe76b9a346..8b113d1e6635fe61c3419f8bf17bdded540a46a3 100644
--- a/contrib/internal/build-i18n.py
+++ b/contrib/internal/build-i18n.py
@@ -11,15 +11,6 @@
 # Script config directory
 sys.path.insert(0, os.path.join(scripts_dir, 'conf'))
 
-from reviewboard.dependencies import django_version
-
-import __main__
-__main__.__requires__ = ['Django%s' % django_version]
-import pkg_resources
-
-from django_evolution.compat.patches import apply_patches
-apply_patches()
-
 import django
 from django.core.management import call_command
 
@@ -29,9 +20,7 @@
 if __name__ == '__main__':
     os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'reviewboard.settings')
 
-    if hasattr(django, 'setup'):
-        # Django >= 1.7
-        django.setup()
+    django.setup()
 
     os.chdir(os.path.dirname(reviewboard.__file__))
     sys.exit(call_command('compilemessages', verbosity=2))
diff --git a/contrib/internal/build-media.py b/contrib/internal/build-media.py
index 2cdb4eb6cafb8f4c75a5fa3eab7d23ebe4e3f35e..9092ac2d8bf7b463a456b6d831ac253ded89a02b 100644
--- a/contrib/internal/build-media.py
+++ b/contrib/internal/build-media.py
@@ -1,37 +1,60 @@
 #!/usr/bin/env python
 
 import os
+import shutil
+import subprocess
 import sys
 
 scripts_dir = os.path.abspath(os.path.dirname(__file__))
 
 # Source root directory
-sys.path.insert(0, os.path.abspath(os.path.join(scripts_dir, '..', '..')))
+root_dir = os.path.abspath(os.path.join(scripts_dir, '..', '..'))
+sys.path.insert(0, root_dir)
 
 # Script config directory
 sys.path.insert(0, os.path.join(scripts_dir, 'conf'))
 
-from reviewboard.dependencies import django_version
-
-import __main__
-__main__.__requires__ = ['Django' + django_version]
-import pkg_resources
-
-from django_evolution.compat.patches import apply_patches
-apply_patches()
-
 import django
 from django.core.management import call_command
 
 
 if __name__ == '__main__':
+    os.chdir(root_dir)
+
+    # Verify that we have npm.
+    npm_command = 'npm'
+
+    try:
+        subprocess.check_call([npm_command, '--version'],
+                              stdout=subprocess.DEVNULL,
+                              stderr=subprocess.DEVNULL)
+    except subprocess.CalledProcessError:
+        raise RuntimeError(
+            f'Unable to locate {npm_command} in the path, which is needed to '
+            f'compile static media.'
+        )
+
+    # Install dependencies.
+    subprocess.call([npm_command, 'install'])
+
+    # Set up the Django environment.
     os.environ['FORCE_BUILD_MEDIA'] = '1'
-    os.environ.setdefault(str('DJANGO_SETTINGS_MODULE'),
-                          str('reviewboard.settings'))
+    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'reviewboard.settings')
+
+    django.setup()
+
+    # Check if we're actually building media. This internal flag is used to
+    # by the package build backend to better control setup vs. building of
+    # static media.
+    if os.environ.get('RUN_COLLECT_STATIC') != '0':
+        # Remove any stale htdocs files.
+        htdocs_static_dir = os.path.join(root_dir, 'reviewboard', 'htdocs',
+                                         'static')
 
-    if hasattr(django, 'setup'):
-        # Django >= 1.7
-        django.setup()
+        if os.path.exists(htdocs_static_dir):
+            shutil.rmtree(htdocs_static_dir)
 
-    # This will raise a CommandError or call sys.exit(1) on failure.
-    call_command('collectstatic', interactive=False, verbosity=2)
+        # Build the static media.
+        #
+        # This will raise a CommandError or call sys.exit(1) on failure.
+        call_command('collectstatic', interactive=False, verbosity=2)
diff --git a/contrib/internal/prepare-dev.py b/contrib/internal/prepare-dev.py
index 69d0ac8bbd60c21e0829c8644463c4e57b6f3ae0..12c7d1a66cf6bcd323329497e0159cd4d1ad167b 100644
--- a/contrib/internal/prepare-dev.py
+++ b/contrib/internal/prepare-dev.py
@@ -1,13 +1,22 @@
 #!/usr/bin/env python
 """Prepare a Review Board tree for development."""
 
+from __future__ import annotations
+
 import argparse
 import os
 import platform
+import re
 import stat
 import subprocess
 import sys
+from configparser import ConfigParser
+from importlib import import_module
 from random import choice
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from reviewboard.cmdline.rbsite import Site
 
 
 FAQ_URL = \
@@ -165,7 +174,9 @@ def install_git_hooks():
     console.print('The post-checkout hook has been installed.')
 
 
-def install_media(site):
+def install_media(
+    site: Site,
+) -> None:
     """Install static media.
 
     Args:
@@ -223,24 +234,148 @@ def install_media(site):
             i += 1
 
 
-def install_dependencies(options):
-    """Install dependencies via setup.py and pip (and therefore npm).
+def setup_local_packages() -> None:
+    """Set up links to any standard local development packages.
+
+    If there's a local editable build of Djblets available, it will be used
+    for the editable install for Review Board.
+
+    Version Added:
+        7.1
+    """
+    packages: dict[str, str] = {}
+
+    for package_name, module_name in (('Djblets', 'djblets'),
+                                      ('django-pipeline', 'pipeline')):
+        try:
+            mod = import_module(module_name)
+        except ImportError:
+            # This is not installed, so we can skip it.
+            continue
+
+        tree_path = os.path.abspath(os.path.join(mod.__path__[0], '..'))
+
+        if (os.path.basename(tree_path) != 'site-packages' and
+            (os.path.exists(os.path.join(tree_path, 'setup.py')) or
+             os.path.exists(os.path.join(tree_path, 'pyproject.toml')))):
+            # This looks like a local editable install.
+            packages[package_name] = tree_path
+
+    if packages:
+        local_packages_dir = '.local-packages'
+
+        os.makedirs(local_packages_dir, 0o755, exist_ok=True)
+
+        for package_name, tree_path in packages.items():
+            local_package_link = os.path.join(local_packages_dir,
+                                              package_name)
+
+            if os.path.lexists(local_package_link):
+                os.unlink(local_package_link)
+
+            os.symlink(tree_path, local_package_link,
+                       target_is_directory=True)
+
+
+def install_dependencies(
+    options: argparse.Namespace,
+) -> None:
+    """Install Review Board and all dependencies in editable mode.
+
+    Version Changed:
+        7.1:
+        This now uses :command:`pip install -e`, and supports multiple
+        Python versions using virtualenv-multiver's :command:`pydo`.
 
     Args:
         options (argparse.Namespace):
             The parsed command line arguments.
     """
+    python_exes: list[str] = []
+
     # We can't use console.print() or console.header() here, since we're
     # running before we know we even have Django installed.
     print('Bootstrapping: Installing the Review Board package and '
           'dependencies..')
 
-    cmdline = [sys.executable, 'setup.py', '-q', 'develop']
-
     if options.all_pyvers:
-        cmdline.append('--all-pyvers')
+        # We need to determine which Python versions to use.
+        #
+        # Back when we were setup.py-based, we just had a hard-coded list of
+        # versions to try, and we'd invoke a flag that would loop through the
+        # versions in setup.py. Now, that looping logic is here, and it's
+        # smarter.
+        #
+        # We grab the list of supported versions from pyproject.toml, and we
+        # AND that with the list of versions supported by the virtualenv
+        # (which requires a virtualenv-multiver-based approach).
+        virtualenv_path = os.environ.get('VIRTUAL_ENV')
+
+        if virtualenv_path:
+            pydorc_path = os.path.join(virtualenv_path, '.pydorc')
+        else:
+            pydorc_path = None
+
+        if not pydorc_path or not os.path.exists(pydorc_path):
+            sys.stderr.write(
+                '--all-pyvers can only be used when run in a '
+                'virtualenv built using virtualenv-multiver.\n')
+            sys.exit(1)
+
+        # Read .pydorc.
+        config_parser = ConfigParser()
+        config_parser.read(pydorc_path)
+        pydorc_pyvers = set(config_parser.get('pydo', 'pyvers').split(' '))
+
+        # Read pyproject.toml.
+        pyproject_pyvers: set[str] = set()
+
+        with open('pyproject.toml', 'r', encoding='utf-8') as fp:
+            PYVER_RE = re.compile(
+                r"'Programming Language :: Python :: (\d\.\d+)'"
+            )
+
+            for line in fp:
+                m = PYVER_RE.search(line)
+
+                if m:
+                    pyproject_pyvers.add(m.group(1))
+
+        # Generate a list of all the Python executables we'll run.
+        python_exes = [
+            f'python{pyver}'
+            for pyver in sorted(
+                pyproject_pyvers & pydorc_pyvers,
+                key=lambda ver: [
+                    int(part)
+                    for part in ver.split('.')
+                ])
+        ]
+    else:
+        python_exes = [sys.executable]
 
-    os.system(subprocess.list2cmdline(cmdline))
+    for python_exe in python_exes:
+        os.system(subprocess.list2cmdline([
+            python_exe, '-m', 'pip', 'install', '-e', '.',
+        ]))
+
+    # `pip install -e` will try to manage .npm-workspaces, but it doesn't
+    # know the packages in the containing virtualenv. If we didn't build
+    # using a local editable copy of Djblets, then we're going to have a
+    # dangling symlink. We need to fix this up here.
+    try:
+        import djblets
+    except ImportError:
+        sys.stderr.write('Could not import djblets. Something must have '
+                         'gone wrong during environment setup.\n')
+        sys.exit(1)
+
+    djblets_link_path = os.path.join('.npm-workspaces', 'djblets')
+
+    if os.path.lexists(djblets_link_path):
+        os.unlink(djblets_link_path)
+
+    os.symlink(djblets.__path__[0], djblets_link_path)
 
 
 def create_superuser(site):
@@ -432,6 +567,8 @@ def main():
 
     options = parse_options(sys.argv[1:])
 
+    setup_local_packages()
+
     if options.install_deps:
         install_dependencies(options)
 
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..08ed387bb3c6ff30f5b572cc0213088100d7520e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,85 @@
+[build-system]
+requires = [
+    'setuptools>=74',
+]
+build-backend = 'build-backend'
+backend-path = ['.']
+
+[project]
+name = 'ReviewBoard'
+description = 'Extensible code review and document review built for Git, Perforce, Mercurial, and more'
+authors = [
+    {name = 'Beanbag, Inc.', email = 'questions@beanbaginc.com'},
+]
+license = { text = 'MIT' }
+readme = 'README.rst'
+dynamic = ['dependencies', 'version']
+requires-python = '>=3.9'
+
+classifiers = [
+    'Development Status :: 5 - Production/Stable',
+    'Environment :: Web Environment',
+    'Framework :: Django',
+    'Framework :: Django :: 4.2',
+    'Framework :: Review Board',
+    'Intended Audience :: Developers',
+    'License :: OSI Approved :: MIT License',
+    'Natural Language :: English',
+    'Operating System :: OS Independent',
+    'Programming Language :: Python',
+    'Programming Language :: Python :: 3',
+    'Programming Language :: Python :: 3.9',
+    'Programming Language :: Python :: 3.10',
+    'Programming Language :: Python :: 3.11',
+    'Programming Language :: Python :: 3.12',
+    'Programming Language :: Python :: 3.13',
+    'Programming Language :: Python :: 3.14',
+    'Topic :: Software Development',
+    'Topic :: Software Development :: Quality Assurance',
+]
+
+
+[project.urls]
+Homepage = 'https://www.reviewboard.org'
+Documentation = 'https://www.reviewboard.org/docs/'
+Repository = 'https://github.com/reviewboard/reviewboard'
+Support = 'https://www.reviewboard.org/support/'
+Hosting = 'https://rbcommons.com/'
+
+
+[project.scripts]
+rb-site = 'reviewboard.cmdline.rbsite:main'
+rbext = 'reviewboard.cmdline.rbext:main'
+rbssh = 'reviewboard.cmdline.rbssh:main'
+
+
+[project.optional-dependencies]
+elasticsearch1 = ['elasticsearch~=1.0']
+elasticsearch2 = ['elasticsearch~=2.0']
+elasticsearch5 = ['elasticsearch~=5.0']
+elasticsearch7 = ['elasticsearch~=7.0']
+extension-packaging = ['setuptools>=74']
+ldap = ['python-ldap>=3.3.1']
+mercurial = ['mercurial']
+mysql = ['mysqlclient>=1.4,<=2.1.999']
+p4 = ['p4python']
+postgres = ['psycopg2-binary']
+s3 = ['django-storages[s3]']
+saml = ['python3-saml']
+subvertpy = ['subvertpy']
+swift = ['django-storage-swift']
+
+
+[tool.setuptools.package-data]
+reviewboard = ['py.typed']
+
+
+[tool.setuptools.packages.find]
+where = ['.']
+include = ['reviewboard*']
+namespaces = false
+
+
+[tool.setuptools.dynamic]
+dependencies = { file = 'package-requirements.txt' }
+version = { attr = 'reviewboard.__version__' }
diff --git a/reviewboard/admin/urls.py b/reviewboard/admin/urls.py
index d4714563e1c14bfd39fd139aaba95e5dddb3f875..4028eaa33a8566d2e5f45ec431cdf116f8f3c66e 100644
--- a/reviewboard/admin/urls.py
+++ b/reviewboard/admin/urls.py
@@ -50,6 +50,8 @@
 
     path('integrations/', include('reviewboard.integrations.urls')),
 
+    path('licenses/', include('reviewboard.licensing.urls')),
+
     path('log/', include('djblets.log.urls')),
 
     path('security/', views.security, name='admin-security-checks'),
diff --git a/reviewboard/dependencies.py b/reviewboard/dependencies.py
index cfae55caaf3c662160b628b65bcd6703a7f10458..e56159eba27f9a6ed1b1d4ce6dca2838c58a0982 100644
--- a/reviewboard/dependencies.py
+++ b/reviewboard/dependencies.py
@@ -7,11 +7,23 @@
 you're going to make use of data from this file, code defensively.
 """
 
+from __future__ import annotations
+
 import sys
 import textwrap
-from typing import Dict
+from typing import TYPE_CHECKING
+
+try:
+    from djblets.dependencies import (
+        npm_dependencies as djblets_npm_dependencies,
+    )
+except ImportError:
+    # We're probably being called as part of the build backend process.
+    # Don't worry too much about this dependency.
+    djblets_npm_dependencies = []
 
-from djblets.dependencies import npm_dependencies as djblets_npm_dependencies
+if TYPE_CHECKING:
+    from collections.abc import Mapping, Sequence
 
 
 ###########################################################################
@@ -114,7 +126,7 @@
 
 
 #: Dependencies required for runtime or static media building.
-runtime_npm_dependencies: Dict[str, str] = {
+runtime_npm_dependencies: dict[str, str] = {
     '@babel/plugin-external-helpers': '^7.18.6',
     '@beanbag/jasmine-suites': '~2.0.0',
     '@prantlf/jsonlint': '^11.7.0',
@@ -137,7 +149,7 @@
 
 
 #: Node dependencies required to package/develop/test Djblets.
-npm_dependencies: Dict[str, str] = {}
+npm_dependencies: dict[str, str] = {}
 npm_dependencies.update(djblets_npm_dependencies)
 npm_dependencies.update(runtime_npm_dependencies)
 
@@ -149,26 +161,50 @@
 _dependency_warning_count = 0
 
 
-def build_dependency_list(deps, version_prefix=''):
+def build_dependency_list(
+    deps: Mapping[str, (str | Sequence[Mapping[str, str]])],
+    version_prefix: str = '',
+    *,
+    local_packages: Mapping[str, str] = {},
+) -> Sequence[str]:
     """Build a list of dependency specifiers from a dependency map.
 
     This can be used along with :py:data:`package_dependencies`,
     :py:data:`npm_dependencies`, or other dependency dictionaries to build a
-    list of dependency specifiers for use on the command line or in
-    :file:`setup.py`.
+    list of dependency specifiers for use on the command line or in the
+    package build backend.
+
+    Version Changed:
+        7.1:
+        * Added the ``local_packages`` argument.
 
     Args:
         deps (dict):
             A dictionary of dependencies.
 
+        version_prefix (str, optional):
+            A prefix to include before any package versions.
+
+        local_packages (dict, optional):
+            A mapping of dependency names to local paths where they could
+            be found.
+
+            Version Added:
+                7.1
+
     Returns:
-        list of unicode:
+        list of str:
         A list of dependency specifiers.
     """
-    new_deps = []
+    new_deps: list[str] = []
 
     for dep_name, dep_details in deps.items():
-        if isinstance(dep_details, list):
+        lower_dep_name = dep_name.lower()
+
+        if lower_dep_name in local_packages:
+            package_path = local_packages[lower_dep_name]
+            new_deps.append(f'{dep_name} @ file://{package_path}')
+        elif isinstance(dep_details, list):
             new_deps += [
                 '%s%s%s; python_version%s'
                 % (dep_name, version_prefix, entry['version'], entry['python'])
diff --git a/reviewboard/licensing/provider.py b/reviewboard/licensing/provider.py
index e51e7bf7093af4c330dc7f527fe44d6b3277476b..e4bcf8aae0a4f6e548fe020069f86cef909002ee 100644
--- a/reviewboard/licensing/provider.py
+++ b/reviewboard/licensing/provider.py
@@ -6,17 +6,23 @@
 
 from __future__ import annotations
 
-from typing import Generic, TYPE_CHECKING, TypeVar
+from datetime import datetime, timedelta
+from typing import Generic, TYPE_CHECKING
 
-from typing_extensions import NotRequired, TypedDict
+from django.utils.html import format_html
+from django.utils.translation import gettext as _
+from typing_extensions import NotRequired, TypeVar, TypedDict
 
-from reviewboard.licensing.license import LicenseInfo
+from reviewboard.licensing.license import LicenseInfo, LicenseStatus
 
 if TYPE_CHECKING:
     from typing import ClassVar, Sequence
 
     from django.http import HttpRequest
-    from djblets.util.typing import JSONValue, StrOrPromise
+    from djblets.util.typing import (JSONValue,
+                                     SerializableJSONDict,
+                                     SerializableJSONList,
+                                     StrOrPromise)
 
     from reviewboard.licensing.license_checks import (
         RequestCheckLicenseResult,
@@ -28,7 +34,9 @@
 #:
 #: Version Added:
 #:    7.1
-_TLicenseInfo = TypeVar('_TLicenseInfo', bound=LicenseInfo)
+_TLicenseInfo = TypeVar('_TLicenseInfo',
+                        bound=LicenseInfo,
+                        default=LicenseInfo)
 
 
 class LicenseAction(TypedDict):
@@ -67,8 +75,11 @@ class BaseLicenseProvider(Generic[_TLicenseInfo]):
     #: The unique ID of the license provider.
     license_provider_id: ClassVar[str]
 
-    #: The name of the JavaScript model for the license provider front-end.
-    js_model_name: ClassVar[str]
+    #: The name of the JavaScript model for the license front-end.
+    js_license_model_name: ClassVar[str] = 'RB.License'
+
+    #: The name of the JavaScript view for the license front-end.
+    js_license_view_name: ClassVar[str] = 'RB.LicenseView'
 
     def get_licenses(self) -> Sequence[_TLicenseInfo]:
         """Return the list of available licenses.
@@ -177,6 +188,130 @@ def get_check_license_request(
         """
         return None
 
+    def get_js_license_model_data(
+        self,
+        *,
+        license_info: _TLicenseInfo,
+        request: (HttpRequest | None) = None,
+    ) -> SerializableJSONDict:
+        """Return data for the JavaScript license model.
+
+        This provides all the data needed by the JavaScript license model
+        to display and manage the license in the UI.
+
+        Args:
+            license_info (reviewboard.licensing.license.LicenseInfo):
+                The license information to convert to model data.
+
+            request (django.http.HttpRequest, optional):
+                The HTTP request from the client.
+
+        Returns:
+            djblets.util.typing.SerializableJSONDict:
+            The data for the JavaScript license model.
+        """
+        is_trial = license_info.is_trial
+        plan_name = license_info.plan_name
+        product = license_info.product_name
+        status = license_info.status
+        summary = license_info.summary
+
+        # Calculate expiration information.
+        expires = license_info.expires
+        grace_period_days = license_info.grace_period_days_remaining
+        hard_expires_date: datetime | None
+
+        if expires and grace_period_days:
+            hard_expires_date = expires + timedelta(days=grace_period_days)
+        else:
+            hard_expires_date = expires
+
+        # Generate a notice, if needed.
+        notice_html: str = ''
+
+        if status == LicenseStatus.EXPIRED_GRACE_PERIOD:
+            # NOTE: This is in Ink syntax, not raw HTML.
+            datetime_ink = format_html(
+                '<time class="timesince" dateTime="{expires_date}"/>',
+                expires_date=hard_expires_date)
+
+            notice_html = format_html(
+                _('Your grace period is now active. Unless renewed, '
+                  '{product} will be disabled {datetime_ink}.'),
+                datetime_ink=datetime_ink,
+                product=product)
+
+        # Generate a default summary.
+        if not summary:
+            if status == LicenseStatus.LICENSED:
+                if is_trial:
+                    if plan_name:
+                        summary = _(
+                            'Trial license for {product} ({plan_name})'
+                        )
+                    else:
+                        summary = _('Trial license for {product}')
+                else:
+                    if plan_name:
+                        summary = _('License for {product} ({plan_name})')
+                    else:
+                        summary = _('License for {product}')
+            elif status == LicenseStatus.UNLICENSED:
+                summary = _('{product} is not licensed!')
+            elif status in (LicenseStatus.HARD_EXPIRED,
+                            LicenseStatus.EXPIRED_GRACE_PERIOD):
+                if is_trial:
+                    if plan_name:
+                        summary = _(
+                            'Expired trial license for {product} ({plan_name})'
+                        )
+                    else:
+                        summary = _('Expired trial license for {product}')
+                else:
+                    if plan_name:
+                        summary = _(
+                            'Expired license for {product} ({plan_name})'
+                        )
+                    else:
+                        summary = _('Expired license for {product}')
+
+            summary = summary.format(plan_name=plan_name,
+                                     product=product)
+
+        # Build actions for the license.
+        actions_data: SerializableJSONList = [
+            {
+                'actionID': action['action_id'],
+                'label': action['label'],
+                'url': action.get('url'),
+            }
+            for action in self.get_license_actions(license_info=license_info)
+        ]
+
+        return {
+            'actionTarget': (
+                f'{self.license_provider_id}:{license_info.license_id}'
+            ),
+            'actions': actions_data,
+            'canUploadLicense': license_info.can_upload_license,
+            'expiresDate': expires,
+            'expiresSoon': license_info.get_expires_soon(),
+            'gracePeriodDaysRemaining': grace_period_days,
+            'hardExpiresDate': hard_expires_date,
+            'isTrial': is_trial,
+            'licenseID': license_info.license_id,
+            'licensedTo': license_info.licensed_to,
+            'lineItems': license_info.line_items,
+            'manageURL': self.get_manage_license_url(
+                license_info=license_info),
+            'noticeHTML': notice_html,
+            'planID': license_info.plan_id,
+            'planName': plan_name,
+            'productName': product,
+            'status': status.value,
+            'summary': summary,
+        }
+
     def process_check_license_result(
         self,
         *,
diff --git a/reviewboard/licensing/tests/test_licenses_view.py b/reviewboard/licensing/tests/test_licenses_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..e27951f74e6830d3078380405b404b7f1469cf96
--- /dev/null
+++ b/reviewboard/licensing/tests/test_licenses_view.py
@@ -0,0 +1,791 @@
+"""Unit tests for reviewboard.licensing.views.LicensesView.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone as tz
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+import kgb
+from django.urls import reverse
+from django.utils import timezone
+
+from reviewboard.licensing.errors import LicenseActionError
+from reviewboard.licensing.license import LicenseInfo, LicenseStatus
+from reviewboard.licensing.license_checks import (
+    ProcessCheckLicenseResult,
+    ProcessCheckLicenseResultStatus,
+)
+from reviewboard.licensing.provider import BaseLicenseProvider
+from reviewboard.licensing.registry import (LicenseProviderRegistry,
+                                            license_provider_registry)
+from reviewboard.testing import TestCase
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable, Sequence
+
+    from django.http import HttpRequest
+    from djblets.util.typing import JSONValue
+
+    from reviewboard.licensing.license_checks import RequestCheckLicenseResult
+    from reviewboard.licensing.provider import LicenseAction
+
+
+class _MyLicenseProvider1(BaseLicenseProvider):
+    license_provider_id = 'my-provider-1'
+
+    def get_license_actions(
+        self,
+        *,
+        license_info: LicenseInfo,
+        request: (HttpRequest | None) = None,
+    ) -> Sequence[LicenseAction]:
+        return [{
+            'action_id': 'test',
+            'label': 'Test',
+            'url': f'https://example.com/{license_info.license_id}/',
+        }]
+
+    def get_licenses(self) -> Sequence[LicenseInfo]:
+        license1 = self.get_license_by_id('license1')
+        license2 = self.get_license_by_id('license2')
+
+        assert license1
+        assert license2
+
+        return [license1, license2]
+
+    def get_license_by_id(
+        self,
+        license_id: str,
+    ) -> LicenseInfo | None:
+        if license_id == 'license1':
+            return LicenseInfo(
+                expires=timezone.now() + timedelta(days=100),
+                license_id=license_id,
+                licensed_to='Test User',
+                line_items=[
+                    'Line 1',
+                    'Line 2',
+                ],
+                product_name='Test Product',
+                status=LicenseStatus.UNLICENSED)
+        elif license_id == 'license2':
+            return LicenseInfo(
+                expires=timezone.now() + timedelta(days=5),
+                license_id=license_id,
+                licensed_to='Test User',
+                plan_id='plan1',
+                plan_name='Plan 1',
+                product_name='Test Product',
+                status=LicenseStatus.LICENSED)
+        else:
+            return None
+
+    def get_manage_license_url(
+        self,
+        *,
+        license_info: LicenseInfo,
+    ) -> str | None:
+        if license_info.license_id == 'license1':
+            return f'https://example.com/{license_info.license_id}/'
+
+        return None
+
+    def get_check_license_status_url(
+        self,
+        *,
+        license_info: LicenseInfo,
+    ) -> str | None:
+        return f'https://example.com/{license_info.license_id}/check/'
+
+    def get_check_license_request(
+        self,
+        *,
+        license_info: LicenseInfo,
+        request: (HttpRequest | None) = None,
+    ) -> RequestCheckLicenseResult | None:
+        return {
+            'data': {
+                'license_id': license_info.license_id,
+                'something': 'special',
+                'version': '1.0',
+            },
+            'url': f'https://example.com/{license_info.license_id}/check/'
+        }
+
+    def process_check_license_result(
+        self,
+        *,
+        license_info: LicenseInfo,
+        check_request_data: JSONValue,
+        check_response_data: JSONValue,
+        request: (HttpRequest | None) = None,
+    ) -> ProcessCheckLicenseResult:
+        assert isinstance(check_response_data, dict)
+
+        if check_response_data.get('version') != '1.0':
+            raise LicenseActionError(
+                'Invalid version from the licensing server! Oh no!'
+            )
+
+        status: ProcessCheckLicenseResultStatus
+
+        if check_response_data.get('updated'):
+            status = ProcessCheckLicenseResultStatus.APPLIED
+            license_info.expires = timezone.now() + timedelta(days=365)
+            license_info.plan_id = 'smpbpe1'
+            license_info.plan_name = 'Super Mega Power Bundle Pro Enterprise'
+        elif check_response_data.get('latest'):
+            status = ProcessCheckLicenseResultStatus.HAS_LATEST
+        else:
+            status = ProcessCheckLicenseResultStatus.ERROR_APPLYING
+
+        return {
+            'status': status,
+            'license_info': self.get_js_license_model_data(
+                license_info=license_info),
+        }
+
+
+class _MyLicenseProvider2(BaseLicenseProvider):
+    license_provider_id = 'my-provider-2'
+
+    def get_licenses(self) -> Sequence[LicenseInfo]:
+        license1 = self.get_license_by_id('license1')
+        license2 = self.get_license_by_id('license2')
+
+        assert license1
+        assert license2
+
+        return [license1, license2]
+
+    def get_license_by_id(
+        self,
+        license_id: str,
+    ) -> LicenseInfo | None:
+        if license_id == 'license1':
+            return LicenseInfo(
+                expires=timezone.now() - timedelta(days=50),
+                is_trial=True,
+                license_id=license_id,
+                licensed_to='Test User',
+                product_name='Test Product',
+                status=LicenseStatus.HARD_EXPIRED)
+        elif license_id == 'license2':
+            return LicenseInfo(
+                expires=timezone.now() - timedelta(days=2),
+                license_id=license_id,
+                licensed_to='Test User',
+                product_name='Test Product',
+                status=LicenseStatus.EXPIRED_GRACE_PERIOD)
+        else:
+            return None
+
+
+class _MyLicenseProviderRegistry(LicenseProviderRegistry):
+    def get_defaults(self) -> Iterable[BaseLicenseProvider]:
+        yield _MyLicenseProvider1()
+        yield _MyLicenseProvider2()
+
+
+class LicenseViewTests(kgb.SpyAgency, TestCase):
+    """Unit tests for LicenseView.
+
+    Version Added:
+        7.1
+    """
+
+    fixtures = ['test_users']
+
+    ######################
+    # Instance variables #
+    ######################
+
+    #: A static timestamp for "now", to ease unit testing.
+    now: datetime
+
+    #: The list of license providers registered for tests.
+    _license_providers: list[BaseLicenseProvider]
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        """Set up state for all unit tests in the class.
+
+        This will store a static timestamp for "now" and a list of license
+        providers to register for the tests.
+        """
+        super().setUpClass()
+
+        license_providers = [
+            _MyLicenseProvider1(),
+            _MyLicenseProvider2(),
+        ]
+
+        cls._license_providers = license_providers
+        cls.now = datetime(2025, 4, 21, 0, 0, 0, tzinfo=tz.utc)
+
+    @classmethod
+    def tearDownClass(cls) -> None:
+        """Tear down state for the unit tests."""
+        super().tearDownClass()
+
+        cls._license_providers = []
+        cls.now = None  # type: ignore
+
+    def setUp(self) -> None:
+        """Set up state for a unit test.
+
+        This will register all the license providers and force the current
+        timestamp for "now".
+        """
+        super().setUp()
+
+        for license_provider in self._license_providers:
+            license_provider_registry.register(license_provider)
+
+        self.spy_on(timezone.now, op=kgb.SpyOpReturn(self.now))
+
+    def tearDown(self) -> None:
+        """Tear down state for the test.
+
+        This will unregister all the license providers.
+        """
+        super().tearDown()
+
+        for license_provider in self._license_providers:
+            license_provider_registry.unregister(license_provider)
+
+    def test_get_as_anonymous(self) -> None:
+        """Testing LicenseView.get as anonymous"""
+        client = self.client
+        response = client.get(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 302)
+
+    def test_get_as_non_admin(self) -> None:
+        """Testing LicenseView.get as non-admin user"""
+        client = self.client
+        self.assertTrue(self.client.login(username='dopey', password='dopey'))
+        response = client.get(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 302)
+
+    def test_get_as_admin(self) -> None:
+        """Testing LicenseView.get as admin"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.get(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(response.context['license_entries'], [
+            {
+                'attrs': {
+                    'actionTarget': 'my-provider-1:license1',
+                    'actions': [
+                        {
+                            'actionID': 'test',
+                            'label': 'Test',
+                            'url': 'https://example.com/license1/',
+                        },
+                    ],
+                    'canUploadLicense': False,
+                    'expiresDate': datetime(2025, 7, 30, 0, 0, tzinfo=tz.utc),
+                    'expiresSoon': False,
+                    'gracePeriodDaysRemaining': 0,
+                    'hardExpiresDate': datetime(2025, 7, 30, 0, 0,
+                                                tzinfo=tz.utc),
+                    'isTrial': False,
+                    'licenseID': 'license1',
+                    'licensedTo': 'Test User',
+                    'lineItems': [
+                        'Line 1',
+                        'Line 2',
+                    ],
+                    'manageURL': 'https://example.com/license1/',
+                    'noticeHTML': '',
+                    'planID': None,
+                    'planName': None,
+                    'productName': 'Test Product',
+                    'status': 'unlicensed',
+                    'summary': 'Test Product is not licensed!',
+                },
+                'model': 'RB.License',
+                'view': 'RB.LicenseView',
+            },
+            {
+                'attrs': {
+                    'actionTarget': 'my-provider-1:license2',
+                    'actions': [
+                        {
+                            'actionID': 'test',
+                            'label': 'Test',
+                            'url': 'https://example.com/license2/',
+                        },
+                    ],
+                    'canUploadLicense': False,
+                    'expiresDate': datetime(2025, 4, 26, 0, 0,
+                                            tzinfo=tz.utc),
+                    'expiresSoon': True,
+                    'gracePeriodDaysRemaining': 0,
+                    'hardExpiresDate': datetime(2025, 4, 26, 0, 0,
+                                                tzinfo=tz.utc),
+                    'isTrial': False,
+                    'licenseID': 'license2',
+                    'licensedTo': 'Test User',
+                    'lineItems': [],
+                    'manageURL': None,
+                    'noticeHTML': '',
+                    'planID': 'plan1',
+                    'planName': 'Plan 1',
+                    'productName': 'Test Product',
+                    'status': 'licensed',
+                    'summary': 'License for Test Product (Plan 1)',
+                },
+                'model': 'RB.License',
+                'view': 'RB.LicenseView',
+            },
+            {
+                'attrs': {
+                    'actionTarget': 'my-provider-2:license1',
+                    'actions': [],
+                    'canUploadLicense': False,
+                    'expiresDate': datetime(2025, 3, 2, 0, 0,
+                                            tzinfo=tz.utc),
+                    'expiresSoon': False,
+                    'gracePeriodDaysRemaining': 0,
+                    'hardExpiresDate': datetime(2025, 3, 2, 0, 0,
+                                                tzinfo=tz.utc),
+                    'isTrial': True,
+                    'licenseID': 'license1',
+                    'licensedTo': 'Test User',
+                    'lineItems': [],
+                    'manageURL': None,
+                    'noticeHTML': '',
+                    'planID': None,
+                    'planName': None,
+                    'productName': 'Test Product',
+                    'status': 'hard-expired',
+                    'summary': 'Expired trial license for Test Product',
+                },
+                'model': 'RB.License',
+                'view': 'RB.LicenseView',
+            },
+            {
+                'attrs': {
+                    'actionTarget': 'my-provider-2:license2',
+                    'actions': [],
+                    'canUploadLicense': False,
+                    'expiresDate': datetime(2025, 4, 19, 0, 0,
+                                            tzinfo=tz.utc),
+                    'expiresSoon': False,
+                    'gracePeriodDaysRemaining': 0,
+                    'hardExpiresDate': datetime(2025, 4, 19, 0, 0,
+                                                tzinfo=tz.utc),
+                    'isTrial': False,
+                    'licenseID': 'license2',
+                    'licensedTo': 'Test User',
+                    'lineItems': [],
+                    'manageURL': None,
+                    'noticeHTML': (
+                        'Your grace period is now active. Unless renewed, '
+                        'Test Product will be disabled <time '
+                        'class="timesince" dateTime="2025-04-19 '
+                        '00:00:00+00:00"/>.'
+                    ),
+                    'planID': None,
+                    'planName': None,
+                    'productName': 'Test Product',
+                    'status': 'expired-grace-period',
+                    'summary': 'Expired license for Test Product',
+                },
+                'model': 'RB.License',
+                'view': 'RB.LicenseView',
+            },
+        ])
+
+    def test_post_as_anonymous(self) -> None:
+        """Testing LicenseView.post as anonymous"""
+        client = self.client
+        response = client.post(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 302)
+
+    def test_post_as_non_admin(self) -> None:
+        """Testing LicenseView.post as non-admin user"""
+        client = self.client
+        self.assertTrue(self.client.login(username='dopey', password='dopey'))
+        response = client.post(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 302)
+
+    def test_post_with_no_action(self) -> None:
+        """Testing LicenseView.post as admin with no action"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'))
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': 'Missing action data.',
+        })
+
+    def test_post_with_no_action_target(self) -> None:
+        """Testing LicenseView.post as admin with no action_target"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'xxx',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': 'Missing action target.',
+        })
+
+    def test_post_with_invalid_action_target(self) -> None:
+        """Testing LicenseView.post as admin with invalid action_target"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'xxx',
+            'action_target': 'xxx',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': 'Invalid action target.',
+        })
+
+    def test_post_with_license_not_found(self) -> None:
+        """Testing LicenseView.post as admin with license not found"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'xxx',
+            'action_target': 'my-provider-2:xxx',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': 'The license entry could not be found.',
+        })
+
+    def test_post_with_invalid_action(self) -> None:
+        """Testing LicenseView.post as admin with invalid action"""
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'xxx',
+            'action_target': 'my-provider-2:license1',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': 'Unsupported license action "xxx".',
+        })
+
+    def test_post_with_license_update_check(self) -> None:
+        """Testing LicenseView.post as admin with action="license-update-check"
+        """
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'license-update-check',
+            'action_target': 'my-provider-1:license1',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'canCheck': True,
+            'checkStatusURL': 'https://example.com/license1/check/',
+            'credentials': None,
+            'data': {
+                'license_id': 'license1',
+                'something': 'special',
+                'version': '1.0',
+            },
+            'headers': None,
+        })
+
+    def test_post_with_license_update_check_not_supported(self) -> None:
+        """Testing LicenseView.post as admin with action="license-update-check"
+        not supported
+        """
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'license-update-check',
+            'action_target': 'my-provider-2:license1',
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'canCheck': False,
+        })
+
+    def test_post_with_process_license_update_and_applied(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and new license data applied
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': json.dumps({
+                'license_id': 'license1',
+                'something': 'special',
+                'version': '1.0',
+            }),
+            'check_response_data': json.dumps({
+                'updated': True,
+                'version': '1.0',
+            }),
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'status': 'applied',
+            'license_info': {
+                'actionTarget': 'my-provider-1:license2',
+                'actions': [
+                    {
+                        'actionID': 'test',
+                        'label': 'Test',
+                        'url': 'https://example.com/license2/',
+                    },
+                ],
+                'canUploadLicense': False,
+                'expiresDate': '2026-04-21T00:00:00Z',
+                'expiresSoon': False,
+                'gracePeriodDaysRemaining': 0,
+                'hardExpiresDate': '2026-04-21T00:00:00Z',
+                'isTrial': False,
+                'licenseID': 'license2',
+                'licensedTo': 'Test User',
+                'lineItems': [],
+                'manageURL': None,
+                'noticeHTML': '',
+                'planID': 'smpbpe1',
+                'planName': 'Super Mega Power Bundle Pro Enterprise',
+                'productName': 'Test Product',
+                'status': 'licensed',
+                'summary': (
+                    'License for Test Product (Super Mega Power Bundle '
+                    'Pro Enterprise)'
+                ),
+            },
+        })
+
+    def test_post_with_process_license_update_and_has_latest(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and already has latest data
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': json.dumps({
+                'license_id': 'license1',
+                'something': 'special',
+                'version': '1.0',
+            }),
+            'check_response_data': json.dumps({
+                'latest': True,
+                'version': '1.0',
+            }),
+        })
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.json(), {
+            'status': 'has-latest',
+            'license_info': {
+                'actionTarget': 'my-provider-1:license2',
+                'actions': [
+                    {
+                        'actionID': 'test',
+                        'label': 'Test',
+                        'url': 'https://example.com/license2/',
+                    },
+                ],
+                'canUploadLicense': False,
+                'expiresDate': '2025-04-26T00:00:00Z',
+                'expiresSoon': True,
+                'gracePeriodDaysRemaining': 0,
+                'hardExpiresDate': '2025-04-26T00:00:00Z',
+                'isTrial': False,
+                'licenseID': 'license2',
+                'licensedTo': 'Test User',
+                'lineItems': [],
+                'manageURL': None,
+                'noticeHTML': '',
+                'planID': 'plan1',
+                'planName': 'Plan 1',
+                'productName': 'Test Product',
+                'status': 'licensed',
+                'summary': 'License for Test Product (Plan 1)',
+            },
+        })
+
+    def test_post_with_process_license_update_and_error(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and error
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': json.dumps({
+                'license_id': 'license1',
+                'something': 'special',
+                'version': '1.0',
+            }),
+            'check_response_data': json.dumps({
+                'latest': True,
+                'version': '2.0',
+            }),
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': (
+                'Error processing license update: Invalid version from '
+                'the licensing server! Oh no!'
+            ),
+        })
+
+    def test_post_with_process_license_update_no_check_data(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and missing check_request_data
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': (
+                f'Missing check_request_data value for license check. This '
+                f'may be an internal error or an issue with the licensing '
+                f'server. Check the Review Board server logs for more '
+                f'information (error ID {trace_id}).'
+            ),
+        })
+
+    def test_post_with_process_license_update_no_response_data(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and missing check_response_data
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': '"abc123"',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': (
+                f'Missing check_response_data value for license check. This '
+                f'may be an internal error or an issue with the licensing '
+                f'server. Check the Review Board server logs for more '
+                f'information (error ID {trace_id}).'
+            ),
+        })
+
+    def test_post_with_process_license_update_invalid_check_data(self) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and non-JSON check_response_data
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': 'xxx',
+            'check_response_data': '"def456"',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': (
+                f'Invalid check_request_data value for license check. This '
+                f'may be an internal error or an issue with the licensing '
+                f'server. Check the Review Board server logs for more '
+                f'information (error ID {trace_id}).'
+            ),
+        })
+
+    def test_post_with_process_license_update_invalid_response_data(
+        self,
+    ) -> None:
+        """Testing LicenseView.post as admin with
+        action="process-license-update" and non-JSON check_response_data
+        """
+        trace_id = '00000000-0000-0000-0000-000000000001'
+        self.spy_on(uuid4, op=kgb.SpyOpReturn(trace_id))
+
+        client = self.client
+        self.assertTrue(client.login(username='admin', password='admin'))
+
+        response = client.post(reverse('admin-licenses'), {
+            'action': 'process-license-update',
+            'action_target': 'my-provider-1:license2',
+            'check_request_data': '"abc123"',
+            'check_response_data': 'xxx',
+        })
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json(), {
+            'error': (
+                f'Invalid check_response_data value for license check. This '
+                f'may be an internal error or an issue with the licensing '
+                f'server. Check the Review Board server logs for more '
+                f'information (error ID {trace_id}).'
+            ),
+        })
diff --git a/reviewboard/licensing/urls.py b/reviewboard/licensing/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ce81349c4216f7c1314ac0fdaa7d2d3d8bcd94e
--- /dev/null
+++ b/reviewboard/licensing/urls.py
@@ -0,0 +1,18 @@
+"""URLs for processing license-related state.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from django.urls import path
+
+from reviewboard.licensing.views import LicensesView
+
+
+urlpatterns = [
+    path('',
+         LicensesView.as_view(),
+         name='admin-licenses'),
+]
diff --git a/reviewboard/licensing/views.py b/reviewboard/licensing/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac484c3813e93eab14f61babef43e4a0a8510a84
--- /dev/null
+++ b/reviewboard/licensing/views.py
@@ -0,0 +1,550 @@
+"""Views for managing licenses.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import TYPE_CHECKING, cast
+from uuid import uuid4
+
+from django.contrib.admin.views.decorators import staff_member_required
+from django.http import HttpResponseBadRequest, JsonResponse
+from django.shortcuts import render
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext as _
+from django.views.decorators.csrf import csrf_protect
+from django.views.generic.base import ContextMixin, View
+from djblets.features.decorators import feature_required
+from djblets.util.serializers import DjbletsJSONEncoder
+from djblets.util.typing import SerializableJSONDict
+
+from reviewboard.licensing.errors import LicenseActionError
+from reviewboard.licensing.features import licensing_feature
+from reviewboard.licensing.registry import license_provider_registry
+
+if TYPE_CHECKING:
+    from django.http import HttpRequest, HttpResponse
+
+    from reviewboard.licensing.license import LicenseInfo
+    from reviewboard.licensing.provider import BaseLicenseProvider
+
+
+logger = logging.getLogger(__name__)
+
+
+@method_decorator(
+    (
+        feature_required(licensing_feature),
+        staff_member_required,
+        csrf_protect,
+    ),
+    name='dispatch',
+)
+class LicensesView(ContextMixin, View):
+    """Displays and processes information on known product licenses.
+
+    Upon loading, the licenses will be checked for any updates and updated
+    if needed.
+
+    Actions on licenses can be performed by sending HTTP POST requests to
+    this view with the following form data:
+
+    ``action``:
+        The name of the action.
+
+    ``action_target``:
+        A generated unique ID for the license for the purpose of this view.
+
+    All actions are considered internal and are subject to change. There are
+    no API stability guarantees.
+
+    The following actions are supported:
+
+    ``license-update-check``:
+        Generates data for a license update check request to a separate
+        licensing server. This is used for automatic license updates.
+
+    ``process-license-update``:
+        Process a license update payload from a separate licensing server.
+        This is used for automatic license updates.
+
+        It takes the following form data:
+
+        ``check_request_data``:
+            The license update check request data originally generated during
+            the ``license-update-check`` action.
+
+        ``check_response_data``:
+            The payload from the licensing server.
+
+    ``upload-license``:
+        Uploads new data for a license, if supported by the License Provider.
+
+        It takes the following form data:
+
+        ``license_data``:
+            The new license data.
+
+    Version Added:
+        7.1
+    """
+
+    def get(
+        self,
+        request: HttpRequest,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
+        """Handle HTTP GET requests.
+
+        This will display each known license, and handle auto-updating the
+        licenses if supported by the License Providers.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            *args (tuple, unused):
+                Unused positional arguments passed to the handler.
+
+            **kwargs (dict, unused):
+                Unused keyword arguments passed to the handler.
+
+        Returns:
+            django.http.HttpResponse:
+            The HTTP response for the page.
+        """
+        license_entries = [
+            {
+                'attrs': license_provider.get_js_license_model_data(
+                    license_info=license_info,
+                    request=request),
+                'model': license_provider.js_license_model_name,
+                'view': license_provider.js_license_view_name,
+            }
+            for license_provider in license_provider_registry
+            for license_info in license_provider.get_licenses()
+        ]
+
+        return render(
+            request=request,
+            template_name='admin/licensing.html',
+            context={
+                'license_entries': license_entries,
+            })
+
+    def post(
+        self,
+        request: HttpRequest,
+        *args,
+        **kwargs,
+    ) -> HttpResponse:
+        """Handle HTTP POST requests.
+
+        This will handle requests for license actions, dispatching out to
+        the right action handler and returning a JSON payload of the
+        results.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            *args (tuple, unused):
+                Unused positional arguments passed to the handler.
+
+            **kwargs (tuple, unused):
+                Unused keyword arguments passed to the handler.
+
+        Returns:
+            django.http.HttpResponse:
+            The HTTP response for the page.
+        """
+        # Pull out the details of the request.
+        action = request.POST.get('action')
+
+        try:
+            if not action:
+                raise LicenseActionError(_('Missing action data.'))
+
+            action_target = request.POST.get('action_target')
+
+            if not action_target:
+                raise LicenseActionError(_('Missing action target.'))
+
+            # Parse the action target.
+            try:
+                license_provider_id, license_id = action_target.split(':')
+            except ValueError:
+                raise LicenseActionError(_('Invalid action target.'))
+
+            # See if that corresponds to a valid License Provider and license.
+            license_provider = license_provider_registry.get_license_provider(
+                license_provider_id)
+
+            if license_provider is None:
+                raise LicenseActionError(_('Invalid license provider.'))
+
+            license_info = license_provider.get_license_by_id(license_id)
+
+            if license_info is None:
+                raise LicenseActionError(_(
+                    'The license entry could not be found.'
+                ))
+        except LicenseActionError as e:
+            return HttpResponseBadRequest(
+                json.dumps({
+                    'error': str(e),
+                    **e.payload,
+                }),
+                content_type='application/json')
+        except Exception as e:
+            logger.exception('Unexpected error performing license action '
+                             '"%s": %s',
+                             action, e)
+
+            return HttpResponseBadRequest(
+                json.dumps({
+                    'error': _(
+                        'Unexpected error performing license action '
+                        '"{action}": {message}'
+                    ).format(action=action,
+                             message=e)
+                }),
+                content_type='application/json')
+
+        # Check which action we're performing and invoke it.
+        try:
+            if action == 'upload-license':
+                result = self._upload_license(
+                    license_info=license_info,
+                    license_provider=license_provider,
+                    request=request)
+            elif action == 'license-update-check':
+                result = self._license_update_check(
+                    license_info=license_info,
+                    license_provider=license_provider,
+                    request=request)
+            elif action == 'process-license-update':
+                result = self._process_license_update_data(
+                    license_info=license_info,
+                    license_provider=license_provider,
+                    request=request)
+            else:
+                raise LicenseActionError(
+                    _('Unsupported license action "{action}".')
+                    .format(action=action))
+
+            return JsonResponse(result, encoder=DjbletsJSONEncoder)
+        except LicenseActionError as e:
+            return HttpResponseBadRequest(
+                json.dumps({
+                    'error': str(e),
+                    **e.payload,
+                }),
+                content_type='application/json')
+        except Exception as e:
+            logger.exception('Unexpected error performing license action '
+                             '"%s" for license %r on provider %r: %s',
+                             action, license_info, license_provider, e)
+
+            return HttpResponseBadRequest(
+                json.dumps({
+                    'error': _(
+                        'Unexpected error performing license action '
+                        '"{action}": {message}'
+                    ).format(action=action,
+                             message=e)
+                }),
+                content_type='application/json')
+
+    def _license_update_check(
+        self,
+        *,
+        license_info: LicenseInfo,
+        license_provider: BaseLicenseProvider,
+        request: HttpRequest,
+    ) -> SerializableJSONDict:
+        """Handle a license update check.
+
+        This will request a license server URL and a payload from the License
+        Provider. The client can send the payload to the license server URL to
+        request a new license, or check status for a license.
+
+        Args:
+            license_info (reviewboard.licensing.license.LicenseInfo):
+                Information on the license to check for updates.
+
+            license_provider (reviewboard.licensing.provider.
+                              BaseLicenseProvider):
+                The License Provider managing this license.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+        Returns:
+            dict:
+            The JSON-serializable dictionary of results to send back to the
+            client.
+
+        Raises:
+            reviewboard.licensing.errors.LicenseActionError:
+                An error invoking an action in the License Provider. This
+                will result in a suitable error message for the client.
+        """
+        check_request = license_provider.get_check_license_request(
+            license_info=license_info,
+            request=request)
+
+        if not check_request:
+            return {
+                'canCheck': False,
+            }
+
+        return {
+            'canCheck': True,
+            'checkStatusURL': check_request['url'],
+            'credentials': check_request.get('credentials'),
+            'data': check_request['data'],
+            'headers': check_request.get('headers'),
+        }
+
+    def _process_license_update_data(
+        self,
+        *,
+        license_info: LicenseInfo,
+        license_provider: BaseLicenseProvider,
+        request: HttpRequest,
+    ) -> SerializableJSONDict:
+        """Handle an automated license update payload.
+
+        This will process the payload from a license server, passing the
+        result to the License Provider. That may install a new license or
+        update information about a license in the backend.
+
+        Args:
+            license_info (reviewboard.licensing.license.LicenseInfo):
+                Information on the license to check for updates.
+
+            license_provider (reviewboard.licensing.provider.
+                              BaseLicenseProvider):
+                The License Provider managing this license.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+        Returns:
+            dict:
+            The JSON-serializable dictionary of results to send back to the
+            client.
+
+        Raises:
+            reviewboard.licensing.errors.LicenseActionError:
+                An error with the data or with invoking the actions in the
+                License Provider. This will result in a suitable error
+                message for the client.
+        """
+        trace_id = str(uuid4())
+
+        logger.info('[%s] Checking license update response for License '
+                    'Provider %r',
+                    trace_id, license_provider.license_provider_id)
+
+        # Pull out the request and response data to verify.
+        try:
+            check_request_data = request.POST['check_request_data']
+        except KeyError:
+            logger.error('[%s] Missing check_request_data for license check',
+                         trace_id)
+
+            raise LicenseActionError(
+                _('Missing check_request_data value for license check. '
+                  'This may be an internal error or an issue with the '
+                  'licensing server. Check the Review Board server logs for '
+                  'more information (error ID {trace_id}).')
+                .format(trace_id=trace_id))
+
+        logger.debug('[%s] Check request data = %r',
+                     trace_id, check_request_data)
+
+        try:
+            check_response_data = request.POST['check_response_data']
+        except KeyError:
+            logger.error('[%s] Missing check_response_data for license check',
+                         trace_id)
+
+            raise LicenseActionError(
+                _('Missing check_response_data value for license check. '
+                  'This may be an internal error or an issue with the '
+                  'licensing server. Check the Review Board server logs for '
+                  'more information (error ID {trace_id}).')
+                .format(trace_id=trace_id))
+
+        logger.debug('[%s] Check response data = %r',
+                     trace_id, check_response_data)
+
+        if not check_response_data:
+            logger.error('[%s] Empty check_response_data for license check',
+                         trace_id)
+
+            raise LicenseActionError(
+                _('Empty check_response_data value for license check. '
+                  'This may be an internal error or an issue with the '
+                  'licensing server. Check the Review Board server logs for '
+                  'more information (error ID {trace_id}).')
+                .format(trace_id=trace_id))
+
+        # Deserialize the payloads.
+        try:
+            check_request_payload = json.loads(check_request_data)
+        except ValueError:
+            logger.error('[%s] check_request_data was not valid JSON',
+                         trace_id)
+
+            raise LicenseActionError(
+                _('Invalid check_request_data value for license check. '
+                  'This may be an internal error or an issue with the '
+                  'licensing server. Check the Review Board server logs for '
+                  'more information (error ID {trace_id}).')
+                .format(trace_id=trace_id))
+
+        try:
+            check_response_payload = json.loads(check_response_data)
+        except ValueError:
+            logger.error('[%s] check_response_data was not valid JSON',
+                         trace_id)
+
+            raise LicenseActionError(
+                _('Invalid check_response_data value for license check. '
+                  'This may be an internal error or an issue with the '
+                  'licensing server. Check the Review Board server logs for '
+                  'more information (error ID {trace_id}).')
+                .format(trace_id=trace_id))
+
+        # Pass to the License Provider and check the result.
+        try:
+            result = license_provider.process_check_license_result(
+                license_info=license_info,
+                check_request_data=check_request_payload,
+                check_response_data=check_response_payload,
+                request=request)
+            status = result['status']
+        except NotImplementedError as e:
+            logger.exception('[%s] Automated license checks are enabled '
+                             'for this license provider but not implemented. '
+                             'This is an internal error.',
+                             trace_id)
+
+            raise LicenseActionError(
+                _('The license provider implementation enables support for '
+                  'automated license checks but does not provide an '
+                  'implementation. This is an internal error. See the '
+                  'Review Board server logs for more information (error ID '
+                  '{trace_id}).')
+                .format(trace_id=trace_id)
+            ) from e
+        except LicenseActionError as e:
+            raise LicenseActionError(
+                _('Error processing license update: {message}')
+                .format(message=str(e)),
+                payload=e.payload
+            ) from e
+        except Exception as e:
+            logger.exception('[%s] Unexpected error checking license '
+                             'result: %s',
+                             trace_id, e)
+
+            raise LicenseActionError(
+                _('Unexpected error processing license update. Check the '
+                  'Review Board server logs for more information (error ID '
+                  '{trace_id}).')
+                .format(trace_id=trace_id)
+            ) from e
+
+        logger.info('[%s] License update check complete: %s',
+                    trace_id, status)
+
+        return cast(SerializableJSONDict, result)
+
+    def _upload_license(
+        self,
+        *,
+        license_info: LicenseInfo,
+        license_provider: BaseLicenseProvider,
+        request: HttpRequest,
+    ) -> SerializableJSONDict:
+        """Handle a manual license upload.
+
+        This will take the provided license data and pass it to the License
+        Provider for license replacement. This is dependent on the
+        capabilities of the License Provider.
+
+        Args:
+            license_info (reviewboard.licensing.license.LicenseInfo):
+                Information on the license to check for updates.
+
+            license_provider (reviewboard.licensing.provider.
+                              BaseLicenseProvider):
+                The License Provider managing this license.
+
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+        Returns:
+            dict:
+            The JSON-serializable dictionary of results to send back to the
+            client.
+
+        Raises:
+            reviewboard.licensing.errors.LicenseActionError:
+                An error with the data or with invoking the upload in the
+                License Provider. This will result in a suitable error
+                message for the client.
+        """
+        license_data_fp = request.FILES.get('license_data')
+
+        if not license_data_fp:
+            raise LicenseActionError(_('No license data was found'))
+
+        license_data = license_data_fp.read()
+
+        # Allow any errors to bubble up.
+        try:
+            license_provider.set_license_data(
+                license_info=license_info,
+                license_data=license_data,
+                request=request)
+        except NotImplementedError:
+            raise LicenseActionError(_(
+                'Licenses for this product cannot be uploaded manually.'
+            ))
+        except LicenseActionError:
+            # Let this error bubble up.
+            raise
+        except Exception as e:
+            logger.exception('Unexpected error setting license data %r for '
+                             'license %r on provider %r: %s',
+                             license_data, license_info, license_provider, e)
+
+            raise LicenseActionError(_(
+                'Unexpected error setting license data for this product. '
+                'Check the Review Board server logs for more information.'
+            ))
+
+        # Reload the license.
+        new_license_info = license_provider.get_license_by_id(
+            license_info.license_id)
+
+        if new_license_info:
+            license_info_data = license_provider.get_js_license_model_data(
+                license_info=new_license_info,
+                request=request)
+        else:
+            license_info_data = None
+
+        return {
+            'license_info': license_info_data,
+        }
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index 5171668f507cc4c4ef6d297b7c188479d1a61d71..f78eb2f161e6b99827362a9c69ed04f6ce700f4c 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -677,6 +677,8 @@ def _(s):
     'auth.user': lambda u: reverse('user', kwargs={'username': u.username})
 }
 
+PRODUCT_SUFFIX = 'Community Edition'
+
 FEATURE_CHECKER = 'reviewboard.features.checkers.RBFeatureChecker'
 
 OAUTH2_PROVIDER = {
diff --git a/reviewboard/static/rb/css/bundles/admin.less b/reviewboard/static/rb/css/bundles/admin.less
index c20efa5974c407fd9277d1e589e3027c8bd32c5c..4e3a2044200b923e922217adbc055a3f6ac9912d 100644
--- a/reviewboard/static/rb/css/bundles/admin.less
+++ b/reviewboard/static/rb/css/bundles/admin.less
@@ -13,5 +13,6 @@
 @import 'rb/css/pages/admin/webhooks.less';
 @import 'rb/css/pages/admin/widgets.less';
 @import 'rb/css/ui/admin/filters.less';
+@import 'rb/css/ui/admin/license.less';
 @import 'rb/css/ui/admin/search.less';
 @import 'rb/css/ui/admin/widgets.less';
diff --git a/reviewboard/static/rb/css/ui/admin/license.less b/reviewboard/static/rb/css/ui/admin/license.less
new file mode 100644
index 0000000000000000000000000000000000000000..5084c04ef9fe94ff99df3b98612403c88a26476a
--- /dev/null
+++ b/reviewboard/static/rb/css/ui/admin/license.less
@@ -0,0 +1,281 @@
+/**
+ * A license card.
+ *
+ * This displays information about a license, including its status, summary,
+ * details, and actions.
+ *
+ * Modifiers:
+ *     -has-warning:
+ *         The license has a warning state to display.
+ *
+ * DOM Attributes:
+ *     data-status:
+ *         The current status of the license. One of:
+
+ *         * ``expired-grace-period``
+ *         * ``hard-expired``
+ *         * ``licensed``
+ *         * ``unlicensed``
+ *
+ *     data-check-status:
+ *         The current status of a license check operation. One of:
+
+ *         * ``applying``
+ *         * ``checking``
+ *         * ``error-checking``
+ *         * ``error-applying``
+ *
+ * Structure:
+ *     <div class="rb-c-license">
+ *      <div class="rb-c-license__header">...</div>
+ *      <ul class="rb-c-license__details">...</ul>
+ *     </div>
+ */
+.rb-c-license {
+  @status-icon-size: 48px;
+
+  background: var(--ink-p-container-bg);
+  border: var(--ink-g-border-container);
+  border-radius: var(--ink-u-border-radius-std);
+  box-shadow: var(--ink-g-shadow-std);
+  color: var(--ink-p-container-fg);
+  display: grid;
+  grid-template-columns: min-content 1fr;
+  gap: var(--ink-u-spacing-std);
+  padding: var(--ink-u-spacing-std);
+
+  &::before {
+    .fa-icon();
+
+    grid-row: 1 / -1;
+    font-size: @status-icon-size;
+    width: @status-icon-size;
+    vertical-align: top;
+    text-align: center;
+  }
+
+  &[data-status].-has-warning {
+    /* TODO: Add an Ink orange palette. */
+    @_light-orange: #f79a28;
+    @_dark-orange: #b9721b;
+
+    &::before {
+      color: @_light-orange;
+      content: @fa-var-warning;
+    }
+
+    .rb-c-license__summary {
+      color:
+        var(--if-dark, @_light-orange)
+        var(--if-light, @_dark-orange);
+    }
+  }
+
+  &[data-status="licensed"] {
+    &::before {
+      color:
+        var(--if-dark, var(--ink-p-green-700))
+        var(--if-light, var(--ink-p-green-500));
+      content: @fa-var-check-circle;
+    }
+
+    .rb-c-license__summary {
+      color: var(--ink-p-green-900);
+    }
+  }
+
+  &[data-check-status="error-checking"],
+  &[data-check-status="error-applying"],
+  &[data-status="hard-expired"],
+  &[data-status="unlicensed"],
+  &[data-status="expired-grace-period"] {
+    &::before {
+      color:
+        var(--if-dark, var(--ink-p-red-800))
+        var(--if-light, var(--ink-p-red-400));
+      content: @fa-var-times-circle;
+    }
+
+    .rb-c-license__summary {
+      color:
+        var(--if-dark, var(--ink-p-red-800))
+        var(--if-light, var(--ink-p-red-600));
+    }
+  }
+
+  &[data-check-status="checking"],
+  &[data-check-status="applying"] {
+    &::before {
+      @spinner-size: 24px;
+
+      color:
+        var(--if-dark, var(--ink-p-red-800))
+        var(--if-light, var(--ink-p-red-400));
+      content: "" !important;
+      background-color: var(--ink-p-container-fg);
+      background-repeat: no-repeat;
+      mask-image: var(--ink-c-spinner-image);
+      width: @spinner-size;
+      height: @spinner-size;
+      margin: ((@status-icon-size - @spinner-size) / 2);
+    }
+
+    .rb-c-license__summary {
+      color: var(--ink-p-container-fg);
+    }
+  }
+
+  /**
+   * Container for license action buttons.
+   *
+   * Structure:
+   *     <div class="rb-c-license__actions">
+   *      <button class="ink-c-button ...">...</button>
+   *      ...
+   *     </div>
+   */
+  &__actions {
+    display: flex;
+    gap: var(--ink-u-spacing-m);
+    padding: var(--ink-u-spacing-m) 0;
+
+    &:empty {
+      padding: 0;
+    }
+  }
+
+  /**
+   * A detail item showing license information.
+   *
+   * Structure:
+   *     <li class="rb-c-license__detail">
+   *      <span class="rb-c-license__detail-icon ..."></span>
+   *      <div class="rb-c-license__detail-content ..."></div>
+   *     </li>
+   */
+  &__detail {
+    display: flex;
+    gap: 0.5ch;
+  }
+
+  /**
+   * Content shown for a detail item.
+   *
+   * Structure:
+   *     <div class="rb-c-license__detail-content ..."></div>
+   */
+  &__detail-content {
+  }
+
+  /**
+   * An icon shown in a detail item.
+   *
+   * Structure:
+   *     <span class="rb-c-license__detail-icon ..."></span>
+   */
+  &__detail-icon {
+    min-height: var(--ink-u-icon-std);
+    min-width: var(--ink-u-icon-std);
+    vertical-align: text-top;
+  }
+
+  /**
+   * Container for license detail items.
+   */
+  &__details {
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    margin: 0;
+    padding: 0;
+    gap: var(--ink-u-spacing-m);
+
+    &:empty {
+      display: none;
+    }
+  }
+
+  /**
+   * Header section containing the status icon and summary.
+   *
+   * Structure:
+   *     <div class="rb-c-license__header">
+   *      <h3 class="rb-c-license__summary">...</h3>
+   *      [<div class="rb-c-license__notice">...</div>]
+   *      <div class="rb-c-license__state">...</div>
+   *     </div>
+   */
+  &__header {
+    display: flex;
+    flex-direction: column;
+    gap: var(--ink-u-spacing-m);
+    line-height: 1.5;
+  }
+
+  /**
+   * A notice shown below the summary.
+   *
+   * Structure:
+   *     <div class="rb-c-license__notice">
+   *      text...
+   *     </div>
+   */
+  &__notice {
+  }
+
+  /**
+   * The current state of the license.
+   *
+   * Structure:
+   *     <div class="rb-c-license__state">
+   *      text...
+   *     </div>
+   */
+  &__state {
+  }
+
+  /**
+   * A summary of the license status.
+   *
+   * Structure:
+   *     <h3 class="rb-c-license__summary">
+   *      text...
+   *     </h3>
+   */
+  &__summary {
+    font-size: var(--ink-u-font-ml);
+    margin: 0;
+    padding: 0;
+  }
+
+  /**
+   * A warning shown below the summary.
+   *
+   * Structure:
+   *     <div class="rb-c-license__warning">
+   *      text...
+   *     </div>
+   */
+  &__warning {
+  }
+
+  &__notice,
+  &__warning {
+    color: var(--ink-p-red-800);
+  }
+}
+
+/**
+ * Container for multiple license cards.
+ *
+ * Structure:
+ *     <div class="rb-c-licenses">
+ *      <div class="rb-c-license">...</div>
+ *      ...
+ *     </div>
+ */
+.rb-c-licenses {
+  display: flex;
+  flex-direction: column;
+  gap: var(--ink-u-spacing-l);
+}
diff --git a/reviewboard/static/rb/css/ui/page-topbar.less b/reviewboard/static/rb/css/ui/page-topbar.less
index 7df5047e196fb415a30ebdf0e4545575499e586b..30e08bb731292cfee21fcbf8088a1225c505eaab 100644
--- a/reviewboard/static/rb/css/ui/page-topbar.less
+++ b/reviewboard/static/rb/css/ui/page-topbar.less
@@ -259,6 +259,20 @@
     }
   }
 
+  /**
+   * The suffix of the product.
+   *
+   * Structure:
+   *     <span class="rb-c-topbar__product-suffix">
+   *      ...
+   *     </span>
+   */
+  &__product-suffix {
+    color: var(--ink-p-header-fg);
+    font-weight: normal;
+    font-size: 90%;
+  }
+
   /**
    * The version of the product.
    *
diff --git a/reviewboard/static/rb/js/admin/index.ts b/reviewboard/static/rb/js/admin/index.ts
index 0e9fc8853e6ec3d0d7358f69482abd4adb94cff4..bb9d179ab2b5f23731105ee44f57aeb898314736 100644
--- a/reviewboard/static/rb/js/admin/index.ts
+++ b/reviewboard/static/rb/js/admin/index.ts
@@ -1,4 +1,13 @@
+export {
+    type LicenseAttrs,
+    License,
+    LicenseCheckStatus,
+    LicenseStatus,
+} from './models/licenseModel';
+export { CallLicenseActionError } from './models/callLicenseActionError';
+
 export { BaseAdminPageView } from './views/baseAdminPageView';
+export { LicenseView } from './views/licenseView';
 
 
 /* Legacy namespace for RB.Admin. */
diff --git a/reviewboard/static/rb/js/admin/models/callLicenseActionError.ts b/reviewboard/static/rb/js/admin/models/callLicenseActionError.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8e46a7d02fbf3bd56a94eb64a6f528434cc4bbef
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/models/callLicenseActionError.ts
@@ -0,0 +1,74 @@
+/**
+ * Error for license action calls.
+ *
+ * Version Added:
+ *     7.1
+ */
+
+
+/**
+ * Options for CallLicenseActionError.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface CallLicenseActionErrorOptions {
+    /** The action that was called. */
+    action: string;
+
+    /** The target for the action. */
+    actionTarget: string;
+
+    /** An explicit error message to show. */
+    message: string;
+
+    /** The HTTP response from the request, if any. */
+    response: Response | null;
+}
+
+
+/**
+ * Error for license action calls.
+ *
+ * This can be thrown whenever a license action call fails, allowing the
+ * handler to convey the error from the client or server and introspect any
+ * API results.
+ *
+ * Version Added:
+ *     7.1
+ */
+export class CallLicenseActionError extends Error {
+    /**********************
+     * Instance variables *
+     **********************/
+
+    /** The action that was called. */
+    action: string;
+
+    /** The target for the action. */
+    actionTarget: string;
+
+    /** The HTTP response from the request, if any. */
+    response: Response | null;
+
+    /**
+     * Construct a new instance.
+     *
+     * This will store the information from the options and set either the
+     * provided or a default error message.
+     *
+     * Args:
+     *     options (CallLicenseActionErrorOptions):
+     *         Options for the error.
+     */
+    constructor(options: CallLicenseActionErrorOptions) {
+        const action = options.action;
+
+        super(options.message ||
+              `Error performing license action "${action}"`);
+
+        this.action = action;
+        this.actionTarget = options.actionTarget;
+        this.response = options.response || null;
+    }
+}
diff --git a/reviewboard/static/rb/js/admin/models/licenseModel.ts b/reviewboard/static/rb/js/admin/models/licenseModel.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1e6d3da8f058574afff6bc59b9727f6f485096d8
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/models/licenseModel.ts
@@ -0,0 +1,529 @@
+/**
+ * Models for managing Review Board licensing.
+ *
+ * This module provides models and types for handling Review Board's licensing
+ * system, including license status tracking, updates, and management.
+ *
+ * Version Added:
+ *     7.1
+ */
+
+import {
+    BaseModel,
+    spina,
+} from '@beanbag/spina';
+
+import {
+    CallLicenseActionError,
+} from './callLicenseActionError';
+
+
+/**
+ * The current status of a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+export enum LicenseStatus {
+    /** No license is currently active. */
+    UNLICENSED = 'unlicensed',
+
+    /** A valid license is active. */
+    LICENSED = 'licensed',
+
+    /** The license has expired but is in a grace period. */
+    EXPIRED_GRACE_PERIOD = 'expired-grace-period',
+
+    /** The license has expired with no grace period remaining. */
+    HARD_EXPIRED = 'hard-expired',
+}
+
+
+/**
+ * The status of a license check operation.
+ *
+ * Version Added:
+ *     7.1
+ */
+export enum LicenseCheckStatus {
+    /** Checking for an updated license. */
+    CHECKING = 'checking',
+
+    /** The latest license has already been applied. */
+    HAS_LATEST = 'has-latest',
+
+    /** Applying a new license. */
+    APPLYING = 'applying',
+
+    /** A new license has been applied. */
+    APPLIED = 'applied',
+
+    /** There is no accessible license. */
+    NO_LICENSE = 'no-license',
+
+    /** There was an error checking for a new license. */
+    ERROR_CHECKING = 'error-checking',
+
+    /** There was an error applying a new license. */
+    ERROR_APPLYING = 'error-applying',
+}
+
+
+/**
+ * Response payload from checking for license updates.
+ *
+ * This is returned by Review Board.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface CheckUpdatesProcessResponsePayload {
+    /** The current status of the license check. */
+    status: LicenseCheckStatus;
+
+    /** Optional license information to set. */
+    license_info?: Record<string, unknown>;
+}
+
+
+/**
+ * Options for calling an action on the license provider.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface CallActionOptions {
+    /** The name of the action. */
+    action: string;
+
+    /** Encoded arguments for the action. */
+    args?: Record<string, Blob | string>;
+}
+
+
+/**
+ * A displayed action that can be taken on a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface LicenseAction {
+    /** The unique identifier for this action. */
+    actionID: string;
+
+    /** The display label for this action. */
+    label: string;
+
+    /** Extra data provided for the action handler. */
+    extraData?: Record<string, unknown>;
+
+    /** The URL to perform this action. */
+    url?: string;
+}
+
+
+/**
+ * Attributes for a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface LicenseAttrs {
+    /** The target used for any actions invoked on behalf of the license. */
+    actionTarget: string;
+
+    /**
+     * The license identifier.
+     *
+     * This is unique within the license provider.
+     */
+    licenseID: string;
+
+    /** Available actions for this license. */
+    actions?: LicenseAction[] | null;
+
+    /** Whether this license supports manual upload of new license data. */
+    canUploadLicense?: boolean;
+
+    /** The current status for any license checks. */
+    checkStatus?: LicenseCheckStatus;
+
+    /** The date when the license expires or expired. */
+    expiresDate?: Date | null;
+
+    /** Whether the license is about to expire. */
+    expiresSoon?: boolean;
+
+    /** Number of days remaining in the grace period. */
+    gracePeriodDaysRemaining?: number | null,
+
+    /** The date when the license hard expires or expired. */
+    hardExpiresDate?: Date | null,
+
+    /** Whether this is a trial license. */
+    isTrial?: boolean;
+
+    /** The entity this license is assigned to. */
+    licensedTo?: string | null;
+
+    /** A list of line items to display in the license. */
+    lineItems?: string[] | null;
+
+    /** The URL for managing the license on a license portal. */
+    manageURL?: string | null;
+
+    /** A notice to display below the summary. */
+    noticeHTML?: string | null;
+
+    /** The plan identifier. */
+    planID?: string | null;
+
+    /** The name of the plan. */
+    planName?: string | null;
+
+    /** The name of the product. */
+    productName?: string | null;
+
+    /** The current status of the license. */
+    status?: LicenseStatus;
+
+    /** A summary of the license. */
+    summary?: string | null;
+
+    /** Any warning associated with the license. */
+    warningHTML?: string | null;
+}
+
+
+/**
+ * Options for a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface LicenseOptions {
+    /** CSRF token for any actions performed on this license. */
+    actionCSRFToken: string;
+}
+
+
+/**
+ * Model for managing a Review Board license.
+ *
+ * This model handles license status tracking, updates, and management
+ * operations.
+ *
+ * Version Added:
+ *     7.1
+ */
+@spina
+export class License<
+    TAttrs extends LicenseAttrs = LicenseAttrs,
+    TExtraOptions extends LicenseOptions = LicenseOptions,
+    TOptions = Backbone.ModelSetOptions,
+> extends BaseModel<TAttrs, TExtraOptions, TOptions> {
+    static defaults: LicenseAttrs = {
+        actionTarget: null,
+        actions: null,
+        canUploadLicense: false,
+        checkStatus: LicenseCheckStatus.HAS_LATEST,
+        expiresDate: null,
+        expiresSoon: false,
+        gracePeriodDaysRemaining: null,
+        hardExpiresDate: null,
+        isTrial: false,
+        licenseID: null,
+        licensedTo: null,
+        lineItems: null,
+        manageURL: null,
+        noticeHTML: null,
+        planID: null,
+        planName: null,
+        productName: null,
+        status: LicenseStatus.UNLICENSED,
+        summary: null,
+        warningHTML: null,
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    /** CSRF token for any actions performed on this license. */
+    actionCSRFToken: string;
+
+    /**
+     * Initialize the license.
+     *
+     * Args:
+     *     attrs (LicenseAttrs):
+     *         The initial attributes for the license.
+     *
+     *     options (LicenseOptions):
+     *         Options for the license.
+     */
+    initialize(
+        attributes?: LicenseAttrs,
+        options?: Backbone.CombinedModelConstructorOptions<
+            TExtraOptions,
+            this
+        >,
+    ) {
+        this.actionCSRFToken = options?.actionCSRFToken;
+    }
+
+    /**
+     * Upload a new set of license data to the license provider.
+     *
+     * Args:
+     *     contents (Blob):
+     *         The binary contents of the file to upload.
+     *
+     * Returns:
+     *     Promise:
+     *     The promise for the upload request.
+     */
+    async uploadLicenseFile(contents: Blob) {
+        await this.callAction({
+            action: 'upload-license',
+            args: {
+                license_data: contents,
+            },
+        });
+    }
+
+    /**
+     * Call an action in the license provider backend.
+     *
+     * Args:
+     *     options (CallActionOptions):
+     *         Options for the action call.
+     *
+     * Returns:
+     *     Promise:
+     *     The promise for the action call.
+     */
+    async callAction(
+        options: CallActionOptions,
+    ): Promise<unknown> {
+        /*
+         * For this, we're going to use $.ajax instead of RB.apiCall, so
+         * that we don't have to worry about turning things off like the
+         * activity indicator or dealing with anything specific to the
+         * Review Board API.
+         *
+         * Also, let any errors bubble up.
+         */
+        const action = options.action;
+        const actionTarget = this.get('actionTarget');
+
+        const formData = new FormData();
+        formData.append('action', action);
+        formData.append('action_target', actionTarget);
+        formData.append('csrfmiddlewaretoken', this.actionCSRFToken);
+
+        if (options.args) {
+            for (const [key, value] of Object.entries(options.args)) {
+                formData.append(key, value);
+            }
+        }
+
+        let response: Response = null;
+        let rsp;
+
+        try {
+            response = await fetch('.', {
+                body: formData,
+                method: 'POST',
+            });
+
+            rsp = await response.json();
+        } catch (err) {
+            /* Fall through and handle this below. */
+        }
+
+        if (!rsp || !response.ok) {
+            throw new CallLicenseActionError({
+                action: action,
+                actionTarget: actionTarget,
+                message: rsp?.error,
+                response: response,
+            });
+        }
+
+        if (rsp.license_info) {
+            this.set(this.parse(rsp.license_info));
+            this.trigger('licenseUpdated');
+        }
+
+        return rsp;
+    }
+
+    /**
+     * Check for license updates.
+     *
+     * This will start by fetching request data from the license provider's
+     * configured endpoint, passing in the data needed for the request. If
+     * successful, the data will be processed by the license provider backend
+     * in Review Board, returning new attributes .
+     *
+     * The status will be updated throughout the process to reflect the current
+     * state. Listeners can monitor the ``checkStatus`` attribute for changes.
+     * Upon completion, the ``licenseUpdates`` event will be triggered.
+     */
+    async checkForUpdates() {
+        this.set('checkStatus', LicenseCheckStatus.CHECKING);
+
+        /* Fetch a license check request payload. */
+        let checkRsp;
+
+        try {
+            checkRsp = await this.callAction({
+                action: 'license-update-check',
+            });
+        } catch (xhr) {
+            this.set('checkStatus', LicenseCheckStatus.ERROR_CHECKING);
+
+            return;
+        }
+
+        if (!checkRsp.canCheck) {
+            /* There's nothing to do. */
+            this.set('checkStatus', LicenseCheckStatus.HAS_LATEST);
+
+            return;
+        }
+
+        const checkStatusURL = checkRsp.checkStatusURL;
+        const requestData = checkRsp.data;
+
+        let data: (FormData | string) = null;
+
+        if (typeof requestData === 'string') {
+            data = requestData;
+        } else if (requestData) {
+            data = new FormData();
+
+            for (const [key, value] of Object.entries(requestData)) {
+                data.append(key, value as string);
+            }
+        }
+
+        /* Send it to the configured endpoint. */
+        let response: Response;
+        let licenseRsp;
+
+        try {
+            const request: RequestInit = {
+                body: data,
+                method: 'POST',
+            };
+
+            if (checkRsp.credentials) {
+                request.credentials = checkRsp.credentials;
+            }
+
+            if (checkRsp.headers) {
+                request.headers = checkRsp.headers;
+            }
+
+            response = await fetch(checkStatusURL, request);
+
+            licenseRsp = await response.json();
+        } catch (err) {
+            /* Fall through and handle this below. */
+            licenseRsp = null;
+        }
+
+        if (!licenseRsp || !response.ok) {
+            this.onCheckForUpdatesHTTPError(response);
+
+            return;
+        }
+
+        this.#processCheckForUpdatesResponse(licenseRsp, requestData);
+    }
+
+    /**
+     * Handle HTTP errors when checking for updates.
+     *
+     * This will update the status of the license check depending on the error
+     * code we get back.
+     *
+     * Subclasses can override this to provide custom error handling.
+     *
+     * By default, this will set the following values for ``checkStatus``:
+     *
+     * * :js:attr:`LicenseCheckStatus.NO_LICENSE` if the server returns a 403.
+     * * :js:attr:`LicenseCheckStatus.ERROR_CHECKING` for any other error.
+     *
+     * Args:
+     *     rsp (Response):
+     *         The fetch response object.
+     */
+    onCheckForUpdatesHTTPError(response: Response) {
+        if (response.status === 403) {
+            /*
+             * A 403 resposne from a license server indicates that there's
+             * no accessible license.
+             */
+            this.set('checkStatus', LicenseCheckStatus.NO_LICENSE);
+        } else {
+            console.error('Error checking for license %o: response=%o',
+                          this, response);
+
+            this.set('checkStatus', LicenseCheckStatus.ERROR_CHECKING);
+        }
+    }
+
+    /**
+     * Process the response from checking for updates.
+     *
+     * This will pass the request and response payloads to the license
+     * provider on the server, allowing it to handle the data as needed.
+     * The result will be a new set of attributes to set on the license.
+     *
+     * Args:
+     *     checkLicenseData (object):
+     *         The license data received from the server.
+     *
+     *     options (CheckForUpdatesOptions):
+     *         The options used for checking for updates.
+     */
+    async #processCheckForUpdatesResponse(
+        checkLicenseData: object,
+        requestData: object,
+    ) {
+        this.set('checkStatus', LicenseCheckStatus.APPLYING);
+
+        let rsp: CheckUpdatesProcessResponsePayload;
+
+        try {
+            rsp = await this.callAction({
+                action: 'process-license-update',
+                args: {
+                    check_request_data: JSON.stringify(requestData),
+                    check_response_data: JSON.stringify(checkLicenseData),
+                },
+            }) as CheckUpdatesProcessResponsePayload;
+        } catch (xhr) {
+            const responseText = xhr.responseText;
+
+            console.error(
+                'Error processing license %o response %o: rsp=%o, ' +
+                'textStatus=%o',
+                this, checkLicenseData, responseText, xhr.statusText);
+
+            this.set('checkStatus', LicenseCheckStatus.ERROR_APPLYING);
+
+            return;
+        }
+
+        /* The call succeeded. Notify the UI and listeners. */
+        this.set('checkStatus', rsp.status);
+
+        if (rsp.status === LicenseCheckStatus.APPLIED) {
+            this.trigger('licenseUpdated');
+        }
+    }
+}
diff --git a/reviewboard/static/rb/js/admin/models/tests/index.ts b/reviewboard/static/rb/js/admin/models/tests/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..54b02aca62a0cad70eef232a2a25f4814e84d8c9
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/models/tests/index.ts
@@ -0,0 +1 @@
+import './licenseModelTests';
diff --git a/reviewboard/static/rb/js/admin/models/tests/licenseModelTests.ts b/reviewboard/static/rb/js/admin/models/tests/licenseModelTests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8afae3d83b830a83d9c4209a7a6ed4c0356eacb5
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/models/tests/licenseModelTests.ts
@@ -0,0 +1,468 @@
+import { suite } from '@beanbag/jasmine-suites';
+import {
+    beforeEach,
+    describe,
+    expect,
+    expectAsync,
+    it,
+    spyOn,
+} from 'jasmine-core';
+
+import {
+    CallLicenseActionError,
+    License,
+    LicenseCheckStatus,
+} from 'reviewboard/admin';
+
+
+suite('rb/admin/models/License', () => {
+    let model: License;
+
+    beforeEach(() => {
+        model = new License({
+            actionTarget: 'provider1:license1',
+            licenseID: 'license1',
+            productName: 'Test Product',
+            summary: 'License summary',
+        }, {
+            actionCSRFToken: 'abc123',
+        });
+    });
+
+    describe('Methods', () => {
+        describe('callAction', () => {
+            it('With success', async () => {
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['action', 'my-action'],
+                        ['action_target', 'provider1:license1'],
+                        ['csrfmiddlewaretoken', 'abc123'],
+                        ['arg1', 'value1'],
+                        ['arg2', 'value2'],
+                    ]);
+
+                    return {
+                        json: async () => ({
+                            fields: 123,
+                        }),
+                        ok: true,
+                    };
+                });
+
+                const rsp = await model.callAction({
+                    action: 'my-action',
+                    args: {
+                        arg1: 'value1',
+                        arg2: 'value2',
+                    },
+                });
+
+                expect(rsp).toEqual({
+                    fields: 123,
+                });
+
+                expect(fetch).toHaveBeenCalled();
+            });
+
+            it('With success and license_info', async () => {
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['action', 'my-action'],
+                        ['action_target', 'provider1:license1'],
+                        ['csrfmiddlewaretoken', 'abc123'],
+                        ['arg1', 'value1'],
+                        ['arg2', 'value2'],
+                    ]);
+
+                    return {
+                        json: async () => ({
+                            fields: 123,
+                            license_info: {
+                                summary: 'New summary',
+                            },
+                        }),
+                        ok: true,
+                    };
+                });
+
+                spyOn(model, 'trigger').and.callThrough();
+
+                const rsp = await model.callAction({
+                    action: 'my-action',
+                    args: {
+                        arg1: 'value1',
+                        arg2: 'value2',
+                    },
+                });
+
+                expect(rsp).toEqual({
+                    fields: 123,
+                    license_info: {
+                        summary: 'New summary',
+                    },
+                });
+
+                expect(fetch).toHaveBeenCalled();
+
+                expect(model.get('summary')).toBe('New summary');
+                expect(model.trigger).toHaveBeenCalledWith('licenseUpdated');
+            });
+
+            it('With error message', async () => {
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['action', 'my-action'],
+                        ['action_target', 'provider1:license1'],
+                        ['csrfmiddlewaretoken', 'abc123'],
+                    ]);
+
+                    return {
+                        json: async () => ({
+                            error: 'Bad things happened!',
+                        }),
+                        ok: false,
+                    };
+                });
+
+                await expectAsync(model.callAction({
+                    action: 'my-action',
+                })).toBeRejectedWithError(
+                    CallLicenseActionError,
+                    'Bad things happened!',
+                );
+            });
+
+            it('With invalid response payload', async () => {
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['action', 'my-action'],
+                        ['action_target', 'provider1:license1'],
+                        ['csrfmiddlewaretoken', 'abc123'],
+                    ]);
+
+                    return {
+                        json: async () => {
+                            throw new SyntaxError();
+                        },
+                        ok: true,
+                    };
+                });
+
+                await expectAsync(model.callAction({
+                    action: 'my-action',
+                })).toBeRejectedWithError(
+                    CallLicenseActionError,
+                    'Error performing license action "my-action"',
+                );
+            });
+
+            it('With HTTP error response', async () => {
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['action', 'my-action'],
+                        ['action_target', 'provider1:license1'],
+                        ['csrfmiddlewaretoken', 'abc123'],
+                    ]);
+
+                    return {
+                        json: async () => ({}),
+                        ok: false,
+                    };
+                });
+
+                await expectAsync(model.callAction({
+                    action: 'my-action',
+                })).toBeRejectedWithError(
+                    CallLicenseActionError,
+                    'Error performing license action "my-action"',
+                );
+            });
+        });
+
+        describe('checkForUpdates', () => {
+            let seenStatuses: string[];
+
+            beforeEach(() => {
+                seenStatuses = [];
+
+                model.on('change:checkStatus',
+                         (model, value) => seenStatuses.push(value));
+
+                spyOn(model, 'trigger').and.callThrough();
+
+                spyOn(window, 'fetch').and.callFake(async (url, options) => {
+                    expect(url).toBe('https://example.com/check/');
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['key1', 'value1'],
+                        ['key2', 'value2'],
+                    ]);
+                    expect(Object.hasOwn(options, 'credentials')).toBeFalse();
+                    expect(Object.hasOwn(options, 'headers')).toBeFalse();
+
+                    return {
+                        json: async () => ({
+                            resultKey1: 'value1',
+                            resultKey2: 'value2',
+                        }),
+                        ok: true,
+                    };
+                });
+            });
+
+            it('With canCheck=false', async () => {
+                spyOn(model, 'callAction').and.returnValues(
+                    Promise.resolve({
+                        canCheck: false,
+                    }),
+                );
+
+                await model.checkForUpdates();
+
+                expect(model.get('checkStatus'))
+                    .toBe(LicenseCheckStatus.HAS_LATEST);
+
+                expect(seenStatuses).toEqual([
+                    LicenseCheckStatus.CHECKING,
+                    LicenseCheckStatus.HAS_LATEST,
+                ]);
+
+                expect(model.trigger)
+                    .not.toHaveBeenCalledWith('licenseUpdated');
+
+                expect(model.callAction).toHaveBeenCalledOnceWith({
+                    action: 'license-update-check',
+                });
+                expect(fetch).not.toHaveBeenCalled();
+            });
+
+            it('With optional fetch control', async () => {
+                spyOn(model, 'callAction').and.returnValues(
+                    Promise.resolve({
+                        canCheck: true,
+                        checkStatusURL: 'https://example.com/check/',
+                        credentials: {
+                            password: 'ZZ9PZA',
+                            username: 'ford',
+                        },
+                        data: {
+                            key1: 'value1',
+                            key2: 'value2',
+                        },
+                        headers: {
+                            'X-Gimme-License': 'please',
+                        },
+                    }),
+                    Promise.resolve({
+                        status: 'has-latest',
+                    }),
+                );
+
+                fetch.and.callFake(async (url, options) => {
+                    expect(url).toBe('https://example.com/check/');
+                    expect(options.method).toBe('POST');
+                    expect(options.headers).toEqual({
+                        'X-Gimme-License': 'please',
+                    });
+                    expect(options.credentials).toEqual({
+                        password: 'ZZ9PZA',
+                        username: 'ford',
+                    });
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['key1', 'value1'],
+                        ['key2', 'value2'],
+                    ]);
+
+                    return {
+                        json: async () => ({
+                            resultKey1: 'value1',
+                            resultKey2: 'value2',
+                        }),
+                        ok: true,
+                    };
+                });
+
+                await model.checkForUpdates();
+
+                expect(model.get('checkStatus'))
+                    .toBe(LicenseCheckStatus.HAS_LATEST);
+
+                expect(seenStatuses).toEqual([
+                    LicenseCheckStatus.CHECKING,
+                    LicenseCheckStatus.APPLYING,
+                    LicenseCheckStatus.HAS_LATEST,
+                ]);
+
+                expect(model.trigger)
+                    .not.toHaveBeenCalledWith('licenseUpdated');
+
+                expect(model.callAction.calls.argsFor(0)).toEqual([
+                    {
+                        action: 'license-update-check',
+                    },
+                ]);
+                expect(fetch).toHaveBeenCalledTimes(1);
+                expect(model.callAction.calls.argsFor(1)).toEqual([
+                    {
+                        action: 'process-license-update',
+                        args: {
+                            check_request_data:
+                                '{"key1":"value1","key2":"value2"}',
+                            check_response_data:
+                                '{"resultKey1":"value1",' +
+                                '"resultKey2":"value2"}',
+                        },
+                    },
+                ]);
+            });
+
+            it('With no updates', async () => {
+                spyOn(model, 'callAction').and.returnValues(
+                    Promise.resolve({
+                        canCheck: true,
+                        checkStatusURL: 'https://example.com/check/',
+                        data: {
+                            key1: 'value1',
+                            key2: 'value2',
+                        },
+                    }),
+                    Promise.resolve({
+                        status: 'has-latest',
+                    }),
+                );
+
+                await model.checkForUpdates();
+
+                expect(model.get('checkStatus'))
+                    .toBe(LicenseCheckStatus.HAS_LATEST);
+
+                expect(seenStatuses).toEqual([
+                    LicenseCheckStatus.CHECKING,
+                    LicenseCheckStatus.APPLYING,
+                    LicenseCheckStatus.HAS_LATEST,
+                ]);
+
+                expect(model.trigger)
+                    .not.toHaveBeenCalledWith('licenseUpdated');
+
+                expect(model.callAction.calls.argsFor(0)).toEqual([
+                    {
+                        action: 'license-update-check',
+                    },
+                ]);
+                expect(fetch).toHaveBeenCalledTimes(1);
+                expect(model.callAction.calls.argsFor(1)).toEqual([
+                    {
+                        action: 'process-license-update',
+                        args: {
+                            check_request_data:
+                                '{"key1":"value1","key2":"value2"}',
+                            check_response_data:
+                                '{"resultKey1":"value1",' +
+                                '"resultKey2":"value2"}',
+                        },
+                    },
+                ]);
+            });
+
+            it('With update applied', async () => {
+                spyOn(model, 'callAction').and.returnValues(
+                    Promise.resolve({
+                        canCheck: true,
+                        checkStatusURL: 'https://example.com/check/',
+                        data: {
+                            key1: 'value1',
+                            key2: 'value2',
+                        },
+                    }),
+                    Promise.resolve({
+                        status: 'applied',
+                    }),
+                );
+
+                await model.checkForUpdates();
+
+                expect(model.get('checkStatus'))
+                    .toBe(LicenseCheckStatus.APPLIED);
+
+                expect(seenStatuses).toEqual([
+                    LicenseCheckStatus.CHECKING,
+                    LicenseCheckStatus.APPLYING,
+                    LicenseCheckStatus.APPLIED,
+                ]);
+
+                expect(model.trigger).toHaveBeenCalledWith('licenseUpdated');
+
+                expect(model.callAction.calls.argsFor(0)).toEqual([
+                    {
+                        action: 'license-update-check',
+                    },
+                ]);
+                expect(fetch).toHaveBeenCalledTimes(1);
+                expect(model.callAction.calls.argsFor(1)).toEqual([
+                    {
+                        action: 'process-license-update',
+                        args: {
+                            check_request_data:
+                                '{"key1":"value1","key2":"value2"}',
+                            check_response_data:
+                                '{"resultKey1":"value1",' +
+                                '"resultKey2":"value2"}',
+                        },
+                    },
+                ]);
+            });
+
+            it('With HTTP 403 error from license server', async () => {
+                fetch.and.callFake(async (url, options) => {
+                    expect(url).toBe('https://example.com/check/');
+                    expect(options.method).toBe('POST');
+                    expect(Array.from(options.body.entries())).toEqual([
+                        ['key1', 'value1'],
+                        ['key2', 'value2'],
+                    ]);
+
+                    return {
+                        json: async () => ({}),
+                        ok: false,
+                        status: 403,
+                    };
+                });
+
+                spyOn(model, 'callAction').and.returnValues(
+                    Promise.resolve({
+                        canCheck: true,
+                        checkStatusURL: 'https://example.com/check/',
+                        data: {
+                            key1: 'value1',
+                            key2: 'value2',
+                        },
+                    }),
+                );
+
+                await model.checkForUpdates();
+
+                expect(model.get('checkStatus'))
+                    .toBe(LicenseCheckStatus.NO_LICENSE);
+
+                expect(seenStatuses).toEqual([
+                    LicenseCheckStatus.CHECKING,
+                    LicenseCheckStatus.NO_LICENSE,
+                ]);
+
+                expect(model.trigger)
+                    .not.toHaveBeenCalledWith('licenseUpdated');
+
+                expect(model.callAction).toHaveBeenCalledOnceWith({
+                    action: 'license-update-check',
+                });
+                expect(fetch).toHaveBeenCalledTimes(1);
+            });
+        });
+    });
+});
diff --git a/reviewboard/static/rb/js/admin/views/licenseView.ts b/reviewboard/static/rb/js/admin/views/licenseView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..867ab6a68667a97d0065d98397db17ebcbf5849a
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/views/licenseView.ts
@@ -0,0 +1,415 @@
+/**
+ * View for managing a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+
+import {
+    type ButtonView,
+    type CraftedComponent,
+    BaseComponentView,
+    craft,
+    paint,
+    renderInto,
+} from '@beanbag/ink';
+import { spina } from '@beanbag/spina';
+import { dedent } from 'babel-plugin-dedent';
+
+import {
+    type License,
+    type LicenseAction,
+    LicenseCheckStatus,
+    LicenseStatus,
+} from '../models/licenseModel';
+
+
+/**
+ * A mapping of license check statuses to localized description strings.
+ *
+ * Version Added:
+ *     7.1
+ */
+const CHECK_STATUS_TEXT: Record<LicenseCheckStatus, string> = {
+    [LicenseCheckStatus.NO_LICENSE]: _`
+        The product is not licensed.
+    `,
+    [LicenseCheckStatus.CHECKING]: _`
+        Checking for updates...
+    `,
+    [LicenseCheckStatus.HAS_LATEST]: _`
+        Your license is up-to-date.
+    `,
+    [LicenseCheckStatus.APPLYING]: _`
+        Applying license update...
+    `,
+    [LicenseCheckStatus.APPLIED]: _`
+        Your license has been automatically updated.
+    `,
+    [LicenseCheckStatus.ERROR_CHECKING]: _`
+        An error occurred when trying to check for license updates.
+        Please contact support.
+    `,
+    [LicenseCheckStatus.ERROR_APPLYING]: _`
+        An error occurred when trying to apply a new license.
+        Please contact support.
+    `,
+};
+
+
+/**
+ * Options for adding a detail item for a license.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface AddLicenseDetailOptions {
+    /** HTML attributes for the item's container element. */
+    attrs?: Record<string, string>;
+
+    /** Extra class names for the item's container element. */
+    className?: string;
+}
+
+
+/**
+ * Options passed to a license action handler.
+ *
+ * Version Added:
+ *     7.1
+ */
+export interface LicenseActionHandlerOptions {
+    /** The ID of the action. */
+    actionID: string;
+
+    /** The registered information on the action. */
+    actionInfo: LicenseAction;
+
+    /** The button representing the action. */
+    button: ButtonView;
+
+    /** The click event on the button. */
+    event: JQuery.ClickEvent;
+}
+
+
+/**
+ * A view managing the display, state, and actions for a license.
+ *
+ * This shows the state of the license, whether it's active, expired, or
+ * expiring soon. It lists the product, line items, and any specific
+ * detail line items provided by a subclass.
+ *
+ * License views contain buttons that can be used to perform actions on a
+ * license, such as managing a license or uploading new license data.
+ *
+ * Version Added:
+ *     7.1
+ */
+@spina
+export class LicenseView<
+    TModel extends License = License,
+> extends BaseComponentView<TModel> {
+    static className = 'rb-c-license';
+
+    static modelEvents = {
+        'change:checkStatus': '_onCheckStatusChanged',
+        'licenseUpdated': 'render',
+    };
+
+    /**********************
+     * Instance variables *
+     **********************/
+
+    /** The element storing additional details for the license. */
+    #detailsEl: HTMLElement;
+
+    /** The element showing license state. */
+    #stateEl: HTMLElement;
+
+    /**
+     * Add a row of details to the license.
+     *
+     * The content and display of the details is up to the caller.
+     *
+     * Args:
+     *     item (string or Node or Ink.CraftedComponent or Array):
+     *         The item or items to use for the details row.
+     *
+     *     options (AddLicenseDetailOptions, optional):
+     *         Options for the item.
+     */
+    addLicenseDetail(
+        item: string | CraftedComponent | CraftedComponent[],
+        options: AddLicenseDetailOptions = {},
+    ) {
+        const attrs = options.attrs || {};
+        const className = options.className;
+
+        attrs.class = 'rb-c-license__detail';
+
+        if (className) {
+            attrs.class += ` ${className}`;
+        }
+
+        renderInto(this.#detailsEl, paint`
+            <li ...${attrs}>${item}</li>
+        `);
+    }
+
+    /**
+     * Handle rendering of the license.
+     *
+     * This will replace the entire contents of the license with a new
+     * render, showing the current state and details for the license along
+     * with any actions.
+     */
+    protected onRender() {
+        const model = this.model;
+        const actions = model.get('actions') || [];
+        const expiresDate = model.get('expiresDate');
+        const licenseStatus = model.get('status');
+        const lineItems = model.get('lineItems') || [];
+        const manageURL = model.get('manageURL');
+        const noticeHTML = model.get('noticeHTML');
+        const summary = model.get('summary');
+        const warningHTML = model.get('warningHTML');
+
+        /* Begin building the license details. */
+        const detailsNodes = paint<HTMLUListElement>`
+            <ul class="rb-c-license__details"/>
+        `;
+        this.#detailsEl = detailsNodes;
+
+        /* Build the information on the license expiration. */
+        if (licenseStatus !== LicenseStatus.UNLICENSED && expiresDate) {
+            const expiresMoment = moment(expiresDate);
+            const expirationTimestamp = expiresMoment.format();
+            const expirationDate =
+                expiresMoment.format('MMM. D, YYYY, h:mm A');
+            let expiresHTML: string;
+            let expiresIcon: string;
+
+            const datetimeHTML = dedent`
+                <time class="timesince"
+                dateTime="${expirationTimestamp}"></time>
+            `;
+
+            if (licenseStatus === LicenseStatus.LICENSED) {
+                expiresIcon = 'ink-i-info';
+                expiresHTML = _`Expires ${datetimeHTML} on ${expirationDate}`;
+            } else if (licenseStatus === LicenseStatus.HARD_EXPIRED) {
+                expiresIcon = 'ink-i-warning';
+                expiresHTML = _`Expired ${datetimeHTML} on ${expirationDate}`;
+            } else if (licenseStatus === LicenseStatus.EXPIRED_GRACE_PERIOD) {
+                const gracePeriodDaysRemaining =
+                    model.get('gracePeriodDaysRemaining');
+
+                expiresIcon = 'ink-i-warning';
+                expiresHTML = _`
+                    Expired ${datetimeHTML} on
+                    ${expirationDate}. There are ${gracePeriodDaysRemaining}
+                    days left on your grace period.
+                `;
+            } else {
+                expiresIcon = 'ink-i-warning';
+                expiresHTML = _`
+                    Unknown expiration status. Please report this.
+                `;
+            }
+
+            this.addLicenseDetail(paint`
+                <span class="rb-c-license__detail-icon ${expiresIcon}"/>
+                <div class="rb-c-license__detail-content">
+                 ${paint([expiresHTML])}
+                </div>
+            `);
+        }
+
+        for (const lineItem of lineItems) {
+            this.addLicenseDetail(lineItem);
+        }
+
+        /* Begin rendering the view. */
+        const el = this.el;
+
+        el.setAttribute('data-status', licenseStatus);
+
+        if (warningHTML) {
+            el.classList.add('-has-warning');
+        }
+
+        const stateEl = paint<HTMLElement>`
+            <div class="rb-c-license__state"></div>
+        `;
+        this.#stateEl = stateEl;
+        this._onCheckStatusChanged();
+
+        const buildActionButton = this.#buildActionButton.bind(this);
+
+        renderInto(
+            el,
+            paint`
+                <div class="rb-c-license__header">
+                 <h3 class="rb-c-license__summary">${summary}</h3>
+                 ${warningHTML && paint`
+                  <div class="rb-c-license__warning">
+                   ${paint([warningHTML])}
+                  </div>
+                 `}
+                 ${noticeHTML && paint`
+                  <div class="rb-c-license__notice">
+                   ${paint([noticeHTML])}
+                  </div>
+                 `}
+                 ${stateEl}
+                 <div class="rb-c-license__actions">
+                  ${manageURL && paint`
+                   <Ink.Button type="primary" tagName="a" href="${manageURL}">
+                    ${_`Manage your license`}
+                   </Ink.Button>
+                  `}
+                  ${model.get('canUploadLicense') &&
+                    this.#buildUploadLicenseActionButton()}
+                  ${actions.map(actionInfo => buildActionButton(actionInfo))}
+                 </div>
+                 ${detailsNodes}
+                </div>
+            `, {
+                empty: true,
+            });
+
+        this.$('.timesince').timesince();
+    }
+
+    /**
+     * Handle a license action.
+     *
+     * Subclasses can override it to provide handling of any custom actions.
+     *
+     * Args:
+     *     options (LicenseActionHandlerOptions):
+     *         Options for the action invocation.
+     */
+    protected onAction(options: LicenseActionHandlerOptions) {
+        /* This function intentionally left blank. */
+    }
+
+    /**
+     * Build an action button.
+     *
+     * Args:
+     *     actionInfo (LicenseAction):
+     *         Information for the action.
+     *
+     * Returns:
+     *     Ink.ButtonView:
+     *     The resulting action button.
+     */
+    #buildActionButton(
+        actionInfo: LicenseAction,
+    ): ButtonView {
+        const attrs: Record<string, unknown> = {};
+
+        if (actionInfo.url) {
+            attrs.tagName = 'a';
+            attrs.href = actionInfo.url;
+        } else {
+            attrs.onClick = (evt => this.onAction({
+                actionID: actionInfo.actionID,
+                actionInfo: actionInfo,
+                button: actionButton,
+                event: evt,
+            }));
+        }
+
+        const actionButton = craft<ButtonView>`
+            <Ink.Button ...${attrs}>
+             ${actionInfo.label}
+            </Ink.Button>
+        `;
+
+        return actionButton;
+    }
+
+    /**
+     * Build the Upload License action elements.
+     *
+     * This will build the action and upload field, and handle all
+     * interactions and UI updates for the upload process.
+     *
+     * Returns:
+     *     HTMLElement[]:
+     *     The resulting action elements.
+     */
+    #buildUploadLicenseActionButton(): HTMLElement[] {
+        const buttonLabel = _`Upload a new license file`;
+        const fileFieldID = `license-upload-form-field-${this.cid}`;
+
+        function resetButton() {
+            button.busy = false;
+            button.label = buttonLabel;
+        }
+
+        function onClick() {
+            button.busy = true;
+            button.label = _`Selecting license file...`;
+            fileFieldEl.click();
+        }
+
+        const button = craft<ButtonView>`
+            <Ink.Button onClick=${onClick}>
+             ${buttonLabel}
+            </Ink.Button>
+        `;
+
+        const fileFieldEl = paint<HTMLInputElement>`
+            <input id="${fileFieldID}"
+                   name="license_data"
+                   type="file"
+                   style="display: none"/>
+        `;
+        fileFieldEl.addEventListener('cancel', () => resetButton());
+        fileFieldEl.addEventListener('change', async () => {
+            /* Handle the file upload. */
+            const file = fileFieldEl.files[0];
+
+            /*
+             * Just a quick sanity-check before we start uploading content.
+             */
+            console.assert(file.size < 1000000);
+
+            button.label = _`Uploading license file...`;
+
+            try {
+                await this.model.uploadLicenseFile(file);
+            } catch (err) {
+                alert(err.message);
+            }
+
+            resetButton();
+        });
+
+        return paint<HTMLElement[]>`
+            ${fileFieldEl}
+            <label htmlFor="${fileFieldID}">
+             ${button}
+            </label>
+        `;
+    }
+
+    /**
+     * Handle changes to the license status.
+     *
+     * This will update the display of the license and the status text to
+     * reflect the current status.
+     */
+    private _onCheckStatusChanged() {
+        const checkStatus = this.model.get('checkStatus') ||
+                            LicenseCheckStatus.HAS_LATEST;
+
+        this.el.setAttribute('data-check-status', checkStatus);
+
+        this.#stateEl.innerText = CHECK_STATUS_TEXT[checkStatus] || '';
+    }
+}
diff --git a/reviewboard/static/rb/js/admin/views/tests/index.ts b/reviewboard/static/rb/js/admin/views/tests/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d26dc736b8b0c240077c6e69336c0ff6a1df28ba
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/views/tests/index.ts
@@ -0,0 +1 @@
+import './licenseViewTests';
diff --git a/reviewboard/static/rb/js/admin/views/tests/licenseViewTests.ts b/reviewboard/static/rb/js/admin/views/tests/licenseViewTests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a64ab90a5516d1a4bd073e00ff3a174caa6d1da4
--- /dev/null
+++ b/reviewboard/static/rb/js/admin/views/tests/licenseViewTests.ts
@@ -0,0 +1,504 @@
+import { paint } from '@beanbag/ink';
+import { suite } from '@beanbag/jasmine-suites';
+import {
+    afterEach,
+    beforeEach,
+    describe,
+    expect,
+    it,
+    spyOn,
+} from 'jasmine-core';
+
+import {
+    License,
+    LicenseCheckStatus,
+    LicenseStatus,
+    LicenseView,
+} from 'reviewboard/admin';
+
+
+suite('rb/admin/views/LicenseView', () => {
+    beforeEach(() => {
+        spyOn($.fn, 'timesince').and.callFake(function() { return this; });
+    });
+
+    describe('Rendering', () => {
+        afterEach(() => {
+            expect($.fn.timesince).toHaveBeenCalled();
+        });
+
+        it('Unlicensed', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="unlicensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"></div>
+                  <ul class="rb-c-license__details"></ul>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Licensed', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    expiresDate: new Date('2025-04-23T00:00:00'),
+                    gracePeriodDaysRemaining: 4,
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    status: LicenseStatus.LICENSED,
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="licensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"/>
+                  <ul class="rb-c-license__details">
+                   <li class="rb-c-license__detail">
+                    <span class="rb-c-license__detail-icon ink-i-info"/>
+                    <div class="rb-c-license__detail-content">
+                     ${'Expires '}
+                     <time class="timesince"
+                           dateTime="2025-04-23T00:00:00-07:00"/>
+                     ${' on Apr. 23, 2025, 12:00 AM'}
+                    </div>
+                   </li>
+                  </ul>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Expired (grace period)', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    expiresDate: new Date('2025-04-23T00:00:00'),
+                    gracePeriodDaysRemaining: 4,
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    status: LicenseStatus.EXPIRED_GRACE_PERIOD,
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="expired-grace-period"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"/>
+                  <ul class="rb-c-license__details">
+                   <li class="rb-c-license__detail">
+                    <span class="rb-c-license__detail-icon ink-i-warning"/>
+                    <div class="rb-c-license__detail-content">
+                     ${'Expired '}
+                     <time class="timesince"
+                           dateTime="2025-04-23T00:00:00-07:00"/>
+                     ${' on Apr. 23, 2025, 12:00 AM. There are 4 days ' +
+                       'left on your grace period.'}
+                    </div>
+                   </li>
+                  </ul>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Hard-expired', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    expiresDate: new Date('2025-01-01T00:00:00'),
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    status: LicenseStatus.HARD_EXPIRED,
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="hard-expired"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"/>
+                  <ul class="rb-c-license__details">
+                   <li class="rb-c-license__detail">
+                    <span class="rb-c-license__detail-icon ink-i-warning"/>
+                    <div class="rb-c-license__detail-content">
+                     ${'Expired '}
+                     <time class="timesince"
+                           dateTime="2025-01-01T00:00:00-08:00"/>
+                     ${' on Jan. 1, 2025, 12:00 AM'}
+                    </div>
+                   </li>
+                  </ul>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Notice', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    licenseID: 'license1',
+                    noticeHTML: 'Watch out!',
+                    productName: 'Test Product',
+                    status: LicenseStatus.LICENSED,
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="licensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__notice">
+                   Watch out!
+                  </div>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"/>
+                  <ul class="rb-c-license__details"/>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Warning', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    status: LicenseStatus.LICENSED,
+                    summary: 'License summary',
+                    warningHTML: 'Oh no, bad things!',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license -has-warning"
+                     data-status="licensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__warning">
+                   Oh no, bad things!
+                  </div>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"/>
+                  <ul class="rb-c-license__details"/>
+                 </div>
+                </div>
+            `);
+        });
+
+        it('Actions', () => {
+            const view = new LicenseView({
+                model: new License({
+                    actionTarget: 'provider1:license1',
+                    actions: [
+                        {
+                            actionID: 'action1',
+                            label: 'Action 1',
+                        },
+                        {
+                            actionID: 'action2',
+                            label: 'Action 2',
+                            url: 'https://example.com/action2',
+                        },
+                    ],
+                    canUploadLicense: true,
+                    licenseID: 'license1',
+                    manageURL: 'https://example.com/manage/',
+                    productName: 'Test Product',
+                    status: LicenseStatus.LICENSED,
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                }),
+            });
+
+            view.render();
+            const cid = view.cid;
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="licensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions">
+                   <a class="ink-c-button -is-primary"
+                      role="button"
+                      href="https://example.com/manage/">
+                    Manage your license
+                   </a>
+                   <input id="license-upload-form-field-${cid}"
+                          name="license_data"
+                          type="file"
+                          style="display: none;"/>
+                   <label htmlFor="license-upload-form-field-${cid}">
+                    <button class="ink-c-button"
+                            type="button">
+                     Upload a new license file
+                    </button>
+                   </label>
+                   <button class="ink-c-button" type="button">
+                    Action 1
+                   </button>
+                   <a class="ink-c-button"
+                      role="button"
+                      href="https://example.com/action2">
+                    Action 2
+                   </a>
+                  </div>
+                  <ul class="rb-c-license__details"/>
+                 </div>
+                </div>
+            `);
+        });
+    });
+
+    describe('Model Events', () => {
+        describe('change:checkStatus', () => {
+            let model: License;
+            let view: LicenseView;
+
+            beforeEach(() => {
+                model = new License({
+                    actionTarget: 'provider1:license1',
+                    licenseID: 'license1',
+                    productName: 'Test Product',
+                    summary: 'License summary',
+                }, {
+                    actionCSRFToken: 'abc123',
+                });
+                view = new LicenseView({
+                    model: model,
+                });
+
+                view.render();
+            });
+
+            function testCheckStatus(
+                status: LicenseCheckStatus,
+                attrValue: string,
+                expectedText: string,
+            ) {
+                it(status, () => {
+                    model.set('checkStatus', status);
+
+                    expect(view.el).toEqual(paint`
+                        <div class="rb-c-license"
+                             data-status="unlicensed"
+                             data-check-status="${attrValue}">
+                         <div class="rb-c-license__header">
+                          <h3 class="rb-c-license__summary">
+                           License summary
+                          </h3>
+                          <div class="rb-c-license__state">
+                           ${expectedText}
+                          </div>
+                          <div class="rb-c-license__actions"></div>
+                          <ul class="rb-c-license__details"></ul>
+                         </div>
+                        </div>
+                    `);
+                });
+            }
+
+            testCheckStatus(LicenseCheckStatus.NO_LICENSE,
+                            'no-license',
+                            'The product is not licensed.');
+            testCheckStatus(LicenseCheckStatus.CHECKING,
+                            'checking',
+                            'Checking for updates...');
+            testCheckStatus(LicenseCheckStatus.HAS_LATEST,
+                            'has-latest',
+                            'Your license is up-to-date.');
+            testCheckStatus(LicenseCheckStatus.APPLYING,
+                            'applying',
+                            'Applying license update...');
+            testCheckStatus(LicenseCheckStatus.APPLIED,
+                            'applied',
+                            'Your license has been automatically updated.');
+            testCheckStatus(LicenseCheckStatus.ERROR_CHECKING,
+                            'error-checking',
+                            'An error occurred when trying to check for ' +
+                            'license updates. Please contact support.');
+            testCheckStatus(LicenseCheckStatus.ERROR_APPLYING,
+                            'error-applying',
+                            'An error occurred when trying to apply a ' +
+                            'new license. Please contact support.');
+        });
+
+        it('licenseUpdated', () => {
+            const model = new License({
+                actionTarget: 'provider1:license1',
+                licenseID: 'license1',
+                productName: 'Test Product',
+                summary: 'License summary',
+            }, {
+                actionCSRFToken: 'abc123',
+            });
+            const view = new LicenseView({
+                model: model,
+            });
+
+            view.render();
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="unlicensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   License summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions"></div>
+                  <ul class="rb-c-license__details"></ul>
+                 </div>
+                </div>
+            `);
+
+            model.set({
+                actions: [
+                    {
+                        actionID: 'action1',
+                        label: 'Action 1',
+                    },
+                    {
+                        actionID: 'action2',
+                        label: 'Action 2',
+                        url: 'https://example.com/action2',
+                    },
+                ],
+                expiresDate: new Date('2025-04-23T00:00:00'),
+                gracePeriodDaysRemaining: 4,
+                status: LicenseStatus.LICENSED,
+                summary: 'New license summary',
+            });
+            model.trigger('licenseUpdated');
+
+            expect(view.el).toEqual(paint`
+                <div class="rb-c-license"
+                     data-status="licensed"
+                     data-check-status="has-latest">
+                 <div class="rb-c-license__header">
+                  <h3 class="rb-c-license__summary">
+                   New license summary
+                  </h3>
+                  <div class="rb-c-license__state">
+                   Your license is up-to-date.
+                  </div>
+                  <div class="rb-c-license__actions">
+                   <button class="ink-c-button" type="button">
+                    Action 1
+                   </button>
+                   <a class="ink-c-button"
+                      role="button"
+                      href="https://example.com/action2">
+                    Action 2
+                   </a>
+                  </div>
+                  <ul class="rb-c-license__details">
+                   <li class="rb-c-license__detail">
+                    <span class="rb-c-license__detail-icon ink-i-info"/>
+                    <div class="rb-c-license__detail-content">
+                     ${'Expires '}
+                     <time class="timesince"
+                           dateTime="2025-04-23T00:00:00-07:00"/>
+                     ${' on Apr. 23, 2025, 12:00 AM'}
+                    </div>
+                   </li>
+                  </ul>
+                 </div>
+                </div>
+            `);
+        });
+    });
+});
diff --git a/reviewboard/static/rb/js/tests/index.ts b/reviewboard/static/rb/js/tests/index.ts
index 1a2825cada2edd0b6a827e846a0c2f3f8d2356b6..526dadc3936a5b562aaa9f7edd7a9f2404a4096a 100644
--- a/reviewboard/static/rb/js/tests/index.ts
+++ b/reviewboard/static/rb/js/tests/index.ts
@@ -1,3 +1,5 @@
+import '../admin/models/tests';
+import '../admin/views/tests';
 import '../common/models/tests';
 import '../common/resources/collections/tests';
 import '../common/resources/models/tests';
diff --git a/reviewboard/templates/admin/licensing.html b/reviewboard/templates/admin/licensing.html
new file mode 100644
index 0000000000000000000000000000000000000000..49c0e296b81afc321f43fdb71534b2f8e553a2f7
--- /dev/null
+++ b/reviewboard/templates/admin/licensing.html
@@ -0,0 +1,55 @@
+{% extends "admin/base_site.html" %}
+{% load djblets_js i18n %}
+
+
+{% block title %}{% trans "Licenses" %}{% endblock %}
+
+
+{% block content %}
+{%  if license_entries %}
+<div class="rb-c-licenses" id="licenses-list"></div>
+{%  else %}
+ <div class="ink-c-alert" role="status">
+  <div class="ink-c-alert__content">
+   <h3 class="ink-c-alert__heading">
+{%   blocktrans %}
+    No licensed products are installed.
+{%   endblocktrans %}
+   </h3>
+   <div class="ink-c-alert__body">
+    <p>
+{%   blocktrans with url="https://www.reviewboard.org/powerpack/" %}
+     Upgrade Review Board with <a href="{{url}}">Power Pack</a> for Document
+     Review, Reports, User Roles, new source code management integrations, and
+     more.
+{%   endblocktrans %}
+    </p>
+  </div>
+ </div>
+{%  endif %}
+{% endblock content %}
+
+
+{% block scripts-post %}
+{{block.super}}
+{%  if license_entries %}
+<script>
+RB.PageManager.ready(function(page) {
+    const licensesEl = document.getElementById('licenses-list');
+    let license;
+    let licenseView;
+
+{%   for license_entry in license_entries %}
+    license = new {{license_entry.model}}({{license_entry.attrs|json_dumps}}, {
+        actionCSRFToken: "{{csrf_token}}"
+    });
+    licenseView = new {{license_entry.view}}({
+        model: license
+    });
+    licenseView.renderInto(licensesEl);
+    license.checkForUpdates();
+{%   endfor %}
+});
+</script>
+{%  endif %}
+{% endblock scripts-post %}
diff --git a/reviewboard/templates/admin/sidebar.html b/reviewboard/templates/admin/sidebar.html
index 6de551c3d527e9f53c6a909b55a6028a51f1d08e..f2575708372de76d670f0bc2f9a68afac6d1a873 100644
--- a/reviewboard/templates/admin/sidebar.html
+++ b/reviewboard/templates/admin/sidebar.html
@@ -1,9 +1,12 @@
-{% load djblets_extensions djblets_utils i18n log rbadmintags %}
+{% load djblets_extensions djblets_utils features i18n log rbadmintags %}
 
 <li class="rb-c-sidebar__section">
  <header class="rb-c-sidebar__section-header">{% trans "Administration" %}</header>
  <ul class="rb-c-sidebar__items">
 {% admin_subnav "admin-dashboard" _("Dashboard") %}
+{% if_feature_enabled "licensing" %}
+{%  admin_subnav "admin-licenses" _("Licenses") %}
+{% endif_feature_enabled %}
 {% admin_subnav "admin-security-checks" _("Security Center") %}
 {% admin_subnav "extension-list" _("Extensions") %}
 {% admin_subnav "integration-list" _("Integrations") %}
diff --git a/reviewboard/templates/base/branding.html b/reviewboard/templates/base/branding.html
index 1c2114c2f41b14e4a265ae21ab66af9ea70592e4..e2595b642e74ae9af90dce6e7ab4a6ff12182b8e 100644
--- a/reviewboard/templates/base/branding.html
+++ b/reviewboard/templates/base/branding.html
@@ -1,9 +1,11 @@
 {% load djblets_utils i18n static %}
+{% url 'root' as root_url %}
+{% trans 'Home' as home_text %}
 
 {% definevar "logo_png" %}{% static "rb/images/logo.png" %}{% enddefinevar %}
 
 <div class="rb-c-topbar__product-info" id="rbinfo">
- <a href="{% url 'root' %}" aria-label="{% trans 'Home' %}">
+ <a href="{{root_url}}" aria-label="{{home_text}}">
   <img class="rb-c-topbar__product-logo"
        id="logo"
        aria-hidden="true"
@@ -15,8 +17,12 @@
        height="57">
  </a>
  <h1 class="rb-c-topbar__product-name" id="title">
-  <a href="{% url 'root' %}"
-     aria-label="{% trans 'Home' %}">{{PRODUCT_NAME}}</a>
+  <a href="{{root_url}}" aria-label="{{home_text}}">
+   {{PRODUCT_NAME}}
+   <span class="rb-c-topbar__product-suffix">
+    {{settings.PRODUCT_SUFFIX}}
+   </span>
+  </a>
   <span class="rb-c-topbar__product-version">
    {{version}}
    {% if section_title %} - <strong>{{section_title}}</strong>{% endif %}
diff --git a/setup.cfg b/setup.cfg
index 32f0da29e989116ae5eba3bfbb614cb307e02dab..c676c89bdd8ce1db7ded5e9e0588c3fa4a3305b8 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,3 @@
-[aliases]
-release = egg_info --no-date --tag-build=
-
 [egg_info]
 tag_build = .dev
 
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 760e1382e51a81e61f7ae58fa0d9b7efeb39a229..0000000000000000000000000000000000000000
--- a/setup.py
+++ /dev/null
@@ -1,535 +0,0 @@
-#!/usr/bin/env python3
-#
-# Setup script for Review Board.
-#
-# A big thanks to Django project for some of the fixes used in here for
-# MacOS X and data files installation.
-
-import os
-import subprocess
-import sys
-import tempfile
-from distutils.command.install import INSTALL_SCHEMES
-from distutils.core import Command
-from importlib import import_module
-
-from setuptools import setup, find_packages
-from setuptools.command.develop import develop
-from setuptools.command.egg_info import egg_info
-
-import reviewboard
-from reviewboard import get_package_version, VERSION
-from reviewboard.dependencies import (PYTHON_MIN_VERSION,
-                                      PYTHON_MIN_VERSION_STR,
-                                      build_dependency_list,
-                                      package_dependencies,
-                                      package_only_dependencies)
-
-
-is_packaging = ('sdist' in sys.argv or
-                'bdist_egg' in sys.argv or
-                'bdist_wheel' in sys.argv or
-                'install' in sys.argv)
-
-
-# Make sure this is a version of Python we are compatible with. This should
-# prevent people on older versions from unintentionally trying to install
-# the source tarball, and failing.
-pyver = sys.version_info[:2]
-
-if pyver < PYTHON_MIN_VERSION:
-    sys.stderr.write(
-        'Review Board %s is incompatible with your version of Python '
-        '(%s.%s).\n'
-        'Please install an older release of Review Board or upgrade to '
-        'Python %s or newer.\n'
-        % (get_package_version(), pyver[0], pyver[1], PYTHON_MIN_VERSION_STR))
-    sys.exit(1)
-
-
-# NOTE: When updating, make sure you update the classifiers below.
-#
-# Python end-of-life dates (as of June 6, 2024):
-#
-# 3.9: October 31, 2025
-# 3.10: October 31, 2026
-# 3.11: October 31, 2027
-# 3.12: October 31, 2028
-#
-# See https://endoflife.date/python
-SUPPORTED_PYVERS = ['3.9', '3.10', '3.11', '3.12']
-
-
-if '--all-pyvers' in sys.argv:
-    new_argv = sys.argv[1:]
-    new_argv.remove('--all-pyvers')
-
-    for pyver in SUPPORTED_PYVERS:
-        result = os.system(subprocess.list2cmdline(
-            ['python%s' % pyver, __file__] + new_argv))
-
-        if result != 0:
-            sys.exit(result)
-
-    sys.exit(0)
-
-if '--pyvers' in sys.argv:
-    i = sys.argv.index('--pyvers')
-    pyvers = sys.argv[i + 1].split()
-
-    new_argv = sys.argv[1:]
-    del new_argv[i - 1:i + 1]
-
-    for pyver in pyvers:
-        if pyver not in SUPPORTED_PYVERS:
-            sys.stderr.write('Python version %s is not in SUPPORTED_PYVERS'
-                             % pyver)
-            sys.exit(1)
-
-        result = os.system(subprocess.list2cmdline(
-            ['python%s' % pyver, __file__] + new_argv))
-
-        if result != 0:
-            sys.exit(result)
-
-    sys.exit(0)
-
-
-# Make sure we're actually in the directory containing setup.py.
-root_dir = os.path.dirname(__file__)
-
-if root_dir != '':
-    os.chdir(root_dir)
-
-
-# Tell distutils to put the data_files in platform-specific installation
-# locations. See here for an explanation:
-# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb
-for scheme in INSTALL_SCHEMES.values():
-    scheme['data'] = scheme['purelib']
-
-
-if is_packaging:
-    # If we're packaging, include the package-only dependencies.
-    package_dependencies = package_dependencies.copy()
-    package_dependencies.update(package_only_dependencies)
-
-
-class BuildEggInfoCommand(egg_info):
-    """Build the egg information for the package.
-
-    If this is called when building a distribution (source, egg, or wheel),
-    or when installing the package from source, this will kick off tasks for
-    building static media and string localization files.
-    """
-
-    def run(self):
-        """Build the egg information."""
-        if is_packaging:
-            self.run_command('build_media')
-            self.run_command('build_i18n')
-
-        egg_info.run(self)
-
-
-class DevelopCommand(develop):
-    """Installs Review Board in developer mode.
-
-    This will install all standard and development dependencies (using Python
-    wheels and node.js packages from npm) and add the source tree to the
-    Python module search path. That includes updating the versions of pip
-    and setuptools on the system.
-
-    To speed up subsequent runs, callers can pass ``--no-npm`` to prevent
-    installing node.js packages.
-    """
-
-    user_options = develop.user_options + [
-        (str('no-npm'), None, "Don't install packages from npm"),
-        (str('use-npm-cache'), None, 'Use npm-cache to install packages'),
-        (str('with-doc-deps'), None,
-         'Install documentation-related dependencies'),
-    ]
-
-    boolean_options = develop.boolean_options + [
-        str('no-npm'),
-        str('use-npm-cache'),
-        str('with-doc-deps'),
-    ]
-
-    def initialize_options(self):
-        """Initialize options for the command."""
-        develop.initialize_options(self)
-
-        self.no_npm = None
-        self.with_doc_deps = None
-        self.use_npm_cache = None
-
-    def install_for_development(self):
-        """Install the package for development.
-
-        This takes care of the work of installing all dependencies.
-        """
-        if self.no_deps:
-            # In this case, we don't want to install any of the dependencies
-            # below. However, it's really unlikely that a user is going to
-            # want to pass --no-deps.
-            #
-            # Instead, what this really does is give us a way to know we've
-            # been called by `pip install -e .`. That will call us with
-            # --no-deps, as it's going to actually handle all dependency
-            # installation, rather than having easy_install do it.
-            develop.install_for_development(self)
-            return
-
-        # Install the dependencies using pip instead of easy_install. This
-        # will use wheels instead of legacy eggs.
-        #
-        # A couple important things to consider here:
-        #
-        # 1. pip will build in build-isolation mode by default, and we want
-        #    this in order to work around issues that can occur with projects
-        #    that install via source *and* use setuptools-scm to compute the
-        #    version (django-haystack being the notable one here).
-        #
-        # 2. We also want to be able to build against a local Djblets, which
-        #    provides build-time functionality we need. However,
-        #    build-isolation environments won't know about this. So we need
-        #    to re-install Djblets in that environment in editable mode.
-        #
-        #    This may be necessary with other modules later, so we keep this
-        #    somewhat generic.
-        #
-        # 3. For safety, we want to consider all this alongside any
-        #    development libraries, to minimize issues and speed up
-        #    installation.
-        #
-        # The approach taken is to generate a temporary requirements.txt file
-        # and to use it to reference the Djblets editable path (if there is
-        # one) and the *-requirements.txt files we care about.
-        project_dir = os.path.abspath(os.path.dirname(__file__))
-        fd, deps_file = tempfile.mkstemp()
-
-        with open(fd, 'w') as fp:
-            for mod_name in ('djblets',):
-                try:
-                    mod = import_module(mod_name)
-
-                    if not mod.__file__:
-                        continue
-
-                    mod_parent_dir = os.path.abspath(os.path.join(
-                        mod.__file__, '..', '..'))
-
-                    if (os.path.exists(os.path.join(mod_parent_dir,
-                                                    'pyproject.toml')) or
-                        os.path.exists(os.path.join(mod_parent_dir,
-                                                    'setup.py'))):
-                        fp.write('-e %s\n' % mod_parent_dir)
-                except ImportError:
-                    # Skip this. Let pip find it via PyPI.
-                    continue
-
-            fp.write('-e %s\n' % project_dir)
-            fp.write('-r %s\n' % os.path.join(project_dir,
-                                              'dev-requirements.txt'))
-
-            if self.with_doc_deps:
-                fp.write('-r %s\n' % os.path.join(project_dir,
-                                                  'doc-requirements.txt'))
-
-        try:
-            self._run_pip(['install', '-r', deps_file])
-        finally:
-            os.unlink(deps_file)
-
-        if not self.no_npm:
-            # Install node.js dependencies, needed for packaging.
-            if self.use_npm_cache:
-                self.distribution.command_options['install_node_deps'] = {
-                    'use_npm_cache': ('install_node_deps', 1),
-                }
-
-            self.run_command('install_node_deps')
-
-    def _run_pip(self, args):
-        """Run pip.
-
-        Args:
-            args (list):
-                Arguments to pass to :command:`pip`.
-
-        Raises:
-            RuntimeError:
-                The :command:`pip` command returned a non-zero exit code.
-        """
-        cmd = subprocess.list2cmdline([sys.executable, '-m', 'pip'] + args)
-        ret = os.system(cmd)
-
-        if ret != 0:
-            raise RuntimeError('Failed to run `%s`' % cmd)
-
-
-class BuildMediaCommand(Command):
-    """Builds static media files for the package.
-
-    This requires first having the node.js dependencies installed.
-    """
-
-    user_options = []
-
-    def initialize_options(self):
-        """Initialize options for the command.
-
-        This is required, but does not actually do anything.
-        """
-        pass
-
-    def finalize_options(self):
-        """Finalize options for the command.
-
-        This is required, but does not actually do anything.
-        """
-        pass
-
-    def run(self):
-        """Runs the commands to build the static media files.
-
-        Raises:
-            RuntimeError:
-                Static media failed to build.
-        """
-        retcode = subprocess.call([
-            sys.executable, 'contrib/internal/build-media.py'])
-
-        if retcode != 0:
-            raise RuntimeError('Failed to build media files')
-
-
-class BuildI18nCommand(Command):
-    """Builds string localization files."""
-
-    description = 'Compile message catalogs to .mo'
-    user_options = []
-
-    def initialize_options(self):
-        """Initialize options for the command.
-
-        This is required, but does not actually do anything.
-        """
-        pass
-
-    def finalize_options(self):
-        """Finalize options for the command.
-
-        This is required, but does not actually do anything.
-        """
-        pass
-
-    def run(self):
-        """Runs the commands to build the string localization files.
-
-        Raises:
-            RuntimeError:
-                Localization files failed to build.
-        """
-        retcode = subprocess.call([
-            sys.executable, 'contrib/internal/build-i18n.py'])
-
-        if retcode != 0:
-            raise RuntimeError('Failed to build i18n files')
-
-
-class InstallNodeDependenciesCommand(Command):
-    """Install all node.js dependencies from npm.
-
-    If ``--use-npm-cache`` is passed, this will use :command:`npm-cache`
-    to install the packages, which is best for Continuous Integration setups.
-    Otherwise, :command:`npm` is used.
-    """
-
-    description = \
-        'Install the node packages required for building static media.'
-
-    user_options = [
-        (str('use-npm-cache'), None, 'Use npm-cache to install packages'),
-    ]
-
-    boolean_options = [str('use-npm-cache')]
-
-    def initialize_options(self):
-        """Initialize options for the command."""
-        self.use_npm_cache = None
-
-    def finalize_options(self):
-        """Finalize options for the command.
-
-        This is required, but does not actually do anything.
-        """
-        pass
-
-    def run(self):
-        """Run the commands to install packages from npm.
-
-        Raises:
-            RuntimeError:
-                There was an error finding or invoking the package manager.
-        """
-        if self.use_npm_cache:
-            npm_command = 'npm-cache'
-        else:
-            npm_command = 'npm'
-
-        try:
-            subprocess.check_call([npm_command, '--version'])
-        except subprocess.CalledProcessError:
-            raise RuntimeError(
-                'Unable to locate %s in the path, which is needed to '
-                'install dependencies required to build this package.'
-                % npm_command)
-
-        # Set up a .djblets symlink to point to the Djblets package directory.
-        #
-        # This will be used for path resolution in JavaScript tools used for
-        # static media building.
-        npm_workspaces_dir = os.path.join(os.path.dirname(__file__),
-                                          '.npm-workspaces')
-
-        if not os.path.exists(npm_workspaces_dir):
-            os.mkdir(npm_workspaces_dir, 0o755)
-
-        # Clean up legacy symlinks.
-        if os.path.exists('.djblets'):
-            os.unlink('.djblets')
-
-        # Populate the workspaces.
-        import djblets
-
-        for mod in (djblets, reviewboard):
-            symlink_path = os.path.join(npm_workspaces_dir, mod.__name__)
-
-            if os.path.exists(symlink_path):
-                os.unlink(symlink_path)
-
-            os.symlink(os.path.dirname(mod.__file__), symlink_path)
-
-        print('Installing node.js modules...')
-        result = os.system('%s install' % npm_command)
-
-        if result != 0:
-            raise RuntimeError(
-                'One or more node.js modules could not be installed.')
-
-
-def build_entrypoints(prefix, entrypoints):
-    """Build and return a list of entrypoints from a module prefix and list.
-
-    This is a utility function to help with constructing entrypoints to pass
-    to :py:func:`~setuptools.setup`. It takes a module prefix and a condensed
-    list of tuples of entrypoint names and relative module/class paths.
-
-    Args:
-        prefix (unicode):
-            The prefix for each module path.
-
-        entrypoints (list of tuple):
-            A list of tuples of entries for the entrypoints. Each tuple
-            contains an entrypoint name and a relative path to append to the
-            prefix.
-
-    Returns:
-        list of unicode:
-        A list of entrypoint items.
-    """
-    result = []
-
-    for entrypoint_id, rel_class_name in entrypoints:
-        if ':' in rel_class_name:
-            sep = '.'
-        else:
-            sep = ':'
-
-        result.append('%s = %s%s%s' % (entrypoint_id, prefix, sep,
-                                       rel_class_name))
-
-    return result
-
-
-PACKAGE_NAME = 'ReviewBoard'
-
-
-with open('README.rst', 'r') as fp:
-    long_description = fp.read()
-
-
-setup(
-    name=PACKAGE_NAME,
-    version=get_package_version(),
-    license='MIT',
-    description=(
-        'Review Board, a fully-featured web-based code and document '
-        'review tool made with love <3'
-    ),
-    long_description=long_description,
-    long_description_content_type='text/x-rst',
-    author='Beanbag, Inc.',
-    author_email='reviewboard@googlegroups.com',
-    url='https://www.reviewboard.org/',
-    download_url=('https://downloads.reviewboard.org/releases/%s/%s.%s/'
-                  % (PACKAGE_NAME, VERSION[0], VERSION[1])),
-    packages=find_packages(exclude=['tests']),
-    entry_points={
-        'console_scripts': build_entrypoints(
-            'reviewboard.cmdline',
-            [
-                ('rb-site', 'rbsite:main'),
-                ('rbext', 'rbext:main'),
-                ('rbssh', 'rbssh:main'),
-            ]
-        ),
-    },
-    install_requires=build_dependency_list(package_dependencies),
-    extras_require={
-        'elasticsearch1': ['elasticsearch~=1.0'],
-        'elasticsearch2': ['elasticsearch~=2.0'],
-        'elasticsearch5': ['elasticsearch~=5.0'],
-        'elasticsearch7': ['elasticsearch~=7.0'],
-        'extension-packaging': ['setuptools>=74'],
-        'ldap': ['python-ldap>=3.3.1'],
-        'mercurial': ['mercurial'],
-        'mysql': ['mysqlclient>=1.4,<=2.1.999'],
-        'p4': ['p4python'],
-        'postgres': ['psycopg2-binary'],
-        's3': ['django-storages[s3]'],
-        'saml': ['python3-saml'],
-        'subvertpy': ['subvertpy'],
-        'swift': ['django-storage-swift'],
-    },
-    include_package_data=True,
-    zip_safe=False,
-    cmdclass={
-        'develop': DevelopCommand,
-        'egg_info': BuildEggInfoCommand,
-        'build_media': BuildMediaCommand,
-        'build_i18n': BuildI18nCommand,
-        'install_node_deps': InstallNodeDependenciesCommand,
-    },
-    python_requires='>=3.8',
-    classifiers=[
-        'Development Status :: 5 - Production/Stable',
-        'Environment :: Web Environment',
-        'Framework :: Django',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: MIT License',
-        'Natural Language :: English',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.9',
-        'Programming Language :: Python :: 3.10',
-        'Programming Language :: Python :: 3.11',
-        'Programming Language :: Python :: 3.12',
-        'Topic :: Software Development',
-        'Topic :: Software Development :: Quality Assurance',
-    ],
-)
