diff --git a/reviewboard/accounts/forms/pages.py b/reviewboard/accounts/forms/pages.py
index 5db01349ee51911202d78f838b7ab7263ff3e54c..c0eb332c256d4b2d91b7c8feff6638ca90e035a0 100644
--- a/reviewboard/accounts/forms/pages.py
+++ b/reviewboard/accounts/forms/pages.py
@@ -75,6 +75,56 @@ class AccountSettingsForm(AccountPageForm):
                              _('Your settings have been saved.'))
 
 
+class APITokensForm(AccountPageForm):
+    """Form for showing a user's API tokens."""
+    form_id = 'api_tokens'
+    form_title = _('API Tokens')
+    save_label = None
+
+    js_view_class = 'RB.APITokensView'
+
+    def get_js_view_data(self):
+        # Fetch the list of the user's API tokens, globally.
+        api_tokens = self.user.webapi_tokens.all()
+
+        # Group the API tokens by LocalSite or the global site.
+        serialized_api_tokens = SortedDict()
+        serialized_api_tokens[''] = \
+            self._serialize_api_tokens(None, api_tokens)
+
+        for local_site in self.page.config_view.ordered_user_local_sites:
+            serialized_api_tokens[local_site.name] = \
+                self._serialize_api_tokens(local_site, api_tokens)
+
+        return {
+            'apiTokens': serialized_api_tokens,
+        }
+
+    def _serialize_api_tokens(self, local_site, api_tokens):
+        if local_site:
+            local_site_prefix = local_site_reverse(
+                'root',
+                local_site_name=local_site.name)[1:]
+        else:
+            local_site_prefix = None
+
+        return {
+            'localSitePrefix': local_site_prefix,
+            'tokens': [
+                {
+                    'id': api_token.pk,
+                    'tokenValue': api_token.token,
+                    'timeAdded': api_token.time_added,
+                    'lastUpdated': api_token.last_updated,
+                    'note': api_token.note,
+                    'policy': api_token.policy,
+                }
+                for api_token in api_tokens
+                if api_token.local_site == local_site
+            ]
+        }
+
+
 class ChangePasswordForm(AccountPageForm):
     """Form for changing a user's password."""
     form_id = 'change_password'
@@ -203,11 +253,11 @@ class GroupsForm(AccountPageForm):
         # Fetch the list of IDs of groups the user has joined.
         joined_group_ids = self.user.review_groups.values_list('pk', flat=True)
 
-        # Fetch the list of gorups available to the user.
+        # Fetch the list of groups available to the user.
         serialized_groups = SortedDict()
         serialized_groups[''] = self._serialize_groups(None, joined_group_ids)
 
-        for local_site in self.user.local_site.order_by('name'):
+        for local_site in self.page.config_view.ordered_user_local_sites:
             serialized_groups[local_site.name] = self._serialize_groups(
                 local_site, joined_group_ids)
 
diff --git a/reviewboard/accounts/pages.py b/reviewboard/accounts/pages.py
index 8dce4b4dc900ab0405dc85ac14bc2071dd0ac253..77fc8d1a4f2318ecce3f152d1f28c2bf0aeb9d4e 100644
--- a/reviewboard/accounts/pages.py
+++ b/reviewboard/accounts/pages.py
@@ -8,6 +8,7 @@ from django.utils.translation import ugettext_lazy as _
 from djblets.configforms.pages import ConfigPage
 
 from reviewboard.accounts.forms.pages import (AccountSettingsForm,
+                                              APITokensForm,
                                               ChangePasswordForm,
                                               ProfileForm,
                                               GroupsForm)
@@ -58,6 +59,13 @@ class AccountSettingsPage(AccountPage):
     form_classes = [AccountSettingsForm]
 
 
+class APITokensPage(AccountPage):
+    """A page containing settings for API tokens."""
+    page_id = 'api-tokens'
+    page_title = _('API Tokens')
+    form_classes = [APITokensForm]
+
+
 class AuthenticationPage(AccountPage):
     """A page containing authentication-related forms.
 
@@ -91,7 +99,7 @@ def _populate_defaults():
         _populated = True
 
         for page_cls in (GroupsPage, AccountSettingsPage, AuthenticationPage,
-                         ProfilePage):
+                         ProfilePage, APITokensPage):
             register_account_page_class(page_cls)
 
 
diff --git a/reviewboard/accounts/views.py b/reviewboard/accounts/views.py
index a22c4f257309dc42f69473edaa3fb808016a3560..305aa0a623604d4c75a09b4a31afe148497182c6 100644
--- a/reviewboard/accounts/views.py
+++ b/reviewboard/accounts/views.py
@@ -4,6 +4,7 @@ from django.contrib.auth.decorators import login_required
 from django.core.urlresolvers import reverse
 from django.http import HttpResponseRedirect
 from django.utils.decorators import method_decorator
+from django.utils.functional import cached_property
 from django.utils.translation import ugettext_lazy as _
 from django.views.decorators.csrf import csrf_protect
 from djblets.auth.views import register
@@ -50,7 +51,15 @@ class MyAccountView(ConfigPagesView):
     """
     title = _('My Account')
 
-    js_bundle_names = ['account-page']
+    css_bundle_names = [
+        'account-page',
+    ]
+
+    js_bundle_names = [
+        '3rdparty-jsonlint',
+        'config-forms',
+        'account-page',
+    ]
 
     @method_decorator(login_required)
     @augment_method_from(ConfigPagesView)
@@ -64,3 +73,7 @@ class MyAccountView(ConfigPagesView):
     @property
     def page_classes(self):
         return get_page_classes()
+
+    @cached_property
+    def ordered_user_local_sites(self):
+        return self.request.user.local_site.order_by('name')
diff --git a/reviewboard/static/lib/css/codemirror.css b/reviewboard/static/lib/css/codemirror.css
index 23eaf74d449bcd5cdb4e952e5b2846428e8ebb71..4655462c82cb5e0f3b0b95587a1315fde71520bc 100644
--- a/reviewboard/static/lib/css/codemirror.css
+++ b/reviewboard/static/lib/css/codemirror.css
@@ -261,3 +261,77 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
     visibility: hidden;
   }
 }
+
+/* The lint marker gutter */
+.CodeMirror-lint-markers {
+  width: 16px;
+}
+
+.CodeMirror-lint-tooltip {
+  background-color: infobackground;
+  border: 1px solid black;
+  border-radius: 4px 4px 4px 4px;
+  color: infotext;
+  font-family: monospace;
+  font-size: 10pt;
+  overflow: hidden;
+  padding: 2px 5px;
+  position: fixed;
+  white-space: pre;
+  white-space: pre-wrap;
+  z-index: 100;
+  max-width: 600px;
+  opacity: 0;
+  transition: opacity .4s;
+  -moz-transition: opacity .4s;
+  -webkit-transition: opacity .4s;
+  -o-transition: opacity .4s;
+  -ms-transition: opacity .4s;
+}
+
+.CodeMirror-lint-mark-error, .CodeMirror-lint-mark-warning {
+  background-position: left bottom;
+  background-repeat: repeat-x;
+}
+
+.CodeMirror-lint-mark-error {
+  background-image:
+  url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==")
+  ;
+}
+
+.CodeMirror-lint-mark-warning {
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJFhQXEbhTg7YAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAMklEQVQI12NkgIIvJ3QXMjAwdDN+OaEbysDA4MPAwNDNwMCwiOHLCd1zX07o6kBVGQEAKBANtobskNMAAAAASUVORK5CYII=");
+}
+
+.CodeMirror-lint-marker-error, .CodeMirror-lint-marker-warning {
+  background-position: center center;
+  background-repeat: no-repeat;
+  cursor: pointer;
+  display: inline-block;
+  height: 16px;
+  width: 16px;
+  vertical-align: middle;
+  position: relative;
+}
+
+.CodeMirror-lint-message-error, .CodeMirror-lint-message-warning {
+  padding-left: 18px;
+  background-position: top left;
+  background-repeat: no-repeat;
+}
+
+.CodeMirror-lint-marker-error, .CodeMirror-lint-message-error {
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAHlBMVEW7AAC7AACxAAC7AAC7AAAAAAC4AAC5AAD///+7AAAUdclpAAAABnRSTlMXnORSiwCK0ZKSAAAATUlEQVR42mWPOQ7AQAgDuQLx/z8csYRmPRIFIwRGnosRrpamvkKi0FTIiMASR3hhKW+hAN6/tIWhu9PDWiTGNEkTtIOucA5Oyr9ckPgAWm0GPBog6v4AAAAASUVORK5CYII=");
+}
+
+.CodeMirror-lint-marker-warning, .CodeMirror-lint-message-warning {
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAANlBMVEX/uwDvrwD/uwD/uwD/uwD/uwD/uwD/uwD/uwD6twD/uwAAAADurwD2tQD7uAD+ugAAAAD/uwDhmeTRAAAADHRSTlMJ8mN1EYcbmiixgACm7WbuAAAAVklEQVR42n3PUQqAIBBFUU1LLc3u/jdbOJoW1P08DA9Gba8+YWJ6gNJoNYIBzAA2chBth5kLmG9YUoG0NHAUwFXwO9LuBQL1giCQb8gC9Oro2vp5rncCIY8L8uEx5ZkAAAAASUVORK5CYII=");
+}
+
+.CodeMirror-lint-marker-multiple {
+  background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAMAAADzjKfhAAAACVBMVEUAAAAAAAC/v7914kyHAAAAAXRSTlMAQObYZgAAACNJREFUeNo1ioEJAAAIwmz/H90iFFSGJgFMe3gaLZ0od+9/AQZ0ADosbYraAAAAAElFTkSuQmCC");
+  background-repeat: no-repeat;
+  background-position: right bottom;
+  width: 100%; height: 100%;
+}
diff --git a/reviewboard/static/lib/js/jsonlint.js b/reviewboard/static/lib/js/jsonlint.js
new file mode 100644
index 0000000000000000000000000000000000000000..e3465544a96ae983cb8b9187b6a7e8308eec6e53
--- /dev/null
+++ b/reviewboard/static/lib/js/jsonlint.js
@@ -0,0 +1,434 @@
+/* Jison generated parser */
+var jsonlint = (function(){
+var parser = {trace: function trace() { },
+yy: {},
+symbols_: {"error":2,"JSONString":3,"STRING":4,"JSONNumber":5,"NUMBER":6,"JSONNullLiteral":7,"NULL":8,"JSONBooleanLiteral":9,"TRUE":10,"FALSE":11,"JSONText":12,"JSONValue":13,"EOF":14,"JSONObject":15,"JSONArray":16,"{":17,"}":18,"JSONMemberList":19,"JSONMember":20,":":21,",":22,"[":23,"]":24,"JSONElementList":25,"$accept":0,"$end":1},
+terminals_: {2:"error",4:"STRING",6:"NUMBER",8:"NULL",10:"TRUE",11:"FALSE",14:"EOF",17:"{",18:"}",21:":",22:",",23:"[",24:"]"},
+productions_: [0,[3,1],[5,1],[7,1],[9,1],[9,1],[12,2],[13,1],[13,1],[13,1],[13,1],[13,1],[13,1],[15,2],[15,3],[20,3],[19,1],[19,3],[16,2],[16,3],[25,1],[25,3]],
+performAction: function anonymous(yytext,yyleng,yylineno,yy,yystate,$$,_$) {
+
+var $0 = $$.length - 1;
+switch (yystate) {
+case 1: // replace escaped characters with actual character
+          this.$ = yytext.replace(/\\(\\|")/g, "$"+"1")
+                     .replace(/\\n/g,'\n')
+                     .replace(/\\r/g,'\r')
+                     .replace(/\\t/g,'\t')
+                     .replace(/\\v/g,'\v')
+                     .replace(/\\f/g,'\f')
+                     .replace(/\\b/g,'\b');
+        
+break;
+case 2:this.$ = Number(yytext);
+break;
+case 3:this.$ = null;
+break;
+case 4:this.$ = true;
+break;
+case 5:this.$ = false;
+break;
+case 6:return this.$ = $$[$0-1];
+break;
+case 13:this.$ = {};
+break;
+case 14:this.$ = $$[$0-1];
+break;
+case 15:this.$ = [$$[$0-2], $$[$0]];
+break;
+case 16:this.$ = {}; this.$[$$[$0][0]] = $$[$0][1];
+break;
+case 17:this.$ = $$[$0-2]; $$[$0-2][$$[$0][0]] = $$[$0][1];
+break;
+case 18:this.$ = [];
+break;
+case 19:this.$ = $$[$0-1];
+break;
+case 20:this.$ = [$$[$0]];
+break;
+case 21:this.$ = $$[$0-2]; $$[$0-2].push($$[$0]);
+break;
+}
+},
+table: [{3:5,4:[1,12],5:6,6:[1,13],7:3,8:[1,9],9:4,10:[1,10],11:[1,11],12:1,13:2,15:7,16:8,17:[1,14],23:[1,15]},{1:[3]},{14:[1,16]},{14:[2,7],18:[2,7],22:[2,7],24:[2,7]},{14:[2,8],18:[2,8],22:[2,8],24:[2,8]},{14:[2,9],18:[2,9],22:[2,9],24:[2,9]},{14:[2,10],18:[2,10],22:[2,10],24:[2,10]},{14:[2,11],18:[2,11],22:[2,11],24:[2,11]},{14:[2,12],18:[2,12],22:[2,12],24:[2,12]},{14:[2,3],18:[2,3],22:[2,3],24:[2,3]},{14:[2,4],18:[2,4],22:[2,4],24:[2,4]},{14:[2,5],18:[2,5],22:[2,5],24:[2,5]},{14:[2,1],18:[2,1],21:[2,1],22:[2,1],24:[2,1]},{14:[2,2],18:[2,2],22:[2,2],24:[2,2]},{3:20,4:[1,12],18:[1,17],19:18,20:19},{3:5,4:[1,12],5:6,6:[1,13],7:3,8:[1,9],9:4,10:[1,10],11:[1,11],13:23,15:7,16:8,17:[1,14],23:[1,15],24:[1,21],25:22},{1:[2,6]},{14:[2,13],18:[2,13],22:[2,13],24:[2,13]},{18:[1,24],22:[1,25]},{18:[2,16],22:[2,16]},{21:[1,26]},{14:[2,18],18:[2,18],22:[2,18],24:[2,18]},{22:[1,28],24:[1,27]},{22:[2,20],24:[2,20]},{14:[2,14],18:[2,14],22:[2,14],24:[2,14]},{3:20,4:[1,12],20:29},{3:5,4:[1,12],5:6,6:[1,13],7:3,8:[1,9],9:4,10:[1,10],11:[1,11],13:30,15:7,16:8,17:[1,14],23:[1,15]},{14:[2,19],18:[2,19],22:[2,19],24:[2,19]},{3:5,4:[1,12],5:6,6:[1,13],7:3,8:[1,9],9:4,10:[1,10],11:[1,11],13:31,15:7,16:8,17:[1,14],23:[1,15]},{18:[2,17],22:[2,17]},{18:[2,15],22:[2,15]},{22:[2,21],24:[2,21]}],
+defaultActions: {16:[2,6]},
+parseError: function parseError(str, hash) {
+    throw new Error(str);
+},
+parse: function parse(input) {
+    var self = this,
+        stack = [0],
+        vstack = [null], // semantic value stack
+        lstack = [], // location stack
+        table = this.table,
+        yytext = '',
+        yylineno = 0,
+        yyleng = 0,
+        recovering = 0,
+        TERROR = 2,
+        EOF = 1;
+
+    //this.reductionCount = this.shiftCount = 0;
+
+    this.lexer.setInput(input);
+    this.lexer.yy = this.yy;
+    this.yy.lexer = this.lexer;
+    if (typeof this.lexer.yylloc == 'undefined')
+        this.lexer.yylloc = {};
+    var yyloc = this.lexer.yylloc;
+    lstack.push(yyloc);
+
+    if (typeof this.yy.parseError === 'function')
+        this.parseError = this.yy.parseError;
+
+    function popStack (n) {
+        stack.length = stack.length - 2*n;
+        vstack.length = vstack.length - n;
+        lstack.length = lstack.length - n;
+    }
+
+    function lex() {
+        var token;
+        token = self.lexer.lex() || 1; // $end = 1
+        // if token isn't its numeric value, convert
+        if (typeof token !== 'number') {
+            token = self.symbols_[token] || token;
+        }
+        return token;
+    }
+
+    var symbol, preErrorSymbol, state, action, a, r, yyval={},p,len,newState, expected;
+    while (true) {
+        // retreive state number from top of stack
+        state = stack[stack.length-1];
+
+        // use default actions if available
+        if (this.defaultActions[state]) {
+            action = this.defaultActions[state];
+        } else {
+            if (symbol == null)
+                symbol = lex();
+            // read action for current state and first input
+            action = table[state] && table[state][symbol];
+        }
+
+        // handle parse error
+        _handle_error:
+        if (typeof action === 'undefined' || !action.length || !action[0]) {
+
+            if (!recovering) {
+                // Report error
+                expected = [];
+                for (p in table[state]) if (this.terminals_[p] && p > 2) {
+                    expected.push("'"+this.terminals_[p]+"'");
+                }
+                var errStr = '';
+                if (this.lexer.showPosition) {
+                    errStr = 'Parse error on line '+(yylineno+1)+":\n"+this.lexer.showPosition()+"\nExpecting "+expected.join(', ') + ", got '" + this.terminals_[symbol]+ "'";
+                } else {
+                    errStr = 'Parse error on line '+(yylineno+1)+": Unexpected " +
+                                  (symbol == 1 /*EOF*/ ? "end of input" :
+                                              ("'"+(this.terminals_[symbol] || symbol)+"'"));
+                }
+                this.parseError(errStr,
+                    {text: this.lexer.match, token: this.terminals_[symbol] || symbol, line: this.lexer.yylineno, loc: yyloc, expected: expected});
+            }
+
+            // just recovered from another error
+            if (recovering == 3) {
+                if (symbol == EOF) {
+                    throw new Error(errStr || 'Parsing halted.');
+                }
+
+                // discard current lookahead and grab another
+                yyleng = this.lexer.yyleng;
+                yytext = this.lexer.yytext;
+                yylineno = this.lexer.yylineno;
+                yyloc = this.lexer.yylloc;
+                symbol = lex();
+            }
+
+            // try to recover from error
+            while (1) {
+                // check for error recovery rule in this state
+                if ((TERROR.toString()) in table[state]) {
+                    break;
+                }
+                if (state == 0) {
+                    throw new Error(errStr || 'Parsing halted.');
+                }
+                popStack(1);
+                state = stack[stack.length-1];
+            }
+
+            preErrorSymbol = symbol; // save the lookahead token
+            symbol = TERROR;         // insert generic error symbol as new lookahead
+            state = stack[stack.length-1];
+            action = table[state] && table[state][TERROR];
+            recovering = 3; // allow 3 real symbols to be shifted before reporting a new error
+        }
+
+        // this shouldn't happen, unless resolve defaults are off
+        if (action[0] instanceof Array && action.length > 1) {
+            throw new Error('Parse Error: multiple actions possible at state: '+state+', token: '+symbol);
+        }
+
+        switch (action[0]) {
+
+            case 1: // shift
+                //this.shiftCount++;
+
+                stack.push(symbol);
+                vstack.push(this.lexer.yytext);
+                lstack.push(this.lexer.yylloc);
+                stack.push(action[1]); // push state
+                symbol = null;
+                if (!preErrorSymbol) { // normal execution/no error
+                    yyleng = this.lexer.yyleng;
+                    yytext = this.lexer.yytext;
+                    yylineno = this.lexer.yylineno;
+                    yyloc = this.lexer.yylloc;
+                    if (recovering > 0)
+                        recovering--;
+                } else { // error just occurred, resume old lookahead f/ before error
+                    symbol = preErrorSymbol;
+                    preErrorSymbol = null;
+                }
+                break;
+
+            case 2: // reduce
+                //this.reductionCount++;
+
+                len = this.productions_[action[1]][1];
+
+                // perform semantic action
+                yyval.$ = vstack[vstack.length-len]; // default to $$ = $1
+                // default location, uses first token for firsts, last for lasts
+                yyval._$ = {
+                    first_line: lstack[lstack.length-(len||1)].first_line,
+                    last_line: lstack[lstack.length-1].last_line,
+                    first_column: lstack[lstack.length-(len||1)].first_column,
+                    last_column: lstack[lstack.length-1].last_column
+                };
+                r = this.performAction.call(yyval, yytext, yyleng, yylineno, this.yy, action[1], vstack, lstack);
+
+                if (typeof r !== 'undefined') {
+                    return r;
+                }
+
+                // pop off stack
+                if (len) {
+                    stack = stack.slice(0,-1*len*2);
+                    vstack = vstack.slice(0, -1*len);
+                    lstack = lstack.slice(0, -1*len);
+                }
+
+                stack.push(this.productions_[action[1]][0]);    // push nonterminal (reduce)
+                vstack.push(yyval.$);
+                lstack.push(yyval._$);
+                // goto new state = table[STATE][NONTERMINAL]
+                newState = table[stack[stack.length-2]][stack[stack.length-1]];
+                stack.push(newState);
+                break;
+
+            case 3: // accept
+                return true;
+        }
+
+    }
+
+    return true;
+}};
+/* Jison generated lexer */
+var lexer = (function(){
+var lexer = ({EOF:1,
+parseError:function parseError(str, hash) {
+        if (this.yy.parseError) {
+            this.yy.parseError(str, hash);
+        } else {
+            throw new Error(str);
+        }
+    },
+setInput:function (input) {
+        this._input = input;
+        this._more = this._less = this.done = false;
+        this.yylineno = this.yyleng = 0;
+        this.yytext = this.matched = this.match = '';
+        this.conditionStack = ['INITIAL'];
+        this.yylloc = {first_line:1,first_column:0,last_line:1,last_column:0};
+        return this;
+    },
+input:function () {
+        var ch = this._input[0];
+        this.yytext+=ch;
+        this.yyleng++;
+        this.match+=ch;
+        this.matched+=ch;
+        var lines = ch.match(/\n/);
+        if (lines) this.yylineno++;
+        this._input = this._input.slice(1);
+        return ch;
+    },
+unput:function (ch) {
+        this._input = ch + this._input;
+        return this;
+    },
+more:function () {
+        this._more = true;
+        return this;
+    },
+less:function (n) {
+        this._input = this.match.slice(n) + this._input;
+    },
+pastInput:function () {
+        var past = this.matched.substr(0, this.matched.length - this.match.length);
+        return (past.length > 20 ? '...':'') + past.substr(-20).replace(/\n/g, "");
+    },
+upcomingInput:function () {
+        var next = this.match;
+        if (next.length < 20) {
+            next += this._input.substr(0, 20-next.length);
+        }
+        return (next.substr(0,20)+(next.length > 20 ? '...':'')).replace(/\n/g, "");
+    },
+showPosition:function () {
+        var pre = this.pastInput();
+        var c = new Array(pre.length + 1).join("-");
+        return pre + this.upcomingInput() + "\n" + c+"^";
+    },
+next:function () {
+        if (this.done) {
+            return this.EOF;
+        }
+        if (!this._input) this.done = true;
+
+        var token,
+            match,
+            tempMatch,
+            index,
+            col,
+            lines;
+        if (!this._more) {
+            this.yytext = '';
+            this.match = '';
+        }
+        var rules = this._currentRules();
+        for (var i=0;i < rules.length; i++) {
+            tempMatch = this._input.match(this.rules[rules[i]]);
+            if (tempMatch && (!match || tempMatch[0].length > match[0].length)) {
+                match = tempMatch;
+                index = i;
+                if (!this.options.flex) break;
+            }
+        }
+        if (match) {
+            lines = match[0].match(/\n.*/g);
+            if (lines) this.yylineno += lines.length;
+            this.yylloc = {first_line: this.yylloc.last_line,
+                           last_line: this.yylineno+1,
+                           first_column: this.yylloc.last_column,
+                           last_column: lines ? lines[lines.length-1].length-1 : this.yylloc.last_column + match[0].length}
+            this.yytext += match[0];
+            this.match += match[0];
+            this.yyleng = this.yytext.length;
+            this._more = false;
+            this._input = this._input.slice(match[0].length);
+            this.matched += match[0];
+            token = this.performAction.call(this, this.yy, this, rules[index],this.conditionStack[this.conditionStack.length-1]);
+            if (this.done && this._input) this.done = false;
+            if (token) return token;
+            else return;
+        }
+        if (this._input === "") {
+            return this.EOF;
+        } else {
+            this.parseError('Lexical error on line '+(this.yylineno+1)+'. Unrecognized text.\n'+this.showPosition(), 
+                    {text: "", token: null, line: this.yylineno});
+        }
+    },
+lex:function lex() {
+        var r = this.next();
+        if (typeof r !== 'undefined') {
+            return r;
+        } else {
+            return this.lex();
+        }
+    },
+begin:function begin(condition) {
+        this.conditionStack.push(condition);
+    },
+popState:function popState() {
+        return this.conditionStack.pop();
+    },
+_currentRules:function _currentRules() {
+        return this.conditions[this.conditionStack[this.conditionStack.length-1]].rules;
+    },
+topState:function () {
+        return this.conditionStack[this.conditionStack.length-2];
+    },
+pushState:function begin(condition) {
+        this.begin(condition);
+    }});
+lexer.options = {};
+lexer.performAction = function anonymous(yy,yy_,$avoiding_name_collisions,YY_START) {
+
+var YYSTATE=YY_START
+switch($avoiding_name_collisions) {
+case 0:/* skip whitespace */
+break;
+case 1:return 6
+break;
+case 2:yy_.yytext = yy_.yytext.substr(1,yy_.yyleng-2); return 4
+break;
+case 3:return 17
+break;
+case 4:return 18
+break;
+case 5:return 23
+break;
+case 6:return 24
+break;
+case 7:return 22
+break;
+case 8:return 21
+break;
+case 9:return 10
+break;
+case 10:return 11
+break;
+case 11:return 8
+break;
+case 12:return 14
+break;
+case 13:return 'INVALID'
+break;
+}
+};
+lexer.rules = [/^(?:\s+)/,/^(?:(-?([0-9]|[1-9][0-9]+))(\.[0-9]+)?([eE][-+]?[0-9]+)?\b)/,/^(?:"(?:\\[\\"bfnrt/]|\\u[a-fA-F0-9]{4}|[^\\\0-\x09\x0a-\x1f"])*")/,/^(?:\{)/,/^(?:\})/,/^(?:\[)/,/^(?:\])/,/^(?:,)/,/^(?::)/,/^(?:true\b)/,/^(?:false\b)/,/^(?:null\b)/,/^(?:$)/,/^(?:.)/];
+lexer.conditions = {"INITIAL":{"rules":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],"inclusive":true}};
+
+
+;
+return lexer;})()
+parser.lexer = lexer;
+return parser;
+})();
+if (typeof require !== 'undefined' && typeof exports !== 'undefined') {
+exports.parser = jsonlint;
+exports.parse = function () { return jsonlint.parse.apply(jsonlint, arguments); }
+exports.main = function commonjsMain(args) {
+    if (!args[1])
+        throw new Error('Usage: '+args[0]+' FILE');
+    if (typeof process !== 'undefined') {
+        var source = require('fs').readFileSync(require('path').join(process.cwd(), args[1]), "utf8");
+    } else {
+        var cwd = require("file").path(require("file").cwd());
+        var source = cwd.join(args[1]).read({charset: "utf-8"});
+    }
+    return exports.parser.parse(source);
+}
+if (typeof module !== 'undefined' && require.main === module) {
+  exports.main(typeof process !== 'undefined' ? process.argv.slice(1) : require("system").args);
+}
+} else {
+  window.jsonlint = jsonlint;
+}
diff --git a/reviewboard/static/rb/css/pages/my-account.less b/reviewboard/static/rb/css/pages/my-account.less
new file mode 100644
index 0000000000000000000000000000000000000000..1c9f55ce907165a8c5aa5352a05f3e3d442df4e8
--- /dev/null
+++ b/reviewboard/static/rb/css/pages/my-account.less
@@ -0,0 +1,57 @@
+#custom_policy_editor {
+  margin: 0;
+  width: 65em;
+
+  p {
+    font-size: 110%;
+    margin: 1em;
+  }
+
+  .CodeMirror {
+    border-top: 1px #DDD solid;
+    border-bottom: 1px #DDD solid;
+  }
+}
+
+.config-forms-container .config-forms-page-content {
+  .config-site-api-tokens {
+    .config-api-token-note {
+      color: #888;
+
+      &.empty {
+        color: #CCC;
+        font-style: italic;
+      }
+    }
+
+    .config-api-token-value {
+      font-family: monospace;
+      font-size: 120%;
+    }
+
+    .config-forms-list-action-label-policy-custom {
+      color: blue;
+      cursor: pointer;
+      text-decoration: underline;
+    }
+
+    .config-forms-list-action-policy {
+      text-align: right;
+      width: 6.5em;
+    }
+
+    .generate-api-token {
+      background: white;
+
+      a {
+        color: blue;
+        font-size: 90%;
+        text-decoration: none;
+      }
+    }
+
+    .rb-icon {
+      vertical-align: middle;
+    }
+  }
+}
diff --git a/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.js b/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.js
new file mode 100644
index 0000000000000000000000000000000000000000..7d319576605db27946ff7df6ca1435c6804ea977
--- /dev/null
+++ b/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.js
@@ -0,0 +1,607 @@
+(function() {
+
+
+var APITokenItem,
+    APITokenItemCollection,
+    APITokenItemView,
+    PolicyEditorView,
+    SiteAPITokensView,
+    POLICY_READ_WRITE = 'rw',
+    POLICY_READ_ONLY = 'ro',
+    POLICY_CUSTOM = 'custom',
+    POLICY_LABELS = {};
+
+
+POLICY_LABELS[POLICY_READ_WRITE] = gettext('Full access');
+POLICY_LABELS[POLICY_READ_ONLY] = gettext('Read-only');
+POLICY_LABELS[POLICY_CUSTOM] = gettext('Custom');
+
+
+/*
+ * Represents an API token in the list.
+ *
+ * This provides actions for editing the policy type for the token and
+ * removing the token.
+ */
+APITokenItem = RB.Config.ResourceListItem.extend({
+    defaults: _.defaults({
+        policyType: POLICY_READ_WRITE,
+        localSiteName: null,
+        showRemove: true
+    }, RB.Config.ResourceListItem.prototype.defaults),
+
+    syncAttrs: ['id', 'note', 'policy', 'tokenValue'],
+
+    /*
+     * Initializes the item.
+     *
+     * This computes the type of policy used, for display, and builds the
+     * policy actions menu.
+     */
+    initialize: function(options) {
+        var policy,
+            policyType;
+
+        _super(this).initialize.call(this, options);
+
+        this.on('change:policyType', this._onPolicyTypeChanged, this);
+
+        policy = this.get('policy') || {};
+        policyType = this._guessPolicyType(policy);
+
+        this._policyMenuAction = {
+            id: 'policy',
+            label: POLICY_LABELS[policyType],
+            children: [
+                this._makePolicyAction(POLICY_READ_WRITE),
+                this._makePolicyAction(POLICY_READ_ONLY),
+                this._makePolicyAction(POLICY_CUSTOM, {
+                    id: 'policy-custom',
+                    dispatchOnClick: true
+                })
+            ]
+        };
+        this.actions.unshift(this._policyMenuAction);
+
+        this.set('policyType', policyType);
+    },
+
+    /*
+     * Creates an APIToken resource for the given attributes.
+     */
+    createResource: function(attrs) {
+        return new RB.APIToken(_.defaults({
+            userName: RB.UserSession.instance.get('username'),
+            localSitePrefix: this.collection.localSitePrefix
+        }, attrs));
+    },
+
+    /*
+     * Sets the provided note on the token and saves it.
+     */
+    saveNote: function(note, options, context) {
+        this._saveAttribute('note', note, options, context);
+    },
+
+    /*
+     * Sets the provided policy on the token and saves it.
+     */
+    savePolicy: function(policy, options, context) {
+        this._saveAttribute('policy', policy, options, context);
+    },
+
+    /*
+     * Sets an attribute on the token and saves it.
+     *
+     * This is a helper function that will set an attribute on the token
+     * and save it, but only after the token is ready.
+     */
+    _saveAttribute: function(attr, value, options, context) {
+        this.resource.ready({
+            ready: function() {
+                this.resource.set(attr, value);
+                this.resource.save(options, context);
+            }
+        }, this);
+    },
+
+    /*
+     * Guesses the policy type for a given policy definition.
+     *
+     * This compares the policy against the built-in versions that
+     * RB.APIToken provides. If one of them matches, the appropriate
+     * policy type will be returned. Otherwise, this assumes it's a
+     * custom policy.
+     */
+    _guessPolicyType: function(policy) {
+        if (_.isEqual(policy, RB.APIToken.defaultPolicies.readOnly)) {
+            return POLICY_READ_ONLY;
+        } else if (_.isEqual(policy, RB.APIToken.defaultPolicies.readWrite)) {
+            return POLICY_READ_WRITE;
+        } else {
+            return POLICY_CUSTOM;
+        }
+    },
+
+    /*
+     * Creates and returns an action for the policy menu.
+     *
+     * This takes a policy type and any options to include with the
+     * action definition. It will then return a suitable action,
+     * for display in the policy menu.
+     */
+    _makePolicyAction: function(policyType, options) {
+        return _.defaults({
+            label: POLICY_LABELS[policyType],
+            type: 'radio',
+            name: 'policy-type',
+            propName: 'policyType',
+            radioValue: policyType
+        }, options);
+    },
+
+    /*
+     * Handler for when the policy type changes.
+     *
+     * This will set the policy menu's label to that of the selected
+     * policy and rebuild the menu.
+     *
+     * Then, if not using a custom policy, the built-in policy definition
+     * matching the selected policy will be saved to the server.
+     */
+    _onPolicyTypeChanged: function() {
+        var policyType = this.get('policyType'),
+            newPolicy;
+
+        this._policyMenuAction.label = POLICY_LABELS[policyType];
+        this.trigger('actionsChanged');
+
+        if (policyType === POLICY_READ_ONLY) {
+            newPolicy = RB.APIToken.defaultPolicies.readOnly;
+        } else if (policyType === POLICY_READ_WRITE) {
+            newPolicy = RB.APIToken.defaultPolicies.readWrite;
+        } else {
+            return;
+        }
+
+        if (!_.isEqual(newPolicy, this.get('policy'))) {
+            this.savePolicy(newPolicy);
+        }
+    }
+});
+
+
+/*
+ * A collection of APITokenItems.
+ *
+ * This works like a standard Backbone.Collection, but can also have
+ * a LocalSite URL prefix attached to it, for use in API calls in
+ * APITokenItem.
+ */
+APITokenItemCollection = Backbone.Collection.extend({
+    model: APITokenItem,
+
+    initialize: function(models, options) {
+        this.localSitePrefix = options.localSitePrefix;
+    }
+});
+
+
+/*
+ * Renders an APITokenItem to the page, and handles actions.
+ *
+ * This will display the information on the given token. Specifically,
+ * the token value, the note, and the actions.
+ *
+ * This also handles deleting the token when the Remove action is clicked,
+ * and displaying the policy editor when choosing a custom policy.
+ */
+APITokenItemView = Djblets.Config.ListItemView.extend({
+    EMPTY_NOTE_PLACEHOLDER: gettext('Click to describe this token'),
+
+    template: _.template([
+        '<div class="config-api-token-value"><%- tokenValue %></div>',
+        '<span class="config-api-token-note"></span>'
+    ].join('')),
+
+    actionHandlers: {
+        'delete': '_onRemoveClicked',
+        'policy-custom': '_onCustomPolicyClicked'
+    },
+
+    /*
+     * Initializes the view.
+     */
+    initialize: function(options) {
+        _super(this).initialize.call(this, options);
+
+        this._$note = null;
+
+        this.listenTo(this.model.resource, 'change:note', this._updateNote);
+    },
+
+    /*
+     * Renders the view.
+     */
+    render: function() {
+        _super(this).render.call(this);
+
+        this._$note = this.$('.config-api-token-note')
+            .inlineEditor({
+                editIconClass: 'rb-icon rb-icon-edit'
+            })
+            .on({
+                beginEdit: _.bind(function() {
+                    this._$note.inlineEditor('setValue',
+                                             this.model.get('note'));
+                }, this),
+                complete: _.bind(function(e, value) {
+                    this.model.saveNote(value);
+                }, this)
+            });
+
+        this._updateNote();
+
+        return this;
+    },
+
+    /*
+     * Updates the displayed note.
+     *
+     * If no note is set, then a placeholder will be shown, informing the
+     * user that they can edit the note. Otherwise, their note contents
+     * will be shown.
+     */
+    _updateNote: function() {
+        var note = this.model.resource.get('note');
+
+        if (note) {
+            this._$note
+                .removeClass('empty')
+                .text(note);
+        } else {
+            this._$note
+                .addClass('empty')
+                .text(this.EMPTY_NOTE_PLACEHOLDER);
+        }
+    },
+
+    /*
+     * Handler for when the "Custom" policy action is clicked.
+     *
+     * This displays the policy editor, allowing the user to edit a
+     * custom policy for the token.
+     *
+     * The previously selected policy type is passed along to the editor,
+     * so that the editor can revert to it if the user cancels.
+     */
+    _onCustomPolicyClicked: function() {
+        var view = new PolicyEditorView({
+            model: this.model,
+            prevPolicyType: this.model.previous('policyType')
+        });
+        view.render();
+
+        return false;
+    },
+
+    /*
+     * Handler for when the Remove action is clicked.
+     *
+     * This will prompt for confirmation before removing the token from
+     * the server.
+     */
+    _onRemoveClicked: function() {
+        $('<p/>')
+            .html(gettext('This will prevent clients using this token when authenticating.'))
+            .modalBox({
+                title: gettext('Are you sure you want to remove this token?'),
+                buttons: [
+                    $('<input type="button"/>')
+                        .val(gettext('Cancel')),
+                    $('<input type="button" class="danger" />')
+                        .val(gettext('Remove'))
+                        .click(_.bind(function() {
+                            this.model.resource.destroy();
+                        }, this))
+                ]
+            });
+    }
+});
+
+
+/*
+ * Provides an editor for constructing or modifying a custom policy definition.
+ *
+ * This renders as a modalBox with a CodeMirror editor inside of it. The
+ * editor is set to allow easy editing of a JSON payload, complete with
+ * lintian checking. Only valid policy payloads can be saved to the server.
+ */
+PolicyEditorView = Backbone.View.extend({
+    id: 'custom_policy_editor',
+
+    template: _.template([
+        '<p><%= instructions %></p>',
+        '<textarea/>'
+    ].join('')),
+
+    /*
+     * Initializes the editor.
+     */
+    initialize: function(options) {
+        this.prevPolicyType = options.prevPolicyType;
+
+        this._codeMirror = null;
+        this._$policy = null;
+        this._$saveButtons = null;
+    },
+
+    /*
+     * Renders the editor.
+     *
+     * The CodeMirror editor will be set up and configured, and then the
+     * view will be placed inside a modalBox.
+     */
+    render: function() {
+        var policy = this.model.get('policy');
+
+        if (_.isEmpty(this.model.get('policy'))) {
+            policy = RB.APIToken.defaultPolicies.custom;
+        }
+
+        this.$el.html(this.template({
+            instructions: interpolate(
+                gettext('You can limit access to the API through a custom policy. See the <a href="%s" target="_blank">documentation</a> on how to write policies.'),
+                [MANUAL_URL + 'webapi/2.0/api-token-policy/'])
+        }));
+
+
+        this._$policy = this.$('textarea')
+            .val(JSON.stringify(policy, null, '  '));
+
+        this.$el.modalBox({
+            title: gettext('Custom Token Access Policy'),
+            buttons: [
+                $('<input type="button"/>')
+                    .val(gettext('Cancel'))
+                    .click(_.bind(this.cancel, this)),
+                $('<input type="button" class="save-button"/>')
+                    .val(gettext('Save and continue editing'))
+                    .click(_.bind(function() {
+                        this.save();
+                        return false;
+                    }, this)),
+                $('<input type="button" class="btn primary save-button"/>')
+                    .val(gettext('Save'))
+                    .click(_.bind(function() {
+                        this.save(true);
+                        return false;
+                    }, this))
+            ]
+        });
+
+        this._$saveButtons = this.$el.modalBox('buttons').find('.save-button');
+
+        this._codeMirror = CodeMirror.fromTextArea(this._$policy[0], {
+            mode: 'application/json',
+            lineNumbers: true,
+            lineWrapping: true,
+            matchBrackets: true,
+            lint: {
+                onUpdateLinting: _.bind(this._onUpdateLinting, this)
+            },
+            gutters: ['CodeMirror-lint-markers']
+        });
+        this._codeMirror.focus();
+    },
+
+    /*
+     * Removes the policy editor from the page.
+     */
+    remove: function() {
+        this.$el.modalBox('destroy');
+    },
+
+    /*
+     * Cancels the editor.
+     *
+     * The previously-selected policy type will be set on the model.
+     */
+    cancel: function() {
+        this.model.set('policyType', this.prevPolicyType);
+    },
+
+    /*
+     * Saves the editor.
+     *
+     * The policy will be saved to the server for immediate use.
+     */
+    save: function(closeOnSave) {
+        var policyStr = this._codeMirror.getValue().strip(),
+            resources,
+            section,
+            policy;
+
+        try {
+            policy = JSON.parse(policyStr);
+        } catch (e) {
+            alert(interpolate(
+                gettext('There is a syntax error in your policy: %s'),
+                [e]));
+
+            return false;
+        }
+
+        this.model.savePolicy(policy, {
+            success: function() {
+                if (closeOnSave) {
+                    this.remove();
+                }
+            },
+            error: function(model, xhr) {
+                if (xhr.errorPayload.err.code === 105 &&
+                    xhr.errorPayload.fields.policy) {
+                    alert(xhr.errorPayload.fields.policy);
+                } else {
+                    alert(xhr.errorPayload.err.msg);
+                }
+            }
+        }, this);
+
+        return false;
+    },
+
+    /*
+     * Handler for when lintian checking has run.
+     *
+     * This will disable the save buttons if there are any lintian errors.
+     */
+    _onUpdateLinting: function(annotationsNotSorted) {
+        this._$saveButtons.prop('disabled', annotationsNotSorted.length > 0);
+    }
+});
+
+
+/*
+ * Renders and manages a list of global or per-LocalSite API tokens.
+ *
+ * This will display all provided API tokens in a list, optionally labeled
+ * by Local Site name. These can be removed or edited, or new tokens generated
+ * through a "Generate a new API token" link.
+ */
+SiteAPITokensView = Backbone.View.extend({
+    className: 'config-site-api-tokens',
+
+    template: _.template([
+        '<% if (name) { %>',
+        ' <h3><%- name %></h3>',
+        '<% } %>',
+        '<div class="api-tokens box-recessed">',
+        ' <div class="generate-api-token config-forms-list-item">',
+        '  <a href="#"><%- generateText %></a>',
+        ' </div>',
+        '</div>'
+    ].join('')),
+
+    events: {
+        'click .generate-api-token': '_onGenerateClicked'
+    },
+
+    /*
+     * Initializes the view.
+     *
+     * This will construct the collection of tokens and construct
+     * a list for the ListView.
+     */
+    initialize: function(options) {
+        this.localSiteName = options.localSiteName;
+        this.localSitePrefix = options.localSitePrefix;
+
+        this.collection = new APITokenItemCollection(options.apiTokens, {
+            localSitePrefix: this.localSitePrefix
+        });
+
+        this.apiTokensList = new Djblets.Config.List({}, {
+            collection: this.collection
+        });
+
+        this._listView = null;
+    },
+
+    /*
+     * Renders the view.
+     *
+     * This will render the list of API token items, along with a link
+     * for generating new tokens.
+     */
+    render: function() {
+        this._listView = new Djblets.Config.ListView({
+            ItemView: APITokenItemView,
+            animateItems: true,
+            model: this.apiTokensList
+        });
+
+        this.$el.html(this.template({
+            name: this.localSiteName,
+            generateText: gettext('Generate a new API token')
+        }));
+
+        this._listView.render().$el.prependTo(this.$('.api-tokens'));
+
+        return this;
+    },
+
+    /*
+     * Handler for when the "Generate a new API token" link is clicked.
+     *
+     * This creates a new API token on the server and displays it in the list.
+     */
+    _onGenerateClicked: function() {
+        var apiToken = new RB.APIToken({
+            localSitePrefix: this.localSitePrefix,
+            userName: RB.UserSession.instance.get('username')
+        });
+
+        apiToken.save({
+            success: function() {
+                this.collection.add({
+                    resource: apiToken
+                });
+            }
+        }, this);
+
+        return false;
+    }
+});
+
+
+/*
+ * Renders and manages a page of API tokens.
+ *
+ * This will take the provided tokens and group them into SiteAPITokensView
+ * instances, one per Local Site and one for the global tokens.
+ */
+RB.APITokensView = Backbone.View.extend({
+    template: _.template([
+        '<div class="api-tokens-list" />'
+    ].join('')),
+
+    /*
+     * Initializes the view.
+     */
+    initialize: function(options) {
+        this.apiTokens = options.apiTokens;
+
+        this._$listsContainer = null;
+        this._apiTokenViews = [];
+    },
+
+    /*
+     * Renders the view.
+     *
+     * This will set up the elements and the list of SiteAPITokensViews.
+     */
+    render: function() {
+        this.$el.html(this.template());
+
+        this._$listsContainer = this.$('.api-tokens-list');
+
+        _.each(this.apiTokens, function(info, localSiteName) {
+            var view = new SiteAPITokensView({
+                localSiteName: localSiteName,
+                localSitePrefix: info.localSitePrefix,
+                apiTokens: info.tokens
+            });
+
+            view.$el.appendTo(this._$listsContainer);
+            view.render();
+
+            this._apiTokenViews.push(view);
+        }, this);
+
+        return this;
+    }
+});
+
+
+})();
diff --git a/reviewboard/static/rb/js/resources/models/apiTokenModel.js b/reviewboard/static/rb/js/resources/models/apiTokenModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..4039dad57fe38e29a901ce695db10674392e8e6b
--- /dev/null
+++ b/reviewboard/static/rb/js/resources/models/apiTokenModel.js
@@ -0,0 +1,56 @@
+RB.APIToken = RB.BaseResource.extend({
+    defaults: _.defaults({
+        tokenValue: null,
+        note: null,
+        policy: null,
+        userName: null
+    }, RB.BaseResource.prototype.defaults),
+
+    rspNamespace: 'api_token',
+
+    url: function() {
+        var url = SITE_ROOT + (this.get('localSitePrefix') || '') +
+                  'api/users/' + this.get('userName') + '/api-tokens/';
+
+        if (!this.isNew()) {
+            url += this.id + '/';
+        }
+
+        return url;
+    },
+
+    toJSON: function() {
+        return {
+            note: this.get('note'),
+            policy: JSON.stringify(this.get('policy'))
+        };
+    },
+
+    parseResourceData: function(rsp) {
+        return {
+            tokenValue: rsp.token,
+            note: rsp.note,
+            policy: rsp.policy
+        };
+    }
+}, {
+    defaultPolicies: {
+        readWrite: {},
+        readOnly: {
+            resources: {
+                '*': {
+                    allow: ['GET', 'HEAD', 'OPTIONS'],
+                    block: ['*']
+                }
+            }
+        },
+        custom: {
+            resources: {
+                '*': {
+                    allow: ['*'],
+                    block: []
+                }
+            }
+        }
+    }
+});
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index c093f4b4a66973ff1fc6023c8e1fc06a90e1ffeb..274cf9854ba621dbe4050ff6eda7201fdc24521e 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -26,6 +26,12 @@ PIPELINE_JS = dict({
         ),
         'output_filename': 'lib/js/3rdparty.min.js',
     },
+    '3rdparty-jsonlint': {
+        'source_filenames': (
+            'lib/js/jsonlint.js',
+        ),
+        'output_filename': 'lib/js/3rdparty-jsonlint.min.js',
+    },
     'js-tests': {
         'source_filenames': (
             'lib/js/jasmine-1.3.1.js',
@@ -107,6 +113,7 @@ PIPELINE_JS = dict({
             'rb/js/extensions/models/reviewDialogCommentHookModel.js',
             'rb/js/pages/models/pageManagerModel.js',
             'rb/js/resources/models/baseResourceModel.js',
+            'rb/js/resources/models/apiTokenModel.js',
             'rb/js/resources/models/repositoryBranchModel.js',
             'rb/js/resources/models/repositoryCommitModel.js',
             'rb/js/resources/models/draftResourceChildModelMixin.js',
@@ -143,6 +150,7 @@ PIPELINE_JS = dict({
     'account-page': {
         'source_filenames': (
             'rb/js/accountPrefsPage/views/accountPrefsPageView.js',
+            'rb/js/accountPrefsPage/views/apiTokensView.js',
             'rb/js/accountPrefsPage/views/joinedGroupsView.js',
         ),
         'output_filename': 'rb/js/account-page.min.js',
@@ -297,6 +305,12 @@ PIPELINE_CSS = dict({
         'output_filename': 'rb/css/js-tests.min.css',
         'absolute_paths': False,
     },
+    'account-page': {
+        'source_filenames': (
+            'rb/css/pages/my-account.less',
+        ),
+        'output_filename': 'rb/css/account-page.min.js',
+    },
     'reviews': {
         'source_filenames': (
             'rb/css/pages/diffviewer.less',
