diff --git a/reviewboard/static/rb/css/pages/my-account.less b/reviewboard/static/rb/css/pages/my-account.less
index 2c0d7ec6eb0e290550d9fa93f109cb38fe4f334b..9535c1e0113c22713c72b9f91d08b71d1f059d8b 100644
--- a/reviewboard/static/rb/css/pages/my-account.less
+++ b/reviewboard/static/rb/css/pages/my-account.less
@@ -4,17 +4,17 @@
 
 
 #custom_policy_editor {
-  margin: 0;
-  width: 65em;
+  overflow-x: hidden;
 
   p {
-    font-size: 110%;
-    margin: 1em;
+    margin: 0 0 var(--ink-u-spacing-m) 0;
   }
 
   .CodeMirror {
+    border-radius: 0;
     border-left: 0;
     border-right: 0;
+    margin: 0 calc(var(--ink-u-spacing-m) * -1);
   }
 }
 
diff --git a/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.ts b/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.ts
index 37ea07d5cd09942c9c7b4397e020eb754686c624..1d924d3181b62e9b1e94899315d6ce5b7c8beb1b 100644
--- a/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.ts
+++ b/reviewboard/static/rb/js/accountPrefsPage/views/apiTokensView.ts
@@ -2,6 +2,16 @@
  * Renders and manages a page of API tokens.
  */
 
+import {
+    type ButtonView,
+    type DialogViewOptions,
+    type DialogViewOpenOptions,
+    type ComponentChild,
+    DialogView,
+    craft,
+    paint,
+    DialogSize,
+} from '@beanbag/ink';
 import {
     type EventsHash,
     type Result,
@@ -9,9 +19,6 @@ import {
     BaseView,
     spina,
 } from '@beanbag/spina';
-import {
-    paint,
-} from '@beanbag/ink';
 import CodeMirror from 'codemirror';
 import {
     ConfigFormsList,
@@ -382,7 +389,7 @@ class APITokenItemCollection extends BaseCollection<
  * Version Added:
  *     8.0
  */
-interface PolicyEditorViewOptions {
+interface PolicyEditorViewOptions extends DialogViewOptions {
     /**
      * The previous policy type.
      *
@@ -395,23 +402,17 @@ interface PolicyEditorViewOptions {
 /**
  * Provides an editor for constructing or modifying a custom policy definition.
  *
- * This renders as a modalBox with a CodeMirror editor inside of it. The
+ * This renders as a modal dialog 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.
  */
 @spina
-class PolicyEditorView extends BaseView<
+class PolicyEditorView extends DialogView<
     APITokenItem,
-    HTMLDivElement,
     PolicyEditorViewOptions
 > {
     static id = 'custom_policy_editor';
-
-    static template = _.template(dedent`
-        <p><%= instructions %></p>
-        <textarea></textarea>
-    `);
-    template: _.CompiledTemplate;
+    static title = _`Custom Token Access Policy`;
 
     /**********************
      * Instance variables *
@@ -424,10 +425,10 @@ class PolicyEditorView extends BaseView<
     #prevPolicyType: string;
 
     /** The policy editor <textarea> element. */
-    #$policy: JQuery<HTMLTextAreaElement> = null;
+    #textarea: HTMLTextAreaElement = null;
 
     /** The save buttons. */
-    #$saveButtons: JQuery = null;
+    #saveButtons: ButtonView[];
 
     /**
      * Initialize the editor.
@@ -436,58 +437,25 @@ class PolicyEditorView extends BaseView<
      *     options (PolicyEditorViewOptions):
      *         Additional options for view construction.
      */
-    initialize(options: PolicyEditorViewOptions) {
+    initialize(options: Partial<PolicyEditorViewOptions>) {
+        super.initialize(_.defaults(options, {
+            size: DialogSize.LARGE,
+        }));
+
         this.#prevPolicyType = options.prevPolicyType;
     }
 
     /**
-     * Render the editor.
+     * Open the dialog.
      *
-     * The CodeMirror editor will be set up and configured, and then the
-     * view will be placed inside a modalBox.
+     * Args:
+     *     options (DialogViewOpenOptions, optional):
+     *         Whether to show the dialog as a modal.
      */
-    protected onRender() {
-        let policy = this.model.get('policy');
+    open(options: DialogViewOpenOptions = {}) {
+        super.open(options);
 
-        if (_.isEmpty(policy)) {
-            policy = APIToken.defaultPolicies.custom;
-        }
-
-        const manualURL = MANUAL_URL + 'webapi/2.0/api-token-policy/';
-
-        this.$el.html(PolicyEditorView.template({
-            instructions: _`
-                You can limit access to the API through a custom policy. See
-                the <a href="${manualURL}" target="_blank">documentation</a>
-                on how to write policies.
-            `,
-        }));
-
-
-        this.#$policy = (this.$('textarea') as JQuery<HTMLTextAreaElement>)
-            .val(JSON.stringify(policy, null, '  '));
-
-        const buttons = paint<HTMLButtonElement[]>`
-            <Ink.Button onClick=${() => this.cancel()}>
-             ${_`Cancel`}
-            </Ink.Button>
-            <Ink.Button class="save-button"
-                        onClick=${() => this.save(false)}>
-             ${_`Save and continue editing`}
-            </Ink.Button>
-            <Ink.Button type="primary" class="save-button"
-                        onClick=${() => this.save(true)}>
-             ${_`Save`}
-            </Ink.Button>
-        `;
-        this.$el.modalBox({
-            buttons: buttons,
-            title: _`Custom Token Access Policy`,
-        });
-
-        this.#$saveButtons = this.$el.modalBox('buttons').find('.save-button');
-
-        this.#codeMirror = CodeMirror.fromTextArea(this.#$policy[0], {
+        this.#codeMirror = CodeMirror.fromTextArea(this.#textarea, {
             gutters: ['CodeMirror-lint-markers'],
             lineNumbers: true,
             lineWrapping: true,
@@ -506,10 +474,80 @@ class PolicyEditorView extends BaseView<
     }
 
     /**
-     * Remove the policy editor from the page.
+     * Render the body of the dialog.
+     *
+     * Returns:
+     *     ComponentChild or Array of ComponentChild:
+     *     The content for the dialog body.
      */
-    protected onRemove() {
-        this.$el.modalBox('destroy');
+    protected renderBody(): ComponentChild | ComponentChild[] {
+        const manualURL = `${MANUAL_URL}webapi/2.0/api-token-policy/`;
+
+        let policy = this.model.get('policy');
+
+        if (_.isEmpty(policy)) {
+            policy = APIToken.defaultPolicies.custom;
+        }
+
+        this.#textarea = paint<HTMLTextAreaElement>`
+            <textarea>${JSON.stringify(policy, null, '  ')}</textarea>
+        `;
+
+        const $instructions = $('<p>')
+            .html(`
+                You can limit access to the API through a custom policy. See
+                the <a href="${manualURL}" target="_blank">documentation</a>
+                on how to write policies.
+            `);
+
+        return paint`
+            <div>
+             <p>${$instructions[0]}</p>
+             ${this.#textarea}
+            </div>
+        `;
+    }
+
+    /**
+     * Render the primary actions for the dialog.
+     *
+     * Returns:
+     *     ComponentChild or Array of ComponentChild:
+     *     The content for the primary actions.
+     */
+    protected renderPrimaryActions(): ComponentChild | ComponentChild[] {
+        this.#saveButtons = [
+            craft<ButtonView>`
+                <Ink.DialogAction class="save-button"
+                                  callback=${() => this.save(false)}>
+                 ${_`Save and continue editing`}
+                </Ink.DialogAction>
+            `,
+            craft<ButtonView>`
+                <Ink.DialogAction type="primary"
+                                  class="save-button"
+                                  callback=${() => this.save(true)}>
+                 ${_`Save`}
+                </Ink.DialogAction>
+            `,
+        ];
+
+        return this.#saveButtons;
+    }
+
+    /**
+     * Render the secondary actions for the dialog.
+     *
+     * Returns:
+     *     ComponentChild or Array of ComponentChild:
+     *     The content for the primary actions.
+     */
+    protected renderSecondaryActions(): ComponentChild | ComponentChild[] {
+        return paint`
+            <Ink.DialogAction callback=${() => this.cancel()}>
+             ${_`Cancel`}
+            </Ink.DialogAction>
+        `;
     }
 
     /**
@@ -536,7 +574,7 @@ class PolicyEditorView extends BaseView<
         let policy;
 
         try {
-            policy = JSON.parse(policyStr);
+            const policy = JSON.parse(policyStr);
         } catch (e) {
             if (e instanceof SyntaxError) {
                 alert(interpolate(
@@ -577,7 +615,11 @@ class PolicyEditorView extends BaseView<
      *         An array of the linter annotations.
      */
     _onUpdateLinting(annotationsNotSorted: unknown[]) {
-        this.#$saveButtons.prop('disabled', annotationsNotSorted.length > 0);
+        const disabled = (annotationsNotSorted.length > 0);
+
+        this.#saveButtons.forEach(button => {
+            button.disabled = disabled;
+        });
     }
 }
 
@@ -839,11 +881,28 @@ class APITokenItemView extends ConfigFormsListItemView<APITokenItem> {
         e.preventDefault();
         e.stopPropagation();
 
+        /*
+         * The drop-down menu doesn't automatically close in this case, even if
+         * we don't swallow the event. This is kind of an ugly experience
+         * because after the user closes the policy editor dialog, the menu
+         * will still be open.
+         *
+         * Ideally we'd fix this inside the ConfigFormsListItemView, but the
+         * way that action menus are done is very ugly, and it's all in need of
+         * a rewrite to use Ink anyway.
+         *
+         * For now, just artificially trigger a click on the document, which
+         * will be caught by the handler in
+         * ConfigFormsListItemView._showActionDropdown, and remove the menu.
+         */
+        $(document).trigger('click');
+
         const view = new PolicyEditorView({
             model: this.model,
             prevPolicyType: this.model.get('policyType'),
         });
         view.render();
+        view.open();
     }
 
     /**
@@ -853,22 +912,47 @@ class APITokenItemView extends ConfigFormsListItemView<APITokenItem> {
      * the server.
      */
     _onRemoveClicked() {
-        $('<p>')
-            .html(_`
-                This will prevent clients using this token when authenticating.
-            `)
-            .modalBox({
-                buttons: paint<HTMLButtonElement[]>`
-                    <Ink.Button type="danger"
-                                onClick=${() => this.model.resource.destroy()}>
-                     ${_`Remove`}
-                    </Ink.Button>
-                    <Ink.Button>
-                     ${_`Cancel`}
-                    </Ink.Button>
-                `,
-                title: _`Are you sure you want to remove this token?`,
-            });
+        const onRemoveClicked = async () => {
+            confirmButton.busy = true;
+            cancelButton.disabled = true;
+
+            await this.model.resource.destroy();
+
+            dialog.close();
+        }
+
+        const cancelButton = craft<ButtonView>`
+            <Ink.Button onClick=${() => dialog.close()}>
+             ${_`Cancel`}
+            </Ink.Button>
+        `;
+        const confirmButton = craft<ButtonView>`
+            <Ink.Button type="danger" onClick=${() => onRemoveClicked()}>
+             ${_`Remove`}
+            </Ink.Button>
+        `;
+
+        const dialog = craft<DialogView>`
+            <Ink.Dialog
+                onClose=${() => dialog.remove()}
+                title=${_`Are you sure you want to remove this token?`}>
+             <Ink.Dialog.Body>
+              <p>
+               ${_`
+                After removing this token, any clients which are configured
+                to use it will no longer be able to authenticate.
+               `}
+              </p>
+             </>
+             <Ink.Dialog.PrimaryActions>
+              ${cancelButton}
+              ${confirmButton}
+             </>
+            </>
+        `;
+
+        dialog.render();
+        dialog.open();
     }
 }
 
