diff --git a/reviewboard/accounts/models.py b/reviewboard/accounts/models.py
index 103dc8febc71d6fedb66eb218fe6d1ffee672ea5..225b5f8511d8e52930d43283b3f4799fa5282e15 100644
--- a/reviewboard/accounts/models.py
+++ b/reviewboard/accounts/models.py
@@ -16,6 +16,7 @@ from reviewboard.accounts.managers import (ProfileManager,
                                            ReviewRequestVisitManager,
                                            TrophyManager)
 from reviewboard.accounts.trophies import trophies_registry
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.avatars import avatar_services
 from reviewboard.reviews.models import Group, ReviewRequest
 from reviewboard.reviews.signals import (reply_published,
@@ -289,6 +290,24 @@ class Profile(models.Model):
         self.settings.setdefault('avatars', {})['avatar_service_id'] = \
             service.avatar_service_id
 
+    def save(self, *args, **kwargs):
+        """Save the profile to the database.
+
+        The profile will only be saved if the user is not affected by
+        read-only mode.
+
+        Args:
+            *args (tuple):
+                Positional arguments for the superclass method.
+
+            **kwargs (dict):
+                Keyword arguments for the superclass method.
+        """
+        if is_site_read_only_for(self.user):
+            return
+
+        super(Profile, self).save(*args, **kwargs)
+
     class Meta:
         db_table = 'accounts_profile'
         verbose_name = _('Profile')
diff --git a/reviewboard/admin/context_processors.py b/reviewboard/admin/context_processors.py
index 219e530614d2de03398d7e8ffed014791cf3e3ad..bb8fb6338e24f225a42d3c5d3d4cba5f36f75a7a 100644
--- a/reviewboard/admin/context_processors.py
+++ b/reviewboard/admin/context_processors.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from reviewboard import (get_manual_url, get_package_version,
                          get_version_string, is_release, VERSION)
+from reviewboard.admin.read_only import is_site_read_only_for
 
 
 def version(request):
@@ -13,3 +14,19 @@ def version(request):
         'version_raw': VERSION,
         'RB_MANUAL_URL': get_manual_url(),
     }
+
+
+def read_only(request):
+    """Return a dictionary with read only mode information.
+
+    Args:
+        request (django.http.HttpRequest):
+            The HTTP request.
+
+    Returns:
+        dict:
+        Dictionary of read-only status information.
+    """
+    return {
+        'is_read_only': is_site_read_only_for(request.user),
+    }
diff --git a/reviewboard/admin/read_only.py b/reviewboard/admin/read_only.py
index 8c6bb23025253252b7499b19dd7ffcc1be057171..aa8294c6af61033089ce0e12b8397834d77fcb69 100644
--- a/reviewboard/admin/read_only.py
+++ b/reviewboard/admin/read_only.py
@@ -20,6 +20,10 @@ def is_site_read_only_for(user):
         bool:
         A boolean representing whether the site is read-only for a user.
     """
+    if user is None:
+        siteconfig = SiteConfiguration.objects.get_current()
+        return siteconfig.get('site_read_only')
+
     if user.is_superuser:
         return False
 
diff --git a/reviewboard/reviews/default_actions.py b/reviewboard/reviews/default_actions.py
index cda9ef49d67581e4776fc3349e7e550a70b083ba..2ae6ff66831fc14dc878f362e4ead93f89db5015 100644
--- a/reviewboard/reviews/default_actions.py
+++ b/reviewboard/reviews/default_actions.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 from django.utils.translation import ugettext_lazy as _
 
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.reviews.actions import (BaseReviewRequestAction,
                                          BaseReviewRequestMenuAction)
 from reviewboard.reviews.features import general_comments_feature
@@ -18,11 +19,13 @@ class CloseMenuAction(BaseReviewRequestMenuAction):
 
     def should_render(self, context):
         review_request = context['review_request']
+        user = context['request'].user
 
         return (review_request.status == ReviewRequest.PENDING_REVIEW and
-                (context['request'].user.pk == review_request.submitter_id or
+                (user.pk == review_request.submitter_id or
                  (context['perms']['reviews']['can_change_status'] and
-                  review_request.public)))
+                  review_request.public)) and
+                not is_site_read_only_for(user))
 
 
 class SubmitAction(BaseReviewRequestAction):
@@ -32,7 +35,8 @@ class SubmitAction(BaseReviewRequestAction):
     label = _('Submitted')
 
     def should_render(self, context):
-        return context['review_request'].public
+        return (context['review_request'].public and
+                not is_site_read_only_for(context['request'].user))
 
 
 class DiscardAction(BaseReviewRequestAction):
@@ -49,7 +53,8 @@ class DeleteAction(BaseReviewRequestAction):
     label = _('Delete Permanently')
 
     def should_render(self, context):
-        return context['perms']['reviews']['delete_reviewrequest']
+        return (context['perms']['reviews']['delete_reviewrequest'] and
+                not is_site_read_only_for(context['request'].user))
 
 
 class UpdateMenuAction(BaseReviewRequestMenuAction):
@@ -60,10 +65,12 @@ class UpdateMenuAction(BaseReviewRequestMenuAction):
 
     def should_render(self, context):
         review_request = context['review_request']
+        user = context['request'].user
 
         return (review_request.status == ReviewRequest.PENDING_REVIEW and
-                (context['request'].user.pk == review_request.submitter_id or
-                 context['perms']['reviews']['can_edit_reviewrequest']))
+                (user.pk == review_request.submitter_id or
+                 context['perms']['reviews']['can_edit_reviewrequest']) and
+                not is_site_read_only_for(user))
 
 
 class UploadDiffAction(BaseReviewRequestAction):
@@ -106,7 +113,8 @@ class UploadDiffAction(BaseReviewRequestAction):
         Returns:
             bool: Determines if this action should render.
         """
-        return context['review_request'].repository_id is not None
+        return (context['review_request'].repository_id is not None and
+                not is_site_read_only_for(context['request'].user))
 
 
 class UploadFileAction(BaseReviewRequestAction):
@@ -115,6 +123,9 @@ class UploadFileAction(BaseReviewRequestAction):
     action_id = 'upload-file-action'
     label = _('Add File')
 
+    def should_render(self, context):
+        return not is_site_read_only_for(context['request'].user)
+
 
 class DownloadDiffAction(BaseReviewRequestAction):
     """An action for downloading a diff from the review request."""
@@ -190,7 +201,9 @@ class EditReviewAction(BaseReviewRequestAction):
     label = _('Review')
 
     def should_render(self, context):
-        return context['request'].user.is_authenticated()
+        user = context['request'].user
+        return (user.is_authenticated() and
+                not is_site_read_only_for(user))
 
 
 class AddGeneralCommentAction(BaseReviewRequestAction):
@@ -202,7 +215,8 @@ class AddGeneralCommentAction(BaseReviewRequestAction):
     def should_render(self, context):
         request = context['request']
         return (request.user.is_authenticated() and
-                general_comments_feature.is_enabled(request=request))
+                general_comments_feature.is_enabled(request=request) and
+                not is_site_read_only_for(request.user))
 
 
 class ShipItAction(BaseReviewRequestAction):
@@ -212,7 +226,9 @@ class ShipItAction(BaseReviewRequestAction):
     label = _('Ship It!')
 
     def should_render(self, context):
-        return context['request'].user.is_authenticated()
+        user = context['request'].user
+        return (user.is_authenticated() and
+                not is_site_read_only_for(user))
 
 
 def get_default_actions():
diff --git a/reviewboard/reviews/models/base_comment.py b/reviewboard/reviews/models/base_comment.py
index 0e5866e89daa1ae81992fb4e297289433d5d3a9a..5e8a7f8fc6740f0421434741952918693222c767 100644
--- a/reviewboard/reviews/models/base_comment.py
+++ b/reviewboard/reviews/models/base_comment.py
@@ -9,6 +9,8 @@ from django.utils.translation import ugettext_lazy as _
 from djblets.db.fields import CounterField, JSONField
 from djblets.db.managers import ConcurrencyManager
 
+from reviewboard.admin.read_only import is_site_read_only_for
+
 
 @python_2_unicode_compatible
 class BaseComment(models.Model):
@@ -122,8 +124,9 @@ class BaseComment(models.Model):
         if not (user and user.is_authenticated()):
             return False
 
-        return (self.get_review_request().is_mutable_by(user) or
-                user == self.get_review().user)
+        return ((self.get_review_request().is_mutable_by(user) or
+                 user == self.get_review().user) and
+                not is_site_read_only_for(user))
 
     def save(self, **kwargs):
         from reviewboard.reviews.models.review_request import ReviewRequest
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index c3b8e10422521aa69c87945d440daaa14bff6a8d..64f8627ade2b02e284c6fe76d3b128ecf493543e 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -13,6 +13,7 @@ from djblets.cache.backend import make_cache_key
 from djblets.db.fields import CounterField, ModificationTimestampField
 from djblets.db.query import get_object_or_none
 
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.attachments.models import (FileAttachment,
                                             FileAttachmentHistory)
 from reviewboard.changedescs.models import ChangeDescription
@@ -497,15 +498,36 @@ class ReviewRequest(BaseReviewRequestDetails):
         return False
 
     def is_mutable_by(self, user):
-        """Returns whether the user can modify this review request."""
-        return (self.submitter == user or
-                user.has_perm('reviews.can_edit_reviewrequest',
-                              self.local_site))
+        """Returns whether the user can modify this review request.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user to check review request mutability with.
+
+        Returns:
+            bool:
+            Whether the user can modify this review request.
+        """
+        return ((self.submitter == user or
+                 user.has_perm('reviews.can_edit_reviewrequest',
+                               self.local_site)) and
+                not is_site_read_only_for(user))
 
     def is_status_mutable_by(self, user):
-        """Returns whether the user can modify this review request's status."""
-        return (self.submitter == user or
-                user.has_perm('reviews.can_change_status', self.local_site))
+        """Returns whether the user can modify this review request's status.
+
+        Args:
+            user (django.contrib.auth.models.User):
+                The user to check review request status mutability with.
+
+        Returns:
+            bool:
+            Whether the user can modify this review request's status.
+        """
+        return ((self.submitter == user or
+                 user.has_perm('reviews.can_change_status',
+                               self.local_site)) and
+                not is_site_read_only_for(user))
 
     def is_deletable_by(self, user):
         """Returns whether the user can delete this review request."""
diff --git a/reviewboard/reviews/templatetags/reviewtags.py b/reviewboard/reviews/templatetags/reviewtags.py
index 60899c03b04a0c83fd2e1660242a1a3922012103..9736f9fbbba9b52996cddde353283538d433b1f9 100644
--- a/reviewboard/reviews/templatetags/reviewtags.py
+++ b/reviewboard/reviews/templatetags/reviewtags.py
@@ -18,6 +18,7 @@ from djblets.util.humanize import humanize_list
 
 from reviewboard.accounts.models import Profile, Trophy
 from reviewboard.accounts.trophies import UnknownTrophy
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.diffviewer.diffutils import get_displayed_diff_line_ranges
 from reviewboard.reviews.actions import get_top_level_actions
 from reviewboard.reviews.fields import (get_review_request_fieldset,
@@ -222,14 +223,43 @@ def reply_list(context, review, comment, context_type, context_id):
                         takes_context=True)
 def reply_section(context, review, comment, context_type, context_id,
                   reply_to_text=''):
-    """
-    Renders a template for displaying a reply.
+    """Renders a template for displaying a reply.
 
     This takes the same parameters as :tag:`reply_list`. The template
     rendered by this function, :template:`reviews/review_reply_section.html`,
     is responsible for invoking :tag:`reply_list` and as such passes these
     variables through. It does not make use of them itself.
+
+    Args:
+        context (django.template.Context):
+            The collection of key-value pairs available in the template.
+
+        review (reviewboard.reviews.models.Review):
+            The review that the reply is for.
+
+        comment (reviewboard.reviews.models.BaseComment):
+            The comment that the reply is for.
+
+        context_type (unicode):
+            Where the comment is from. One of ``"diff_comments"``,
+            ``"screenshot_comments"``, ``"general_comments"`` or
+            ``"file_attachment_comments"`` if the reply is to a comment,
+            and ``"body_top"`` or ``"body_bottom"`` if the reply is to a
+            review.
+
+        context_id (unicode):
+            A parameter hat has to do with the internal IDs used by the
+            JavaScript code for storing and categorizing the comments.
+
+        reply_to_text (unicode):
+            The text in the review or comment that the reply is for.
+
+    Returns:
+        dict:
+        A dictionary containing the context for rendering the reply template.
     """
+    user = context['user']
+
     if comment != "":
         if type(comment) is ScreenshotComment:
             context_id += 's'
@@ -245,8 +275,9 @@ def reply_section(context, review, comment, context_type, context_id,
         'comment': comment,
         'context_type': context_type,
         'context_id': context_id,
-        'user': context.get('user', None),
+        'user': user,
         'local_site_name': context.get('local_site_name'),
+        'read_only': is_site_read_only_for(user),
         'reply_to_is_empty': reply_to_text == '',
         'request': context['request'],
     }
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index 0fd3d96f63f99eace1c01542ebc9cc9f67752ed7..44ded2228218aa8c5c3cd244f3bd807301cf8a17 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -34,6 +34,7 @@ from djblets.util.http import (encode_etag, set_last_modified,
 from reviewboard.accounts.decorators import (check_login_required,
                                              valid_prefs_required)
 from reviewboard.accounts.models import ReviewRequestVisit, Profile
+from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.attachments.models import (FileAttachment,
                                             get_latest_file_attachments)
 from reviewboard.avatars import avatar_services
@@ -394,11 +395,12 @@ def review_detail(request,
 
     # Find out if we can bail early. Generate an ETag for this.
     etag = encode_etag(
-       '%s:%s:%s:%s:%s:%s:%s:%s:%s:%s' %
+       '%s:%s:%s:%s:%s:%s:%s:%s:%s:%s:%s' %
        (request.user, etag_timestamp, draft_timestamp,
         data.latest_review_timestamp,
         review_request.last_review_activity_timestamp,
         is_rich_text_default_for_user(request.user),
+        is_site_read_only_for(request.user),
         [r.pk for r in blocks],
         starred, visited and visited.visibility, settings.AJAX_SERIAL))
 
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index d74cbcc17280d0f181e6758c5b154c961ab93105..3776ba2040e89dfcdfe9376d2e46642d57400111 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -305,6 +305,7 @@ TEMPLATE_CONTEXT_PROCESSORS = [
     'djblets.urls.context_processors.site_root',
     'reviewboard.accounts.context_processors.auth_backends',
     'reviewboard.accounts.context_processors.profile',
+    'reviewboard.admin.context_processors.read_only',
     'reviewboard.admin.context_processors.version',
     'reviewboard.site.context_processors.localsite',
 ]
diff --git a/reviewboard/static/rb/js/models/commentEditorModel.js b/reviewboard/static/rb/js/models/commentEditorModel.js
index 2138c1834a61e28d6034c776e9dc8d6aecc11274..a301c983897bd33d8c629f04cfcc2b457e38c6c1 100644
--- a/reviewboard/static/rb/js/models/commentEditorModel.js
+++ b/reviewboard/static/rb/js/models/commentEditorModel.js
@@ -263,7 +263,8 @@ RB.CommentEditor = Backbone.Model.extend(_.defaults({
 
         this.set('canEdit',
                  userSession.get('authenticated') &&
-                 !reviewRequest.get('hasDraft'));
+                 !reviewRequest.get('hasDraft') &&
+                 !userSession.get('readOnly'));
     },
 
     /*
diff --git a/reviewboard/static/rb/js/models/reviewRequestEditorModel.js b/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
index abfbc4fbd32d6d0504170893c30d2c61d5a566fa..e3cabefcaed46516873b0fdedce96f690e562ed0 100644
--- a/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
+++ b/reviewboard/static/rb/js/models/reviewRequestEditorModel.js
@@ -173,48 +173,58 @@ RB.ReviewRequestEditor = Backbone.Model.extend({
 
                 if (_.isFunction(options.error)) {
                     rsp = xhr.errorPayload;
-                    fieldValue = rsp.fields[jsonFieldName];
-                    fieldValueLen = fieldValue.length;
-
-                    /* Wrap each term in quotes or a leading 'and'. */
-                    _.each(fieldValue, function(value, i) {
-                        if (i === fieldValueLen - 1 && fieldValueLen > 1) {
-                            if (i > 2) {
-                                message += ', ';
-                            }
 
-                            message += " and '" + value + "'";
-                        } else {
-                            if (i > 0) {
-                                message += ', ';
+                    /*
+                     * An error can be caused by a 503 when the site is in
+                     * read-only mode, in which case the the fields will be
+                     * empty.
+                     */
+                    if (rsp.fields === undefined) {
+                        message = xhr.errorText;
+                    } else {
+                        fieldValue = rsp.fields[jsonFieldName];
+                        fieldValueLen = fieldValue.length;
+
+                        /* Wrap each term in quotes or a leading 'and'. */
+                        _.each(fieldValue, function(value, i) {
+                            if (i === fieldValueLen - 1 && fieldValueLen > 1) {
+                                if (i > 2) {
+                                    message += ', ';
+                                }
+
+                                message += " and '" + value + "'";
+                            } else {
+                                if (i > 0) {
+                                    message += ', ';
+                                }
+
+                                message += "'" + value + "'";
                             }
-
-                            message += "'" + value + "'";
+                        });
+
+                        if (fieldName === "targetGroups") {
+                            message = interpolate(
+                                ngettext('Group %s does not exist.',
+                                         'Groups %s do not exist.',
+                                         fieldValue.length),
+                                [message]);
+                        } else if (fieldName === "targetPeople") {
+                            message = interpolate(
+                                ngettext('User %s does not exist.',
+                                         'Users %s do not exist.',
+                                         fieldValue.length),
+                                [message]);
+                        } else if (fieldName === 'submitter') {
+                            message = interpolate(
+                                gettext('User %s does not exist.'),
+                                [message]);
+                        } else if (fieldName === "dependsOn") {
+                            message = interpolate(
+                                ngettext('Review Request %s does not exist.',
+                                         'Review Requests %s do not exist.',
+                                         fieldValue.length),
+                                [message]);
                         }
-                    });
-
-                    if (fieldName === "targetGroups") {
-                        message = interpolate(
-                            ngettext('Group %s does not exist.',
-                                     'Groups %s do not exist.',
-                                     fieldValue.length),
-                            [message]);
-                    } else if (fieldName === "targetPeople") {
-                        message = interpolate(
-                            ngettext('User %s does not exist.',
-                                     'Users %s do not exist.',
-                                     fieldValue.length),
-                            [message]);
-                    } else if (fieldName === 'submitter') {
-                        message = interpolate(
-                            gettext('User %s does not exist.'),
-                            [message]);
-                    } else if (fieldName === "dependsOn") {
-                        message = interpolate(
-                            ngettext('Review Request %s does not exist.',
-                                     'Review Requests %s do not exist.',
-                                     fieldValue.length),
-                            [message]);
                     }
 
                     options.error.call(context, {
diff --git a/reviewboard/static/rb/js/models/userSessionModel.js b/reviewboard/static/rb/js/models/userSessionModel.js
index 50dc705326751032a0def2eeb9d3cd5cf3f21fe3..6f025154ba48b0349fadea3d9dc98cdef18c2aea 100644
--- a/reviewboard/static/rb/js/models/userSessionModel.js
+++ b/reviewboard/static/rb/js/models/userSessionModel.js
@@ -125,6 +125,7 @@ RB.UserSession = Backbone.Model.extend({
         diffsShowExtraWhitespace: false,
         fullName: null,
         loginURL: null,
+        readOnly: false,
         username: null,
         userPageURL: null,
         sessionURL: null,
diff --git a/reviewboard/static/rb/js/pages/views/dashboardView.js b/reviewboard/static/rb/js/pages/views/dashboardView.js
index 8660ff3498b78ff5a2320eb8ccccc0748018c9d8..2d4a716b36ba5adc79dabb9113ad6ae98fe48a95 100644
--- a/reviewboard/static/rb/js/pages/views/dashboardView.js
+++ b/reviewboard/static/rb/js/pages/views/dashboardView.js
@@ -13,17 +13,19 @@ var DashboardActionsView = Backbone.View.extend({
         '<div class="datagrid-actions-content">',
         ' <p class="count"></p>',
         ' <ul>',
+        '<% if (!read_only) { %>',
         '  <li><a class="discard" href="#"><%= close_discarded_text %></a></li>',
         '  <li><a class="submit" href="#"><%= close_submitted_text %></a></li>',
         '  <li>&nbsp;</li>',
         '  <li><a class="archive" href="#"><%= archive_text %></a></li>',
-        '<% if (show_archived) { %>',
+        '<%  if (show_archived) { %>',
         '  <li><a class="unarchive" href="#"><%= unarchive_text %></a></li>',
-        '<% } %>',
+        '<%  } %>',
         '  <li>&nbsp;</li>',
         '  <li><a class="mute" href="#"><%= mute_text %></a></li>',
-        '<% if (show_archived) { %>',
+        '<%  if (show_archived) { %>',
         '  <li><a class="unmute" href="#"><%= unmute_text %></a></li>',
+        '<%  } %>',
         '<% } %>',
         ' </ul>',
         '</div>'
@@ -59,6 +61,7 @@ var DashboardActionsView = Backbone.View.extend({
                 close_submitted_text: gettext('<b>Close</b> Submitted'),
                 archive_text: gettext('<b>Archive</b>'),
                 mute_text: gettext('<b>Mute</b>'),
+                read_only: RB.UserSession.instance.get('readOnly'),
                 unarchive_text: gettext('<b>Unarchive</b>'),
                 unmute_text: gettext('<b>Unmute</b>'),
                 show_archived: show_archived
diff --git a/reviewboard/static/rb/js/utils/apiUtils.es6.js b/reviewboard/static/rb/js/utils/apiUtils.es6.js
index 1e01282128b633bd49bf29aca3d432d1bc99700f..53f63f3025bdedbf3ac131d4710345561dcc0373 100644
--- a/reviewboard/static/rb/js/utils/apiUtils.es6.js
+++ b/reviewboard/static/rb/js/utils/apiUtils.es6.js
@@ -197,6 +197,12 @@ RB.apiCall = function(options) {
 
     options.type = options.type || 'POST';
 
+    if (options.type !== 'GET' && RB.UserSession.instance.get('readOnly')) {
+        console.error('%s request not sent. Site is in read-only mode.',
+                      options.type);
+        return;
+    }
+
     // We allow disabling the function queue for the sake of unit tests.
     if (RB.ajaxOptions.enableQueuing && options.type !== 'GET') {
         $.funcQueue('rbapicall').add(doCall);
diff --git a/reviewboard/static/rb/js/views/commentDialogView.js b/reviewboard/static/rb/js/views/commentDialogView.js
index 5713f8ef035a5f0f2d95896e2dbb0154cc9a9b02..b15f00ac0e33d5fea9008cb0fe47721e65068e11 100644
--- a/reviewboard/static/rb/js/views/commentDialogView.js
+++ b/reviewboard/static/rb/js/views/commentDialogView.js
@@ -120,6 +120,8 @@ RB.CommentDialogView = Backbone.View.extend({
         ' </p>',
         '<% } else if (hasDraft) { %>',
         ' <p class="draft-warning"><%= draftWarning %></p>',
+        '<% } else if (readOnly) { %>',
+        ' <p class="read-only-text"><%= readOnlyText %></p>',
         '<% } %>',
         ' <div class="comment-dlg-body">',
         '  <div class="comment-text-field"></div>',
@@ -170,6 +172,7 @@ RB.CommentDialogView = Backbone.View.extend({
             .html(this.template({
                 authenticated: userSession.get('authenticated'),
                 hasDraft: reviewRequest.get('hasDraft'),
+                readOnly: userSession.get('readOnly'),
                 markdownDocsURL: MANUAL_URL + 'users/markdown/',
                 markdownText: gettext('Markdown'),
                 otherReviewsText: gettext('Other reviews'),
@@ -181,6 +184,7 @@ RB.CommentDialogView = Backbone.View.extend({
                     [reviewRequest.get('reviewURL')]),
                 openAnIssueText: gettext('Open an <u>I</u>ssue'),
                 enableMarkdownText: gettext('Enable <u>M</u>arkdown'),
+                readOnlyText: gettext('Review Board is currently in read-only mode.'),
                 saveButton: gettext('Save'),
                 cancelButton: gettext('Cancel'),
                 deleteButton: gettext('Delete'),
diff --git a/reviewboard/static/rb/js/views/reviewRequestEditorView.js b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
index 22320888674431253cb180eeceb3937b9322ff59..738c27454baf6edd51922ce530284b3728fc8230 100644
--- a/reviewboard/static/rb/js/views/reviewRequestEditorView.js
+++ b/reviewboard/static/rb/js/views/reviewRequestEditorView.js
@@ -1,7 +1,8 @@
 (function() {
 
 
-var BannerView,
+var readOnly = RB.UserSession.instance.get('readOnly'),
+    BannerView,
     ClosedBannerView,
     DiscardedBannerView,
     DraftBannerView,
@@ -19,7 +20,8 @@ BannerView = Backbone.View.extend({
     title: '',
     subtitle: '',
     actions: [],
-    showChangesField: true,
+    showActions: !readOnly,
+    showChangesField: !readOnly,
     describeText: '',
     fieldOptions: {},
     descriptionFieldID: 'changedescription',
@@ -92,7 +94,8 @@ BannerView = Backbone.View.extend({
             this.$el.html(this.template({
                 title: this.title,
                 subtitle: this.subtitle,
-                actions: this.actions,
+                actions: (this.showActions ? this.actions : []),
+                showActions: this.showActions,
                 showChangesField: this.showChangesField,
                 describeText: this.describeText,
                 descriptionFieldHTML: this.descriptionFieldHTML,
@@ -1754,6 +1757,10 @@ RB.ReviewRequestEditorView = Backbone.View.extend({
 
         this.$('#hide-review-request-link')
             .html('<span class="rb-icon ' + iconClass + '"></span>');
+
+        if (readOnly) {
+            this.$('#hide-review-request-menu').hide();
+        }
     },
 
     /*
diff --git a/reviewboard/static/rb/js/views/starManagerView.js b/reviewboard/static/rb/js/views/starManagerView.js
index b33c13cd5b4761f9dbde320fd2b56a638cd6a67f..8fec4f68cf1a1e87c6f6a30f09d8b93cbc6da6ba 100644
--- a/reviewboard/static/rb/js/views/starManagerView.js
+++ b/reviewboard/static/rb/js/views/starManagerView.js
@@ -132,6 +132,10 @@ RB.StarManagerView = Backbone.View.extend({
         e.preventDefault();
         e.stopPropagation();
 
+        if (RB.UserSession.instance.get('readOnly')) {
+            return;
+        }
+
         obj.setStarred(objStarred);
         starred[objID] = objStarred;
 
diff --git a/reviewboard/templates/base.html b/reviewboard/templates/base.html
index 8656c57749933996e661904c4024a3894a9218c3..700a3d1144f03c720f969bcdddc96139ac8bf89e 100644
--- a/reviewboard/templates/base.html
+++ b/reviewboard/templates/base.html
@@ -116,6 +116,7 @@
         defaultUseRichText: {{user_profile.should_use_rich_text|yesno:"true,false"}},
 {%  endif %}
         fullName: "{{request.user|user_displayname|escapejs}}",
+        readOnly: {{ is_read_only|yesno:'true,false' }},
 {% if siteconfig_settings.avatars_enabled %}
         avatarURLs: {
             32: {% avatar_urls request.user 32 %}
diff --git a/reviewboard/templates/base/_nav_support_menu.html b/reviewboard/templates/base/_nav_support_menu.html
index 8be58800700d856cfdadb29c92a373143c181c2b..7812ca68e74608e634cc52b736aff3c278086018 100644
--- a/reviewboard/templates/base/_nav_support_menu.html
+++ b/reviewboard/templates/base/_nav_support_menu.html
@@ -18,7 +18,9 @@
    </a>
 {%  endspaceless %}
    <ul>
+{%  if not is_read_only %}
     <li><a href="{% url 'user-preferences' %}">{% trans "My account" %}</a></li>
+{%  endif %}
 {%  if request.user.is_staff %}
     <li><a href="{% url 'reviewboard.admin.views.dashboard' %}">{% trans "Admin" %}</a></li>
 {%  endif %}
diff --git a/reviewboard/templates/base/navbar.html b/reviewboard/templates/base/navbar.html
index 09a1fdb017622c39f0998d96f8e0f19eacaf1814..879665654f2d15e0b519af85f864431418a7d4d3 100644
--- a/reviewboard/templates/base/navbar.html
+++ b/reviewboard/templates/base/navbar.html
@@ -4,7 +4,9 @@
 {% if request.user.is_authenticated or not siteconfig_settings.auth_require_sitewide_login %}
 {%  if request.user.is_authenticated %}
  <li><a href="{% url 'dashboard' %}">{% trans "My Dashboard" %}</a></li>
+{%   if not is_read_only %}
  <li><a href="{% url 'new-review-request' %}">{% trans "New Review Request" %}</a></li>
+{%   endif %}
 {%  endif %}
  <li><a href="{% url 'all-review-requests' %}">{% trans "All Review Requests" %}</a></li>
  <li><a href="{% url 'all-users' %}">{% trans "Users" %}</a></li>
diff --git a/reviewboard/templates/reviews/review_reply_section.html b/reviewboard/templates/reviews/review_reply_section.html
index 44c5b91e0f1721a8476e396326b6189bb43723b9..839af01c9af201e8a32ae4f4e87a03128be81312 100644
--- a/reviewboard/templates/reviews/review_reply_section.html
+++ b/reviewboard/templates/reviews/review_reply_section.html
@@ -7,7 +7,7 @@
  <ol class="reply-comments">
   {% reply_list review comment context_type context_id %}
  </ol>
-{% if user.is_authenticated %}
+{% if user.is_authenticated and not read_only %}
  <ul class="controls">
   <li>
    <a href="#" class="add_comment_link">
