← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:access-token-ui into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:access-token-ui into launchpad:master.

Commit message:
Add UI for personal access tokens

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/410560

This requires JavaScript in order to ensure that the new token secret is never stored in the database.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:access-token-ui into launchpad:master.
diff --git a/lib/lp/services/auth/browser.py b/lib/lp/services/auth/browser.py
new file mode 100644
index 0000000..5c9a35d
--- /dev/null
+++ b/lib/lp/services/auth/browser.py
@@ -0,0 +1,80 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""UI for personal access tokens."""
+
+__all__ = [
+    "AccessTokensView",
+    ]
+
+from lazr.restful.interface import (
+    copy_field,
+    use_template,
+    )
+from zope.component import getUtility
+from zope.interface import Interface
+
+from lp import _
+from lp.app.browser.launchpadform import (
+    action,
+    LaunchpadFormView,
+    )
+from lp.app.errors import UnexpectedFormData
+from lp.app.widgets.date import DateTimeWidget
+from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.services.auth.interfaces import (
+    IAccessToken,
+    IAccessTokenSet,
+    )
+from lp.services.propertycache import cachedproperty
+from lp.services.webapp.publisher import canonical_url
+
+
+class IAccessTokenCreateSchema(Interface):
+    """Schema for creating a personal access token."""
+
+    use_template(IAccessToken, include=[
+        "description",
+        "scopes",
+        ])
+
+    date_expires = copy_field(
+        IAccessToken["date_expires"],
+        description=_("When the token should expire."))
+
+
+class AccessTokensView(LaunchpadFormView):
+
+    schema = IAccessTokenCreateSchema
+    custom_widget_scopes = LabeledMultiCheckBoxWidget
+    custom_widget_date_expires = DateTimeWidget
+
+    @property
+    def label(self):
+        return "Personal access tokens for %s" % self.context.display_name
+
+    page_title = "Personal access tokens"
+
+    @cachedproperty
+    def access_tokens(self):
+        return list(getUtility(IAccessTokenSet).findByTarget(
+            self.context, visible_by_user=self.user))
+
+    @action("Revoke", name="revoke")
+    def revoke_action(self, action, data):
+        form = self.request.form
+        token_id = form.get("token_id")
+        if token_id is None:
+            raise UnexpectedFormData("Missing token_id")
+        try:
+            token_id = int(token_id)
+        except ValueError:
+            raise UnexpectedFormData("token_id is not an integer")
+        token = getUtility(IAccessTokenSet).getByTargetAndID(
+            self.context, token_id, visible_by_user=self.user)
+        if token is not None:
+            token.revoke(self.user)
+            self.request.response.addInfoNotification(
+                "Token revoked successfully.")
+        self.request.response.redirect(
+            canonical_url(self.context, view_name="+access-tokens"))
diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
index f300218..c0446d6 100644
--- a/lib/lp/services/auth/configure.zcml
+++ b/lib/lp/services/auth/configure.zcml
@@ -31,5 +31,12 @@
         path_expression="string:+access-token/${id}"
         attribute_to_parent="target" />
 
+    <browser:page
+        for="lp.services.auth.interfaces.IAccessTokenTarget"
+        name="+access-tokens"
+        permission="launchpad.Edit"
+        class="lp.services.auth.browser.AccessTokensView"
+        template="templates/accesstokentarget-access-tokens.pt" />
+
     <webservice:register module="lp.services.auth.webservice" />
 </configure>
diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
index 7e20ee0..d18d51c 100644
--- a/lib/lp/services/auth/interfaces.py
+++ b/lib/lp/services/auth/interfaces.py
@@ -48,44 +48,54 @@ class IAccessToken(Interface):
     id = Int(title=_("ID"), required=True, readonly=True)
 
     date_created = exported(Datetime(
-        title=_("When the token was created."), required=True, readonly=True))
+        title=_("Creation date"),
+        description=_("When the token was created."),
+        required=True, readonly=True))
 
     owner = exported(PublicPersonChoice(
-        title=_("The person who created the token."),
+        title=_("Owner"),
+        description=_("The person who created the token."),
         vocabulary="ValidPersonOrTeam", required=True, readonly=True))
 
     description = exported(TextLine(
-        title=_("A short description of the token."), required=True))
+        title=_("Description"),
+        description=_("A short description of the token."), required=True))
 
     git_repository = Reference(
-        title=_("The Git repository for which the token was issued."),
+        title=_("Git repository"),
+        description=_("The Git repository for which the token was issued."),
         # Really IGitRepository, patched in _schema_circular_imports.py.
         schema=Interface, required=True, readonly=True)
 
     target = exported(Reference(
-        title=_("The target for which the token was issued."),
+        title=_("Target"),
+        description=_("The target for which the token was issued."),
         # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
         schema=Interface, required=True, readonly=True))
 
     scopes = exported(List(
         value_type=Choice(vocabulary=AccessTokenScope),
-        title=_("A list of scopes granted by the token."),
+        title=_("Scopes"),
+        description=_("A list of scopes granted by the token."),
         required=True, readonly=True))
 
     date_last_used = exported(Datetime(
-        title=_("When the token was last used."),
+        title=_("Date last used"),
+        description=_("When the token was last used."),
         required=False, readonly=True))
 
     date_expires = exported(Datetime(
-        title=_("When the token should expire or was revoked."),
+        title=_("Expiry date"),
+        description=_("When the token should expire or was revoked."),
         required=False, readonly=True))
 
     is_expired = Bool(
-        title=_("Whether this token has expired."),
+        description=_("Whether this token has expired."),
         required=False, readonly=True)
 
     revoked_by = exported(PublicPersonChoice(
-        title=_("The person who revoked the token, if any."),
+        title=_("Revoked by"),
+        description=_("The person who revoked the token, if any."),
         vocabulary="ValidPersonOrTeam", required=False, readonly=True))
 
     def updateLastUsed():
@@ -135,6 +145,15 @@ class IAccessTokenSet(Interface):
             by this user.
         """
 
+    def getByTargetAndID(target, token_id, visible_by_user=None):
+        """Return the access token with this target and ID, or None.
+
+        :param target: An `IAccessTokenTarget`.
+        :param token_id: An `AccessToken` ID.
+        :param visible_by_user: If given, return only access tokens visible
+            by this user.
+        """
+
 
 class IAccessTokenVerifiedRequest(Interface):
     """Marker interface for a request with a verified access token."""
diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.html b/lib/lp/services/auth/javascript/tests/test_tokens.html
new file mode 100644
index 0000000..f7887fb
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+Copyright 2021 Canonical Ltd.  This software is licensed under the
+GNU Affero General Public License version 3 (see the file LICENSE).
+-->
+
+<html>
+  <head>
+    <title>Personal access token widget tests</title>
+
+    <!-- YUI and test setup -->
+    <script type="text/javascript"
+            src="../../../../../../build/js/yui/yui/yui.js">
+    </script>
+    <link rel="stylesheet"
+    href="../../../../../../build/js/yui/console/assets/console-core.css" />
+    <link rel="stylesheet"
+    href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
+    <link rel="stylesheet"
+    href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
+
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/testing/helpers.js"></script>
+
+    <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
+
+    <!-- Dependencies -->
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/anim/anim.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/client.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/effects/effects.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/errors.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/expander.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/extras/extras.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/lp.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
+    <script type="text/javascript"
+            src="../../../../../../build/js/lp/app/ui/ui.js"></script>
+
+    <!-- The module under test. -->
+    <script type="text/javascript" src="../tokens.js"></script>
+
+    <!-- The test suite -->
+    <script type="text/javascript" src="test_tokens.js"></script>
+
+    <script id="fixture-template" type="text/x-template">
+      <div id="create-token">
+        <table class="form">
+          <tr>
+            <td>
+              <label for="field.description">Description:</label>
+              <input class="textType" id="field.description"
+                     name="field.description" size="20" type="text" value="" />
+            </td>
+            <td>
+              <label for="field.scopes">Scopes:</label>
+              <label for="field.scopes.0">
+                <input class="checkboxType" id="field.scopes.0"
+                       name="field.scopes" type="checkbox"
+                       value="REPOSITORY_BUILD_STATUS"
+                       />&nbsp;repository:build_status
+              </label>
+              <br />
+              <label for="field.scopes.1">
+                <input class="checkboxType" id="field.scopes.1"
+                       name="field.scopes" type="checkbox"
+                       value="REPOSITORY_PUSH" />&nbsp;repository:push
+              </label>
+              <input name="field.scopes.empty-marker" type="hidden"
+                     value="1" />
+            </td>
+            <td>
+              <label for="field.date_expires">Expiry date:</label>
+              <input size="19" type="text" class="yui2-calendar withtime"
+                     id="field.date_expires" name="field.date_expires" />
+              in time zone: UTC
+            </td>
+          </tr>
+        </table>
+
+        <p>
+          <input id="create-token-button" class="js-only"
+                 type="button" value="Create token" />
+          <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
+        </p>
+        <div id="new-token-information" class="hidden">
+          <p>Your new personal access token is:</p>
+          <pre id="new-token-secret" class="subordinate"></pre>
+          <p>
+            Launchpad will not show you this again, so make sure to save it
+            now.
+          </p>
+        </div>
+      </div>
+
+      <table class="listing access-tokens-table">
+        <tbody id="access-tokens-tbody" />
+      </table>
+    </script>
+  </head>
+  <body class="yui3-skin-sam">
+    <ul id="suites">
+        <li>lp.services.auth.tokens.test</li>
+    </ul>
+
+    <div id="fixture" />
+  </body>
+</html>
diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.js b/lib/lp/services/auth/javascript/tests/test_tokens.js
new file mode 100644
index 0000000..347b3d3
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tests/test_tokens.js
@@ -0,0 +1,181 @@
+/* Copyright 2021 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE). */
+
+YUI.add('lp.services.auth.tokens.test', function (Y) {
+
+    var tests = Y.namespace('lp.services.auth.tokens.test');
+    tests.suite = new Y.Test.Suite('lp.services.auth.tokens Tests');
+
+    tests.suite.add(new Y.Test.Case({
+        name: 'lp.services.auth.tokens_tests',
+
+        setUp: function () {
+            this.node = Y.Node.create(Y.one('#fixture-template').getContent());
+            Y.one('#fixture').append(this.node);
+            this.widget = new Y.lp.services.auth.tokens.CreateTokenWidget({
+                srcNode: Y.one('#create-token'),
+                target_uri: '/api/devel/repo'
+            });
+            this.mockio = new Y.lp.testing.mockio.MockIo();
+            this.widget.client.io_provider = this.mockio;
+            this.old_error_method = Y.lp.app.errors.display_error;
+        },
+
+        tearDown: function () {
+            this.widget.destroy();
+            Y.one('#fixture').empty();
+            Y.lp.app.errors.display_error = this.old_error_method;
+        },
+
+        test_library_exists: function () {
+            Y.Assert.isObject(Y.lp.services.auth.tokens,
+                'Could not locate the lp.services.auth.tokens module');
+        },
+
+        test_widget_can_be_instantiated: function () {
+            Y.Assert.isInstanceOf(
+                Y.lp.services.auth.tokens.CreateTokenWidget,
+                this.widget, 'Widget failed to be instantiated');
+        },
+
+        test_create_no_description: function () {
+            var error_shown = false;
+            Y.lp.app.errors.display_error = function(flash_node, msg) {
+                Y.Assert.areEqual(
+                    Y.one('[name="field.description"]'), flash_node);
+                Y.Assert.areEqual(
+                    'A personal access token must have a description.', msg);
+                error_shown = true;
+            };
+
+            this.widget.render();
+            Y.one('#create-token-button').simulate('click');
+            Y.Assert.isTrue(error_shown);
+        },
+
+        test_create_no_scopes: function () {
+            var error_shown = false;
+            Y.lp.app.errors.display_error = function(flash_node, msg) {
+                Y.Assert.isNull(flash_node);
+                Y.Assert.areEqual(
+                    'A personal access token must have scopes.', msg);
+                error_shown = true;
+            };
+
+            Y.one('[name="field.description"]').set('value', 'Test');
+            this.widget.render();
+            Y.one('#create-token-button').simulate('click');
+            Y.Assert.isTrue(error_shown);
+        },
+
+        test_create_failure: function () {
+            var error_shown = false;
+            Y.lp.app.errors.display_error = function(flash_node, msg) {
+                Y.Assert.isNull(flash_node);
+                Y.Assert.areEqual(
+                    'Failed to create personal access token.', msg);
+                error_shown = true;
+            };
+
+            Y.one('[name="field.description"]').set(
+                'value', 'Test description');
+            Y.all('[name="field.scopes"]').item(1).set('checked', true);
+            this.widget.render();
+            Y.one('#create-token-button').simulate('click');
+            Y.Assert.isFalse(error_shown);
+
+            this.mockio.failure();
+            Y.Assert.isTrue(error_shown);
+        },
+
+        test_create: function () {
+            var error_shown = false;
+            Y.lp.app.errors.display_error = function(flash_node, msg) {
+                error_shown = true;
+            };
+
+            Y.one('[name="field.description"]').set(
+                'value', 'Test description');
+            Y.all('[name="field.scopes"]').item(1).set('checked', true);
+            this.widget.render();
+            Y.one('#create-token-button').simulate('click');
+            Y.Assert.isFalse(error_shown);
+            Y.Assert.areEqual(1, this.mockio.requests.length);
+            Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
+            Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
+            Y.Assert.areEqual(
+                'ws.op=issueAccessToken' +
+                '&description=Test%20description' +
+                '&scopes=repository%3Apush',
+                this.mockio.last_request.config.data);
+            Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
+
+            this.mockio.success({
+                responseHeaders: {'Content-Type': 'application/json'},
+                responseText: Y.JSON.stringify('test-secret')
+            });
+            Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
+            Y.Assert.areEqual(
+                'test-secret', Y.one('#new-token-secret').get('text'));
+            Y.Assert.isFalse(
+                Y.one('#new-token-information').hasClass('hidden'));
+            var token_row = Y.one('#access-tokens-tbody tr');
+            Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
+            Y.ArrayAssert.itemsAreEqual(
+                ['Test description', 'repository:push', 'a moment ago',
+                 '', 'Never', ''],
+                token_row.all('td').map(function (node) {
+                    return node.get('text');
+                }));
+        },
+
+        test_create_with_expiry: function () {
+            var error_shown = false;
+            Y.lp.app.errors.display_error = function(flash_node, msg) {
+                error_shown = true;
+            };
+
+            Y.one('[name="field.description"]').set(
+                'value', 'Test description');
+            Y.all('[name="field.scopes"]').item(0).set('checked', true);
+            Y.one('[name="field.date_expires"]').set('value', '2021-01-01');
+            this.widget.render();
+            Y.one('#create-token-button').simulate('click');
+            Y.Assert.isFalse(error_shown);
+            Y.Assert.areEqual(1, this.mockio.requests.length);
+            Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
+            Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
+            Y.Assert.areEqual(
+                'ws.op=issueAccessToken' +
+                '&description=Test%20description' +
+                '&scopes=repository%3Abuild_status' +
+                '&date_expires=2021-01-01',
+                this.mockio.last_request.config.data);
+            Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
+
+            this.mockio.success({
+                responseHeaders: {'Content-Type': 'application/json'},
+                responseText: Y.JSON.stringify('test-secret')
+            });
+            Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
+            Y.Assert.areEqual(
+                'test-secret', Y.one('#new-token-secret').get('text'));
+            Y.Assert.isFalse(
+                Y.one('#new-token-information').hasClass('hidden'));
+            var token_row = Y.one('#access-tokens-tbody tr');
+            Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
+            Y.ArrayAssert.itemsAreEqual(
+                ['Test description', 'repository:build_status', 'a moment ago',
+                 '', '2021-01-01', ''],
+                token_row.all('td').map(function (node) {
+                    return node.get('text');
+                }));
+        }
+    }));
+
+}, '0.1', {
+    requires: [
+        'json-stringify', 'node-event-simulate', 'test',
+        'lp.services.auth.tokens', 'lp.testing.mockio'
+    ]
+});
diff --git a/lib/lp/services/auth/javascript/tokens.js b/lib/lp/services/auth/javascript/tokens.js
new file mode 100644
index 0000000..81b4d4b
--- /dev/null
+++ b/lib/lp/services/auth/javascript/tokens.js
@@ -0,0 +1,151 @@
+/* Copyright 2021 Canonical Ltd.  This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * Personal access token widgets.
+ *
+ * @module lp.services.auth.tokens
+ * @requires node, widget, lp.app.errors, lp.client, lp.extras, lp.ui-base
+ */
+
+YUI.add('lp.services.auth.tokens', function(Y) {
+    var module = Y.namespace('lp.services.auth.tokens');
+
+    var CreateTokenWidget = function() {
+        CreateTokenWidget.superclass.constructor.apply(this, arguments);
+    };
+
+    CreateTokenWidget.NAME = 'create-token-widget';
+
+    CreateTokenWidget.ATTRS = {
+        /**
+         * The URI for the target for new tokens.
+         *
+         * @attribute target_uri
+         * @type String
+         * @default null
+         */
+        target_uri: {
+            value: null
+        }
+    };
+
+    Y.extend(CreateTokenWidget, Y.Widget, {
+        initializer: function(cfg) {
+            this.client = new Y.lp.client.Launchpad();
+            this.set('target_uri', cfg.target_uri);
+        },
+
+        /**
+         * Show the spinner.
+         *
+         * @method showSpinner
+         */
+        showSpinner: function() {
+            this.get('srcNode').all('.spinner').removeClass('hidden');
+        },
+
+        /**
+         * Hide the spinner.
+         *
+         * @method hideSpinner
+         */
+        hideSpinner: function() {
+            this.get('srcNode').all('.spinner').addClass('hidden');
+        },
+
+        /**
+         * Create a new token.
+         *
+         * @method createToken
+         */
+        createToken: function() {
+            var container = this.get('srcNode');
+            var description_node = container.one('[name="field.description"]');
+            var description = description_node.get('value');
+            if (description === '') {
+                Y.lp.app.errors.display_error(
+                    description_node,
+                    'A personal access token must have a description.');
+                return;
+            }
+            var scopes = container.all('[name="field.scopes"]')
+                .filter(':checked')
+                .map(function (node) {
+                    var node_id = node.get('id');
+                    var label = container.one('label[for="' + node_id + '"]');
+                    return label.get('text').trim();
+                });
+            if (scopes.length === 0) {
+                Y.lp.app.errors.display_error(
+                    null, 'A personal access token must have scopes.');
+                return;
+            }
+            var date_expires = container
+                .one('[name="field.date_expires"]').get('value');
+            var self = this;
+            var config = {
+                on: {
+                    start: function() {
+                        self.showSpinner();
+                    },
+                    end: function() {
+                        self.hideSpinner();
+                    },
+                    success: function(response) {
+                        container.one('#new-token-secret')
+                            .set('text', response);
+                        container.one('#new-token-information')
+                            .removeClass('hidden');
+                        var tokens_tbody = Y.one('#access-tokens-tbody');
+                        tokens_tbody.append(Y.Node.create('<tr />')
+                            .addClass(
+                                tokens_tbody.all('tr').size() % 2
+                                    ? Y.lp.ui.CSS_ODD : Y.lp.ui.CSS_EVEN)
+                            .append(Y.Node.create('<td />')
+                                .set('text', description))
+                            .append(Y.Node.create('<td />')
+                                .set('text', scopes.join(', ')))
+                            .append(Y.Node.create('<td />')
+                                .set('text', 'a moment ago'))
+                            .append(Y.Node.create('<td />'))
+                            .append(Y.Node.create('<td />')
+                                .set('text',
+                                     date_expires !== ''
+                                        ? date_expires : 'Never'))
+                            .append(Y.Node.create('<td />')));
+                    },
+                    failure: function(ignore, response, args) {
+                        Y.lp.app.errors.display_error(
+                            null, 'Failed to create personal access token.');
+                    }
+                },
+                parameters: {
+                    description: description,
+                    scopes: scopes
+                }
+            };
+            if (date_expires !== "") {
+                config.parameters.date_expires = date_expires;
+            }
+            this.client.named_post(
+                this.get('target_uri'), 'issueAccessToken', config);
+        },
+
+        bindUI: function() {
+            this.constructor.superclass.bindUI.call(this);
+            var create_token_button = this.get('srcNode')
+                .one('#create-token-button');
+            if (Y.Lang.isValue(create_token_button)) {
+                var self = this;
+                create_token_button.on('click', function(e) {
+                    e.halt();
+                    self.createToken();
+                });
+            }
+        }
+    });
+
+    module.CreateTokenWidget = CreateTokenWidget;
+}, '0.1', {'requires': [
+    'node', 'widget', 'lp.app.errors', 'lp.client', 'lp.extras', 'lp.ui-base'
+]});
diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
index 39a6684..b1cc7fc 100644
--- a/lib/lp/services/auth/model.py
+++ b/lib/lp/services/auth/model.py
@@ -186,7 +186,16 @@ class AccessTokenSet:
                         removeSecurityProxy(ids)._get_select())))
         else:
             raise TypeError("Unsupported target: {!r}".format(target))
-        return IStore(AccessToken).find(AccessToken, *clauses)
+        clauses.append(Or(
+            AccessToken.date_expires == None,
+            AccessToken.date_expires > UTC_NOW))
+        return IStore(AccessToken).find(AccessToken, *clauses).order_by(
+            AccessToken.date_created)
+
+    def getByTargetAndID(self, target, token_id, visible_by_user=None):
+        """See `IAccessTokenSet`."""
+        return self.findByTarget(target, visible_by_user=visible_by_user).find(
+            id=token_id).one()
 
 
 class AccessTokenTargetMixin:
diff --git a/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
new file mode 100644
index 0000000..6d85df0
--- /dev/null
+++ b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
@@ -0,0 +1,122 @@
+<html
+  xmlns="http://www.w3.org/1999/xhtml";
+  xmlns:tal="http://xml.zope.org/namespaces/tal";
+  xmlns:metal="http://xml.zope.org/namespaces/metal";
+  xmlns:i18n="http://xml.zope.org/namespaces/i18n";
+  metal:use-macro="view/macro:page/main_only"
+  i18n:domain="launchpad"
+>
+<body>
+
+<metal:block fill-slot="head_epilogue">
+  <style type="text/css">
+    .js-only {
+      display: none;
+    }
+    .yui3-js-enabled .js-only {
+      display: inline;
+    }
+  </style>
+
+  <script type="text/javascript">
+    LPJS.use('node', 'lp.services.auth.tokens', function(Y) {
+      Y.on('domready', function() {
+        var ns = Y.lp.services.auth.tokens;
+        var create_token_widget = new ns.CreateTokenWidget({
+          srcNode: Y.one('#create-token'),
+          target_uri: LP.cache.context.self_link
+        });
+        create_token_widget.render();
+      });
+    });
+  </script>
+</metal:block>
+
+<div metal:fill-slot="main">
+  <p>Personal access tokens allow using certain parts of the Launchpad API.</p>
+
+  <h2>Create a token</h2>
+  <div id="create-token">
+    <metal:form use-macro="context/@@launchpad_form/form">
+      <metal:formbody fill-slot="widgets">
+        <table class="form">
+          <tal:widget define="widget nocall:view/widgets/description">
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/scopes">
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+          <tal:widget define="widget nocall:view/widgets/date_expires">
+            <metal:block use-macro="context/@@launchpad_form/widget_row" />
+          </tal:widget>
+        </table>
+      </metal:formbody>
+
+      <metal:buttons fill-slot="buttons">
+        <p>
+          <input id="create-token-button" class="js-only"
+                 type="button" value="Create token" />
+          <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
+          <noscript><strong>
+            Creating personal access tokens requires JavaScript.
+          </strong></noscript>
+        </p>
+        <div id="new-token-information" class="hidden">
+          <p>Your new personal access token is:</p>
+          <pre id="new-token-secret" class="subordinate"></pre>
+          <p>
+            Launchpad will not show you this again, so make sure to save it
+            now.
+          </p>
+        </div>
+      </metal:buttons>
+    </metal:form>
+  </div>
+
+  <h2>Active tokens</h2>
+  <table class="listing access-tokens-table"
+         style="max-width: 80em;">
+    <thead>
+      <tr>
+        <th>Description</th>
+        <th>Scopes</th>
+        <th>Created</th>
+        <th>Last used</th>
+        <th>Expires</th>
+        <th><tal:comment condition="nothing">Revoke button</tal:comment></th>
+      </tr>
+    </thead>
+    <tbody id="access-tokens-tbody">
+      <tr tal:repeat="token view/access_tokens"
+          tal:attributes="
+            class python: 'yui3-lazr-even' if repeat['token'].even()
+                          else 'yui3-lazr-odd';
+            token-id token/id">
+        <td tal:content="token/description" />
+        <td tal:content="
+          python: ', '.join(scope.title for scope in token.scopes)" />
+        <td tal:content="
+          structure token/date_created/fmt:approximatedatetitle" />
+        <td tal:content="
+          structure token/date_last_used/fmt:approximatedatetitle" />
+        <td>
+          <tal:expires
+            condition="token/date_expires"
+            replace="
+              structure token/date_expires/fmt:approximatedatetitle" />
+          <span tal:condition="not: token/date_expires">Never</span>
+        </td>
+        <td>
+          <form method="post" tal:attributes="name string:revoke-${token/id}">
+            <input type="hidden" name="token_id"
+                   tal:attributes="value token/id" />
+            <input type="submit" name="field.actions.revoke" value="Revoke" />
+          </form>
+        </td>
+      </tr>
+    </tbody>
+  </table>
+</div>
+
+</body>
+</html>
diff --git a/lib/lp/services/auth/tests/test_browser.py b/lib/lp/services/auth/tests/test_browser.py
new file mode 100644
index 0000000..6ec0b70
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_browser.py
@@ -0,0 +1,151 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test personal access token views."""
+
+import re
+
+import soupmatchers
+from testtools.matchers import (
+    Equals,
+    Is,
+    MatchesAll,
+    MatchesListwise,
+    MatchesStructure,
+    Not,
+    )
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.services.webapp.interfaces import IPlacelessAuthUtility
+from lp.services.webapp.publisher import canonical_url
+from lp.testing import (
+    login_person,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.views import create_view
+
+
+breadcrumbs_tag = soupmatchers.Tag(
+    "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
+tokens_page_crumb_tag = soupmatchers.Tag(
+    "tokens page breadcrumb", "li", text=re.compile(r"Personal access tokens"))
+token_listing_constants = soupmatchers.HTMLContains(
+    soupmatchers.Within(breadcrumbs_tag, tokens_page_crumb_tag))
+token_listing_tag = soupmatchers.Tag(
+    "tokens table", "table", attrs={"class": "listing"})
+
+
+class TestAccessTokenViewBase:
+
+    layer = DatabaseFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.target = self.makeTarget()
+        self.owner = self.target.owner
+        login_person(self.owner)
+
+    def makeView(self, name, **kwargs):
+        # XXX cjwatson 2021-10-19: We need to give the view a
+        # LaunchpadPrincipal rather than just a person, since otherwise bits
+        # of the navigation menu machinery try to use the scope_url
+        # attribute on the principal and fail.  This should probably be done
+        # in create_view instead, but that approach needs care to avoid
+        # adding an extra query to tests that might be sensitive to that.
+        principal = getUtility(IPlacelessAuthUtility).getPrincipal(
+            self.owner.accountID)
+        view = create_view(
+            self.target, name, principal=principal, current_request=True,
+            **kwargs)
+        # To test the breadcrumbs we need a correct traversal stack.
+        view.request.traversed_objects = (
+            self.getTraversalStack(self.target) + [view])
+        # The navigation menu machinery needs this to find the view from the
+        # request.
+        view.request._last_obj_traversed = view
+        view.initialize()
+        return view
+
+    def makeTokensAndMatchers(self, count):
+        tokens = [
+            self.factory.makeAccessToken(target=self.target)[1]
+            for _ in range(count)]
+        # There is a row for each token.
+        matchers = []
+        for token in tokens:
+            row_tag = soupmatchers.Tag(
+                "token row", "tr",
+                attrs={"token-id": removeSecurityProxy(token).id})
+            column_tags = [
+                soupmatchers.Tag(
+                    "description", "td", text=token.description),
+                soupmatchers.Tag(
+                    "scopes", "td",
+                    text=", ".join(scope.title for scope in token.scopes)),
+                ]
+            matchers.extend([
+                soupmatchers.Within(row_tag, column_tag)
+                for column_tag in column_tags])
+        return matchers
+
+    def test_empty(self):
+        self.assertThat(
+            self.makeView("+access-tokens")(),
+            MatchesAll(
+                token_listing_constants,
+                soupmatchers.HTMLContains(token_listing_tag)))
+
+    def test_existing_tokens(self):
+        token_matchers = self.makeTokensAndMatchers(10)
+        self.assertThat(
+            self.makeView("+access-tokens")(),
+            MatchesAll(
+                token_listing_constants,
+                soupmatchers.HTMLContains(token_listing_tag, *token_matchers)))
+
+    def test_revoke(self):
+        tokens = [
+            self.factory.makeAccessToken(target=self.target)[1]
+            for _ in range(3)]
+        token_ids = [token.id for token in tokens]
+        access_tokens_url = canonical_url(
+            self.target, view_name="+access-tokens")
+        browser = self.getUserBrowser(access_tokens_url, user=self.owner)
+        for token_id in token_ids:
+            self.assertThat(
+                browser.getForm(name="revoke-%s" % token_id).controls,
+                MatchesListwise([
+                    MatchesStructure.byEquality(
+                        type="hidden", name="token_id", value=str(token_id)),
+                    MatchesStructure.byEquality(
+                        type="submit", name="field.actions.revoke",
+                        value="Revoke"),
+                    ]))
+        browser.getForm(name="revoke-%s" % token_ids[1]).getControl(
+            "Revoke").click()
+        login_person(self.owner)
+        self.assertEqual(access_tokens_url, browser.url)
+        self.assertThat(tokens[0], MatchesStructure(
+            id=Equals(token_ids[0]),
+            date_expires=Is(None),
+            revoked_by=Is(None)))
+        self.assertThat(tokens[1], MatchesStructure(
+            id=Equals(token_ids[1]),
+            date_expires=Not(Is(None)),
+            revoked_by=Equals(self.owner)))
+        self.assertThat(tokens[2], MatchesStructure(
+            id=Equals(token_ids[2]),
+            date_expires=Is(None),
+            revoked_by=Is(None)))
+
+
+class TestAccessTokenViewGitRepository(
+        TestAccessTokenViewBase, TestCaseWithFactory):
+
+    def makeTarget(self):
+        return self.factory.makeGitRepository()
+
+    def getTraversalStack(self, obj):
+        return [obj.target, obj]
diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
index c64dbd9..805b02c 100644
--- a/lib/lp/services/auth/tests/test_model.py
+++ b/lib/lp/services/auth/tests/test_model.py
@@ -251,6 +251,87 @@ class TestAccessTokenSet(TestCaseWithFactory):
                     targets[target_index],
                     visible_by_user=owners[owner_index]))
 
+    def test_findByTarget_excludes_expired(self):
+        target = self.factory.makeGitRepository()
+        _, current_token = self.factory.makeAccessToken(target=target)
+        _, expires_soon_token = self.factory.makeAccessToken(
+            target=target,
+            date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
+        _, expired_token = self.factory.makeAccessToken(
+            target=target,
+            date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
+        self.assertContentEqual(
+            [current_token, expires_soon_token],
+            getUtility(IAccessTokenSet).findByTarget(target))
+
+    def test_getByTargetAndID(self):
+        targets = [self.factory.makeGitRepository() for _ in range(3)]
+        tokens = [
+            self.factory.makeAccessToken(target=targets[0])[1],
+            self.factory.makeAccessToken(target=targets[0])[1],
+            self.factory.makeAccessToken(target=targets[1])[1],
+            ]
+        self.assertEqual(
+            tokens[0],
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                targets[0], removeSecurityProxy(tokens[0]).id))
+        self.assertEqual(
+            tokens[1],
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                targets[0], removeSecurityProxy(tokens[1]).id))
+        self.assertIsNone(
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                targets[0], removeSecurityProxy(tokens[2]).id))
+
+    def test_getByTargetAndID_visible_by_user(self):
+        targets = [self.factory.makeGitRepository() for _ in range(3)]
+        owners = [self.factory.makePerson() for _ in range(3)]
+        tokens = [
+            self.factory.makeAccessToken(
+                owner=owners[owner_index], target=targets[target_index])[1]
+            for owner_index, target_index in (
+                (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
+        for owner_index, target_index, expected_tokens in (
+                (0, 0, tokens[:2]),
+                (0, 1, []),
+                (0, 2, []),
+                (1, 0, [tokens[2]]),
+                (1, 1, [tokens[3]]),
+                (1, 2, []),
+                (2, 0, []),
+                (2, 1, [tokens[4]]),
+                (2, 2, []),
+                ):
+            for token in tokens:
+                fetched_token = getUtility(IAccessTokenSet).getByTargetAndID(
+                    targets[target_index], removeSecurityProxy(token).id,
+                    visible_by_user=owners[owner_index])
+                if token in expected_tokens:
+                    self.assertEqual(token, fetched_token)
+                else:
+                    self.assertIsNone(fetched_token)
+
+    def test_getByTargetAndID_excludes_expired(self):
+        target = self.factory.makeGitRepository()
+        _, current_token = self.factory.makeAccessToken(target=target)
+        _, expires_soon_token = self.factory.makeAccessToken(
+            target=target,
+            date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
+        _, expired_token = self.factory.makeAccessToken(
+            target=target,
+            date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
+        self.assertEqual(
+            current_token,
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                target, removeSecurityProxy(current_token).id))
+        self.assertEqual(
+            expires_soon_token,
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                target, removeSecurityProxy(expires_soon_token).id))
+        self.assertIsNone(
+            getUtility(IAccessTokenSet).getByTargetAndID(
+                target, removeSecurityProxy(expired_token).id))
+
 
 class TestAccessTokenTargetBase:
 
diff --git a/lib/lp/services/auth/tests/test_yuitests.py b/lib/lp/services/auth/tests/test_yuitests.py
new file mode 100644
index 0000000..069cc9d
--- /dev/null
+++ b/lib/lp/services/auth/tests/test_yuitests.py
@@ -0,0 +1,23 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Run YUI.test tests."""
+
+__all__ = []
+
+from lp.testing import (
+    build_yui_unittest_suite,
+    YUIUnitTestCase,
+    )
+from lp.testing.layers import YUITestLayer
+
+
+class AuthYUIUnitTestCase(YUIUnitTestCase):
+
+    layer = YUITestLayer
+    suite_name = "AuthYUIUnitTests"
+
+
+def test_suite():
+    app_testing_path = "lp/services/auth"
+    return build_yui_unittest_suite(app_testing_path, AuthYUIUnitTestCase)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 9b500b4..2856fa0 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -4538,6 +4538,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         token = getUtility(IAccessTokenSet).new(
             secret, owner, description, target, scopes,
             date_expires=date_expires)
+        IStore(token).flush()
         return secret, token
 
     def makeCVE(self, sequence, description=None,