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

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: 81db109277f6ff9531e33157d7fc31f61e243939
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:access-token-ui
Merge into: launchpad:master
Diff against target: 1099 lines (+957/-11)
12 files modified
lib/lp/services/auth/browser.py (+80/-0)
lib/lp/services/auth/configure.zcml (+7/-0)
lib/lp/services/auth/interfaces.py (+29/-10)
lib/lp/services/auth/javascript/tests/test_tokens.html (+121/-0)
lib/lp/services/auth/javascript/tests/test_tokens.js (+181/-0)
lib/lp/services/auth/javascript/tokens.js (+151/-0)
lib/lp/services/auth/model.py (+10/-1)
lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt (+122/-0)
lib/lp/services/auth/tests/test_browser.py (+151/-0)
lib/lp/services/auth/tests/test_model.py (+81/-0)
lib/lp/services/auth/tests/test_yuitests.py (+23/-0)
lib/lp/testing/factory.py (+1/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc Approve
Review via email: mp+410560@code.launchpad.net

Commit message

Add UI for personal access tokens

Description of the change

This requires JavaScript in order to ensure that the new token secret is never stored in the database.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/services/auth/browser.py b/lib/lp/services/auth/browser.py
2new file mode 100644
3index 0000000..5c9a35d
4--- /dev/null
5+++ b/lib/lp/services/auth/browser.py
6@@ -0,0 +1,80 @@
7+# Copyright 2021 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""UI for personal access tokens."""
11+
12+__all__ = [
13+ "AccessTokensView",
14+ ]
15+
16+from lazr.restful.interface import (
17+ copy_field,
18+ use_template,
19+ )
20+from zope.component import getUtility
21+from zope.interface import Interface
22+
23+from lp import _
24+from lp.app.browser.launchpadform import (
25+ action,
26+ LaunchpadFormView,
27+ )
28+from lp.app.errors import UnexpectedFormData
29+from lp.app.widgets.date import DateTimeWidget
30+from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
31+from lp.services.auth.interfaces import (
32+ IAccessToken,
33+ IAccessTokenSet,
34+ )
35+from lp.services.propertycache import cachedproperty
36+from lp.services.webapp.publisher import canonical_url
37+
38+
39+class IAccessTokenCreateSchema(Interface):
40+ """Schema for creating a personal access token."""
41+
42+ use_template(IAccessToken, include=[
43+ "description",
44+ "scopes",
45+ ])
46+
47+ date_expires = copy_field(
48+ IAccessToken["date_expires"],
49+ description=_("When the token should expire."))
50+
51+
52+class AccessTokensView(LaunchpadFormView):
53+
54+ schema = IAccessTokenCreateSchema
55+ custom_widget_scopes = LabeledMultiCheckBoxWidget
56+ custom_widget_date_expires = DateTimeWidget
57+
58+ @property
59+ def label(self):
60+ return "Personal access tokens for %s" % self.context.display_name
61+
62+ page_title = "Personal access tokens"
63+
64+ @cachedproperty
65+ def access_tokens(self):
66+ return list(getUtility(IAccessTokenSet).findByTarget(
67+ self.context, visible_by_user=self.user))
68+
69+ @action("Revoke", name="revoke")
70+ def revoke_action(self, action, data):
71+ form = self.request.form
72+ token_id = form.get("token_id")
73+ if token_id is None:
74+ raise UnexpectedFormData("Missing token_id")
75+ try:
76+ token_id = int(token_id)
77+ except ValueError:
78+ raise UnexpectedFormData("token_id is not an integer")
79+ token = getUtility(IAccessTokenSet).getByTargetAndID(
80+ self.context, token_id, visible_by_user=self.user)
81+ if token is not None:
82+ token.revoke(self.user)
83+ self.request.response.addInfoNotification(
84+ "Token revoked successfully.")
85+ self.request.response.redirect(
86+ canonical_url(self.context, view_name="+access-tokens"))
87diff --git a/lib/lp/services/auth/configure.zcml b/lib/lp/services/auth/configure.zcml
88index f300218..c0446d6 100644
89--- a/lib/lp/services/auth/configure.zcml
90+++ b/lib/lp/services/auth/configure.zcml
91@@ -31,5 +31,12 @@
92 path_expression="string:+access-token/${id}"
93 attribute_to_parent="target" />
94
95+ <browser:page
96+ for="lp.services.auth.interfaces.IAccessTokenTarget"
97+ name="+access-tokens"
98+ permission="launchpad.Edit"
99+ class="lp.services.auth.browser.AccessTokensView"
100+ template="templates/accesstokentarget-access-tokens.pt" />
101+
102 <webservice:register module="lp.services.auth.webservice" />
103 </configure>
104diff --git a/lib/lp/services/auth/interfaces.py b/lib/lp/services/auth/interfaces.py
105index 7e20ee0..d18d51c 100644
106--- a/lib/lp/services/auth/interfaces.py
107+++ b/lib/lp/services/auth/interfaces.py
108@@ -48,44 +48,54 @@ class IAccessToken(Interface):
109 id = Int(title=_("ID"), required=True, readonly=True)
110
111 date_created = exported(Datetime(
112- title=_("When the token was created."), required=True, readonly=True))
113+ title=_("Creation date"),
114+ description=_("When the token was created."),
115+ required=True, readonly=True))
116
117 owner = exported(PublicPersonChoice(
118- title=_("The person who created the token."),
119+ title=_("Owner"),
120+ description=_("The person who created the token."),
121 vocabulary="ValidPersonOrTeam", required=True, readonly=True))
122
123 description = exported(TextLine(
124- title=_("A short description of the token."), required=True))
125+ title=_("Description"),
126+ description=_("A short description of the token."), required=True))
127
128 git_repository = Reference(
129- title=_("The Git repository for which the token was issued."),
130+ title=_("Git repository"),
131+ description=_("The Git repository for which the token was issued."),
132 # Really IGitRepository, patched in _schema_circular_imports.py.
133 schema=Interface, required=True, readonly=True)
134
135 target = exported(Reference(
136- title=_("The target for which the token was issued."),
137+ title=_("Target"),
138+ description=_("The target for which the token was issued."),
139 # Really IAccessTokenTarget, patched in _schema_circular_imports.py.
140 schema=Interface, required=True, readonly=True))
141
142 scopes = exported(List(
143 value_type=Choice(vocabulary=AccessTokenScope),
144- title=_("A list of scopes granted by the token."),
145+ title=_("Scopes"),
146+ description=_("A list of scopes granted by the token."),
147 required=True, readonly=True))
148
149 date_last_used = exported(Datetime(
150- title=_("When the token was last used."),
151+ title=_("Date last used"),
152+ description=_("When the token was last used."),
153 required=False, readonly=True))
154
155 date_expires = exported(Datetime(
156- title=_("When the token should expire or was revoked."),
157+ title=_("Expiry date"),
158+ description=_("When the token should expire or was revoked."),
159 required=False, readonly=True))
160
161 is_expired = Bool(
162- title=_("Whether this token has expired."),
163+ description=_("Whether this token has expired."),
164 required=False, readonly=True)
165
166 revoked_by = exported(PublicPersonChoice(
167- title=_("The person who revoked the token, if any."),
168+ title=_("Revoked by"),
169+ description=_("The person who revoked the token, if any."),
170 vocabulary="ValidPersonOrTeam", required=False, readonly=True))
171
172 def updateLastUsed():
173@@ -135,6 +145,15 @@ class IAccessTokenSet(Interface):
174 by this user.
175 """
176
177+ def getByTargetAndID(target, token_id, visible_by_user=None):
178+ """Return the access token with this target and ID, or None.
179+
180+ :param target: An `IAccessTokenTarget`.
181+ :param token_id: An `AccessToken` ID.
182+ :param visible_by_user: If given, return only access tokens visible
183+ by this user.
184+ """
185+
186
187 class IAccessTokenVerifiedRequest(Interface):
188 """Marker interface for a request with a verified access token."""
189diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.html b/lib/lp/services/auth/javascript/tests/test_tokens.html
190new file mode 100644
191index 0000000..f7887fb
192--- /dev/null
193+++ b/lib/lp/services/auth/javascript/tests/test_tokens.html
194@@ -0,0 +1,121 @@
195+<!DOCTYPE html>
196+<!--
197+Copyright 2021 Canonical Ltd. This software is licensed under the
198+GNU Affero General Public License version 3 (see the file LICENSE).
199+-->
200+
201+<html>
202+ <head>
203+ <title>Personal access token widget tests</title>
204+
205+ <!-- YUI and test setup -->
206+ <script type="text/javascript"
207+ src="../../../../../../build/js/yui/yui/yui.js">
208+ </script>
209+ <link rel="stylesheet"
210+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
211+ <link rel="stylesheet"
212+ href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
213+ <link rel="stylesheet"
214+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
215+
216+ <script type="text/javascript"
217+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
218+ <script type="text/javascript"
219+ src="../../../../../../build/js/lp/app/testing/helpers.js"></script>
220+
221+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
222+
223+ <!-- Dependencies -->
224+ <script type="text/javascript"
225+ src="../../../../../../build/js/lp/app/anim/anim.js"></script>
226+ <script type="text/javascript"
227+ src="../../../../../../build/js/lp/app/client.js"></script>
228+ <script type="text/javascript"
229+ src="../../../../../../build/js/lp/app/effects/effects.js"></script>
230+ <script type="text/javascript"
231+ src="../../../../../../build/js/lp/app/errors.js"></script>
232+ <script type="text/javascript"
233+ src="../../../../../../build/js/lp/app/expander.js"></script>
234+ <script type="text/javascript"
235+ src="../../../../../../build/js/lp/app/extras/extras.js"></script>
236+ <script type="text/javascript"
237+ src="../../../../../../build/js/lp/app/formoverlay/formoverlay.js"></script>
238+ <script type="text/javascript"
239+ src="../../../../../../build/js/lp/app/lp.js"></script>
240+ <script type="text/javascript"
241+ src="../../../../../../build/js/lp/app/overlay/overlay.js"></script>
242+ <script type="text/javascript"
243+ src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
244+ <script type="text/javascript"
245+ src="../../../../../../build/js/lp/app/ui/ui.js"></script>
246+
247+ <!-- The module under test. -->
248+ <script type="text/javascript" src="../tokens.js"></script>
249+
250+ <!-- The test suite -->
251+ <script type="text/javascript" src="test_tokens.js"></script>
252+
253+ <script id="fixture-template" type="text/x-template">
254+ <div id="create-token">
255+ <table class="form">
256+ <tr>
257+ <td>
258+ <label for="field.description">Description:</label>
259+ <input class="textType" id="field.description"
260+ name="field.description" size="20" type="text" value="" />
261+ </td>
262+ <td>
263+ <label for="field.scopes">Scopes:</label>
264+ <label for="field.scopes.0">
265+ <input class="checkboxType" id="field.scopes.0"
266+ name="field.scopes" type="checkbox"
267+ value="REPOSITORY_BUILD_STATUS"
268+ />&nbsp;repository:build_status
269+ </label>
270+ <br />
271+ <label for="field.scopes.1">
272+ <input class="checkboxType" id="field.scopes.1"
273+ name="field.scopes" type="checkbox"
274+ value="REPOSITORY_PUSH" />&nbsp;repository:push
275+ </label>
276+ <input name="field.scopes.empty-marker" type="hidden"
277+ value="1" />
278+ </td>
279+ <td>
280+ <label for="field.date_expires">Expiry date:</label>
281+ <input size="19" type="text" class="yui2-calendar withtime"
282+ id="field.date_expires" name="field.date_expires" />
283+ in time zone: UTC
284+ </td>
285+ </tr>
286+ </table>
287+
288+ <p>
289+ <input id="create-token-button" class="js-only"
290+ type="button" value="Create token" />
291+ <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
292+ </p>
293+ <div id="new-token-information" class="hidden">
294+ <p>Your new personal access token is:</p>
295+ <pre id="new-token-secret" class="subordinate"></pre>
296+ <p>
297+ Launchpad will not show you this again, so make sure to save it
298+ now.
299+ </p>
300+ </div>
301+ </div>
302+
303+ <table class="listing access-tokens-table">
304+ <tbody id="access-tokens-tbody" />
305+ </table>
306+ </script>
307+ </head>
308+ <body class="yui3-skin-sam">
309+ <ul id="suites">
310+ <li>lp.services.auth.tokens.test</li>
311+ </ul>
312+
313+ <div id="fixture" />
314+ </body>
315+</html>
316diff --git a/lib/lp/services/auth/javascript/tests/test_tokens.js b/lib/lp/services/auth/javascript/tests/test_tokens.js
317new file mode 100644
318index 0000000..347b3d3
319--- /dev/null
320+++ b/lib/lp/services/auth/javascript/tests/test_tokens.js
321@@ -0,0 +1,181 @@
322+/* Copyright 2021 Canonical Ltd. This software is licensed under the
323+ * GNU Affero General Public License version 3 (see the file LICENSE). */
324+
325+YUI.add('lp.services.auth.tokens.test', function (Y) {
326+
327+ var tests = Y.namespace('lp.services.auth.tokens.test');
328+ tests.suite = new Y.Test.Suite('lp.services.auth.tokens Tests');
329+
330+ tests.suite.add(new Y.Test.Case({
331+ name: 'lp.services.auth.tokens_tests',
332+
333+ setUp: function () {
334+ this.node = Y.Node.create(Y.one('#fixture-template').getContent());
335+ Y.one('#fixture').append(this.node);
336+ this.widget = new Y.lp.services.auth.tokens.CreateTokenWidget({
337+ srcNode: Y.one('#create-token'),
338+ target_uri: '/api/devel/repo'
339+ });
340+ this.mockio = new Y.lp.testing.mockio.MockIo();
341+ this.widget.client.io_provider = this.mockio;
342+ this.old_error_method = Y.lp.app.errors.display_error;
343+ },
344+
345+ tearDown: function () {
346+ this.widget.destroy();
347+ Y.one('#fixture').empty();
348+ Y.lp.app.errors.display_error = this.old_error_method;
349+ },
350+
351+ test_library_exists: function () {
352+ Y.Assert.isObject(Y.lp.services.auth.tokens,
353+ 'Could not locate the lp.services.auth.tokens module');
354+ },
355+
356+ test_widget_can_be_instantiated: function () {
357+ Y.Assert.isInstanceOf(
358+ Y.lp.services.auth.tokens.CreateTokenWidget,
359+ this.widget, 'Widget failed to be instantiated');
360+ },
361+
362+ test_create_no_description: function () {
363+ var error_shown = false;
364+ Y.lp.app.errors.display_error = function(flash_node, msg) {
365+ Y.Assert.areEqual(
366+ Y.one('[name="field.description"]'), flash_node);
367+ Y.Assert.areEqual(
368+ 'A personal access token must have a description.', msg);
369+ error_shown = true;
370+ };
371+
372+ this.widget.render();
373+ Y.one('#create-token-button').simulate('click');
374+ Y.Assert.isTrue(error_shown);
375+ },
376+
377+ test_create_no_scopes: function () {
378+ var error_shown = false;
379+ Y.lp.app.errors.display_error = function(flash_node, msg) {
380+ Y.Assert.isNull(flash_node);
381+ Y.Assert.areEqual(
382+ 'A personal access token must have scopes.', msg);
383+ error_shown = true;
384+ };
385+
386+ Y.one('[name="field.description"]').set('value', 'Test');
387+ this.widget.render();
388+ Y.one('#create-token-button').simulate('click');
389+ Y.Assert.isTrue(error_shown);
390+ },
391+
392+ test_create_failure: function () {
393+ var error_shown = false;
394+ Y.lp.app.errors.display_error = function(flash_node, msg) {
395+ Y.Assert.isNull(flash_node);
396+ Y.Assert.areEqual(
397+ 'Failed to create personal access token.', msg);
398+ error_shown = true;
399+ };
400+
401+ Y.one('[name="field.description"]').set(
402+ 'value', 'Test description');
403+ Y.all('[name="field.scopes"]').item(1).set('checked', true);
404+ this.widget.render();
405+ Y.one('#create-token-button').simulate('click');
406+ Y.Assert.isFalse(error_shown);
407+
408+ this.mockio.failure();
409+ Y.Assert.isTrue(error_shown);
410+ },
411+
412+ test_create: function () {
413+ var error_shown = false;
414+ Y.lp.app.errors.display_error = function(flash_node, msg) {
415+ error_shown = true;
416+ };
417+
418+ Y.one('[name="field.description"]').set(
419+ 'value', 'Test description');
420+ Y.all('[name="field.scopes"]').item(1).set('checked', true);
421+ this.widget.render();
422+ Y.one('#create-token-button').simulate('click');
423+ Y.Assert.isFalse(error_shown);
424+ Y.Assert.areEqual(1, this.mockio.requests.length);
425+ Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
426+ Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
427+ Y.Assert.areEqual(
428+ 'ws.op=issueAccessToken' +
429+ '&description=Test%20description' +
430+ '&scopes=repository%3Apush',
431+ this.mockio.last_request.config.data);
432+ Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
433+
434+ this.mockio.success({
435+ responseHeaders: {'Content-Type': 'application/json'},
436+ responseText: Y.JSON.stringify('test-secret')
437+ });
438+ Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
439+ Y.Assert.areEqual(
440+ 'test-secret', Y.one('#new-token-secret').get('text'));
441+ Y.Assert.isFalse(
442+ Y.one('#new-token-information').hasClass('hidden'));
443+ var token_row = Y.one('#access-tokens-tbody tr');
444+ Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
445+ Y.ArrayAssert.itemsAreEqual(
446+ ['Test description', 'repository:push', 'a moment ago',
447+ '', 'Never', ''],
448+ token_row.all('td').map(function (node) {
449+ return node.get('text');
450+ }));
451+ },
452+
453+ test_create_with_expiry: function () {
454+ var error_shown = false;
455+ Y.lp.app.errors.display_error = function(flash_node, msg) {
456+ error_shown = true;
457+ };
458+
459+ Y.one('[name="field.description"]').set(
460+ 'value', 'Test description');
461+ Y.all('[name="field.scopes"]').item(0).set('checked', true);
462+ Y.one('[name="field.date_expires"]').set('value', '2021-01-01');
463+ this.widget.render();
464+ Y.one('#create-token-button').simulate('click');
465+ Y.Assert.isFalse(error_shown);
466+ Y.Assert.areEqual(1, this.mockio.requests.length);
467+ Y.Assert.areEqual('/api/devel/repo', this.mockio.last_request.url);
468+ Y.Assert.areEqual('POST', this.mockio.last_request.config.method);
469+ Y.Assert.areEqual(
470+ 'ws.op=issueAccessToken' +
471+ '&description=Test%20description' +
472+ '&scopes=repository%3Abuild_status' +
473+ '&date_expires=2021-01-01',
474+ this.mockio.last_request.config.data);
475+ Y.Assert.isFalse(Y.one('.spinner').hasClass('hidden'));
476+
477+ this.mockio.success({
478+ responseHeaders: {'Content-Type': 'application/json'},
479+ responseText: Y.JSON.stringify('test-secret')
480+ });
481+ Y.Assert.isTrue(Y.one('.spinner').hasClass('hidden'));
482+ Y.Assert.areEqual(
483+ 'test-secret', Y.one('#new-token-secret').get('text'));
484+ Y.Assert.isFalse(
485+ Y.one('#new-token-information').hasClass('hidden'));
486+ var token_row = Y.one('#access-tokens-tbody tr');
487+ Y.Assert.isTrue(token_row.hasClass('yui3-lazr-even'));
488+ Y.ArrayAssert.itemsAreEqual(
489+ ['Test description', 'repository:build_status', 'a moment ago',
490+ '', '2021-01-01', ''],
491+ token_row.all('td').map(function (node) {
492+ return node.get('text');
493+ }));
494+ }
495+ }));
496+
497+}, '0.1', {
498+ requires: [
499+ 'json-stringify', 'node-event-simulate', 'test',
500+ 'lp.services.auth.tokens', 'lp.testing.mockio'
501+ ]
502+});
503diff --git a/lib/lp/services/auth/javascript/tokens.js b/lib/lp/services/auth/javascript/tokens.js
504new file mode 100644
505index 0000000..81b4d4b
506--- /dev/null
507+++ b/lib/lp/services/auth/javascript/tokens.js
508@@ -0,0 +1,151 @@
509+/* Copyright 2021 Canonical Ltd. This software is licensed under the
510+ * GNU Affero General Public License version 3 (see the file LICENSE).
511+ *
512+ * Personal access token widgets.
513+ *
514+ * @module lp.services.auth.tokens
515+ * @requires node, widget, lp.app.errors, lp.client, lp.extras, lp.ui-base
516+ */
517+
518+YUI.add('lp.services.auth.tokens', function(Y) {
519+ var module = Y.namespace('lp.services.auth.tokens');
520+
521+ var CreateTokenWidget = function() {
522+ CreateTokenWidget.superclass.constructor.apply(this, arguments);
523+ };
524+
525+ CreateTokenWidget.NAME = 'create-token-widget';
526+
527+ CreateTokenWidget.ATTRS = {
528+ /**
529+ * The URI for the target for new tokens.
530+ *
531+ * @attribute target_uri
532+ * @type String
533+ * @default null
534+ */
535+ target_uri: {
536+ value: null
537+ }
538+ };
539+
540+ Y.extend(CreateTokenWidget, Y.Widget, {
541+ initializer: function(cfg) {
542+ this.client = new Y.lp.client.Launchpad();
543+ this.set('target_uri', cfg.target_uri);
544+ },
545+
546+ /**
547+ * Show the spinner.
548+ *
549+ * @method showSpinner
550+ */
551+ showSpinner: function() {
552+ this.get('srcNode').all('.spinner').removeClass('hidden');
553+ },
554+
555+ /**
556+ * Hide the spinner.
557+ *
558+ * @method hideSpinner
559+ */
560+ hideSpinner: function() {
561+ this.get('srcNode').all('.spinner').addClass('hidden');
562+ },
563+
564+ /**
565+ * Create a new token.
566+ *
567+ * @method createToken
568+ */
569+ createToken: function() {
570+ var container = this.get('srcNode');
571+ var description_node = container.one('[name="field.description"]');
572+ var description = description_node.get('value');
573+ if (description === '') {
574+ Y.lp.app.errors.display_error(
575+ description_node,
576+ 'A personal access token must have a description.');
577+ return;
578+ }
579+ var scopes = container.all('[name="field.scopes"]')
580+ .filter(':checked')
581+ .map(function (node) {
582+ var node_id = node.get('id');
583+ var label = container.one('label[for="' + node_id + '"]');
584+ return label.get('text').trim();
585+ });
586+ if (scopes.length === 0) {
587+ Y.lp.app.errors.display_error(
588+ null, 'A personal access token must have scopes.');
589+ return;
590+ }
591+ var date_expires = container
592+ .one('[name="field.date_expires"]').get('value');
593+ var self = this;
594+ var config = {
595+ on: {
596+ start: function() {
597+ self.showSpinner();
598+ },
599+ end: function() {
600+ self.hideSpinner();
601+ },
602+ success: function(response) {
603+ container.one('#new-token-secret')
604+ .set('text', response);
605+ container.one('#new-token-information')
606+ .removeClass('hidden');
607+ var tokens_tbody = Y.one('#access-tokens-tbody');
608+ tokens_tbody.append(Y.Node.create('<tr />')
609+ .addClass(
610+ tokens_tbody.all('tr').size() % 2
611+ ? Y.lp.ui.CSS_ODD : Y.lp.ui.CSS_EVEN)
612+ .append(Y.Node.create('<td />')
613+ .set('text', description))
614+ .append(Y.Node.create('<td />')
615+ .set('text', scopes.join(', ')))
616+ .append(Y.Node.create('<td />')
617+ .set('text', 'a moment ago'))
618+ .append(Y.Node.create('<td />'))
619+ .append(Y.Node.create('<td />')
620+ .set('text',
621+ date_expires !== ''
622+ ? date_expires : 'Never'))
623+ .append(Y.Node.create('<td />')));
624+ },
625+ failure: function(ignore, response, args) {
626+ Y.lp.app.errors.display_error(
627+ null, 'Failed to create personal access token.');
628+ }
629+ },
630+ parameters: {
631+ description: description,
632+ scopes: scopes
633+ }
634+ };
635+ if (date_expires !== "") {
636+ config.parameters.date_expires = date_expires;
637+ }
638+ this.client.named_post(
639+ this.get('target_uri'), 'issueAccessToken', config);
640+ },
641+
642+ bindUI: function() {
643+ this.constructor.superclass.bindUI.call(this);
644+ var create_token_button = this.get('srcNode')
645+ .one('#create-token-button');
646+ if (Y.Lang.isValue(create_token_button)) {
647+ var self = this;
648+ create_token_button.on('click', function(e) {
649+ e.halt();
650+ self.createToken();
651+ });
652+ }
653+ }
654+ });
655+
656+ module.CreateTokenWidget = CreateTokenWidget;
657+}, '0.1', {'requires': [
658+ 'node', 'widget', 'lp.app.errors', 'lp.client', 'lp.extras', 'lp.ui-base'
659+]});
660diff --git a/lib/lp/services/auth/model.py b/lib/lp/services/auth/model.py
661index 39a6684..b1cc7fc 100644
662--- a/lib/lp/services/auth/model.py
663+++ b/lib/lp/services/auth/model.py
664@@ -186,7 +186,16 @@ class AccessTokenSet:
665 removeSecurityProxy(ids)._get_select())))
666 else:
667 raise TypeError("Unsupported target: {!r}".format(target))
668- return IStore(AccessToken).find(AccessToken, *clauses)
669+ clauses.append(Or(
670+ AccessToken.date_expires == None,
671+ AccessToken.date_expires > UTC_NOW))
672+ return IStore(AccessToken).find(AccessToken, *clauses).order_by(
673+ AccessToken.date_created)
674+
675+ def getByTargetAndID(self, target, token_id, visible_by_user=None):
676+ """See `IAccessTokenSet`."""
677+ return self.findByTarget(target, visible_by_user=visible_by_user).find(
678+ id=token_id).one()
679
680
681 class AccessTokenTargetMixin:
682diff --git a/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
683new file mode 100644
684index 0000000..6d85df0
685--- /dev/null
686+++ b/lib/lp/services/auth/templates/accesstokentarget-access-tokens.pt
687@@ -0,0 +1,122 @@
688+<html
689+ xmlns="http://www.w3.org/1999/xhtml"
690+ xmlns:tal="http://xml.zope.org/namespaces/tal"
691+ xmlns:metal="http://xml.zope.org/namespaces/metal"
692+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
693+ metal:use-macro="view/macro:page/main_only"
694+ i18n:domain="launchpad"
695+>
696+<body>
697+
698+<metal:block fill-slot="head_epilogue">
699+ <style type="text/css">
700+ .js-only {
701+ display: none;
702+ }
703+ .yui3-js-enabled .js-only {
704+ display: inline;
705+ }
706+ </style>
707+
708+ <script type="text/javascript">
709+ LPJS.use('node', 'lp.services.auth.tokens', function(Y) {
710+ Y.on('domready', function() {
711+ var ns = Y.lp.services.auth.tokens;
712+ var create_token_widget = new ns.CreateTokenWidget({
713+ srcNode: Y.one('#create-token'),
714+ target_uri: LP.cache.context.self_link
715+ });
716+ create_token_widget.render();
717+ });
718+ });
719+ </script>
720+</metal:block>
721+
722+<div metal:fill-slot="main">
723+ <p>Personal access tokens allow using certain parts of the Launchpad API.</p>
724+
725+ <h2>Create a token</h2>
726+ <div id="create-token">
727+ <metal:form use-macro="context/@@launchpad_form/form">
728+ <metal:formbody fill-slot="widgets">
729+ <table class="form">
730+ <tal:widget define="widget nocall:view/widgets/description">
731+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
732+ </tal:widget>
733+ <tal:widget define="widget nocall:view/widgets/scopes">
734+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
735+ </tal:widget>
736+ <tal:widget define="widget nocall:view/widgets/date_expires">
737+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
738+ </tal:widget>
739+ </table>
740+ </metal:formbody>
741+
742+ <metal:buttons fill-slot="buttons">
743+ <p>
744+ <input id="create-token-button" class="js-only"
745+ type="button" value="Create token" />
746+ <img class="spinner hidden" src="/@@/spinner" alt="Loading..." />
747+ <noscript><strong>
748+ Creating personal access tokens requires JavaScript.
749+ </strong></noscript>
750+ </p>
751+ <div id="new-token-information" class="hidden">
752+ <p>Your new personal access token is:</p>
753+ <pre id="new-token-secret" class="subordinate"></pre>
754+ <p>
755+ Launchpad will not show you this again, so make sure to save it
756+ now.
757+ </p>
758+ </div>
759+ </metal:buttons>
760+ </metal:form>
761+ </div>
762+
763+ <h2>Active tokens</h2>
764+ <table class="listing access-tokens-table"
765+ style="max-width: 80em;">
766+ <thead>
767+ <tr>
768+ <th>Description</th>
769+ <th>Scopes</th>
770+ <th>Created</th>
771+ <th>Last used</th>
772+ <th>Expires</th>
773+ <th><tal:comment condition="nothing">Revoke button</tal:comment></th>
774+ </tr>
775+ </thead>
776+ <tbody id="access-tokens-tbody">
777+ <tr tal:repeat="token view/access_tokens"
778+ tal:attributes="
779+ class python: 'yui3-lazr-even' if repeat['token'].even()
780+ else 'yui3-lazr-odd';
781+ token-id token/id">
782+ <td tal:content="token/description" />
783+ <td tal:content="
784+ python: ', '.join(scope.title for scope in token.scopes)" />
785+ <td tal:content="
786+ structure token/date_created/fmt:approximatedatetitle" />
787+ <td tal:content="
788+ structure token/date_last_used/fmt:approximatedatetitle" />
789+ <td>
790+ <tal:expires
791+ condition="token/date_expires"
792+ replace="
793+ structure token/date_expires/fmt:approximatedatetitle" />
794+ <span tal:condition="not: token/date_expires">Never</span>
795+ </td>
796+ <td>
797+ <form method="post" tal:attributes="name string:revoke-${token/id}">
798+ <input type="hidden" name="token_id"
799+ tal:attributes="value token/id" />
800+ <input type="submit" name="field.actions.revoke" value="Revoke" />
801+ </form>
802+ </td>
803+ </tr>
804+ </tbody>
805+ </table>
806+</div>
807+
808+</body>
809+</html>
810diff --git a/lib/lp/services/auth/tests/test_browser.py b/lib/lp/services/auth/tests/test_browser.py
811new file mode 100644
812index 0000000..6ec0b70
813--- /dev/null
814+++ b/lib/lp/services/auth/tests/test_browser.py
815@@ -0,0 +1,151 @@
816+# Copyright 2021 Canonical Ltd. This software is licensed under the
817+# GNU Affero General Public License version 3 (see the file LICENSE).
818+
819+"""Test personal access token views."""
820+
821+import re
822+
823+import soupmatchers
824+from testtools.matchers import (
825+ Equals,
826+ Is,
827+ MatchesAll,
828+ MatchesListwise,
829+ MatchesStructure,
830+ Not,
831+ )
832+from zope.component import getUtility
833+from zope.security.proxy import removeSecurityProxy
834+
835+from lp.services.webapp.interfaces import IPlacelessAuthUtility
836+from lp.services.webapp.publisher import canonical_url
837+from lp.testing import (
838+ login_person,
839+ TestCaseWithFactory,
840+ )
841+from lp.testing.layers import DatabaseFunctionalLayer
842+from lp.testing.views import create_view
843+
844+
845+breadcrumbs_tag = soupmatchers.Tag(
846+ "breadcrumbs", "ol", attrs={"class": "breadcrumbs"})
847+tokens_page_crumb_tag = soupmatchers.Tag(
848+ "tokens page breadcrumb", "li", text=re.compile(r"Personal access tokens"))
849+token_listing_constants = soupmatchers.HTMLContains(
850+ soupmatchers.Within(breadcrumbs_tag, tokens_page_crumb_tag))
851+token_listing_tag = soupmatchers.Tag(
852+ "tokens table", "table", attrs={"class": "listing"})
853+
854+
855+class TestAccessTokenViewBase:
856+
857+ layer = DatabaseFunctionalLayer
858+
859+ def setUp(self):
860+ super().setUp()
861+ self.target = self.makeTarget()
862+ self.owner = self.target.owner
863+ login_person(self.owner)
864+
865+ def makeView(self, name, **kwargs):
866+ # XXX cjwatson 2021-10-19: We need to give the view a
867+ # LaunchpadPrincipal rather than just a person, since otherwise bits
868+ # of the navigation menu machinery try to use the scope_url
869+ # attribute on the principal and fail. This should probably be done
870+ # in create_view instead, but that approach needs care to avoid
871+ # adding an extra query to tests that might be sensitive to that.
872+ principal = getUtility(IPlacelessAuthUtility).getPrincipal(
873+ self.owner.accountID)
874+ view = create_view(
875+ self.target, name, principal=principal, current_request=True,
876+ **kwargs)
877+ # To test the breadcrumbs we need a correct traversal stack.
878+ view.request.traversed_objects = (
879+ self.getTraversalStack(self.target) + [view])
880+ # The navigation menu machinery needs this to find the view from the
881+ # request.
882+ view.request._last_obj_traversed = view
883+ view.initialize()
884+ return view
885+
886+ def makeTokensAndMatchers(self, count):
887+ tokens = [
888+ self.factory.makeAccessToken(target=self.target)[1]
889+ for _ in range(count)]
890+ # There is a row for each token.
891+ matchers = []
892+ for token in tokens:
893+ row_tag = soupmatchers.Tag(
894+ "token row", "tr",
895+ attrs={"token-id": removeSecurityProxy(token).id})
896+ column_tags = [
897+ soupmatchers.Tag(
898+ "description", "td", text=token.description),
899+ soupmatchers.Tag(
900+ "scopes", "td",
901+ text=", ".join(scope.title for scope in token.scopes)),
902+ ]
903+ matchers.extend([
904+ soupmatchers.Within(row_tag, column_tag)
905+ for column_tag in column_tags])
906+ return matchers
907+
908+ def test_empty(self):
909+ self.assertThat(
910+ self.makeView("+access-tokens")(),
911+ MatchesAll(
912+ token_listing_constants,
913+ soupmatchers.HTMLContains(token_listing_tag)))
914+
915+ def test_existing_tokens(self):
916+ token_matchers = self.makeTokensAndMatchers(10)
917+ self.assertThat(
918+ self.makeView("+access-tokens")(),
919+ MatchesAll(
920+ token_listing_constants,
921+ soupmatchers.HTMLContains(token_listing_tag, *token_matchers)))
922+
923+ def test_revoke(self):
924+ tokens = [
925+ self.factory.makeAccessToken(target=self.target)[1]
926+ for _ in range(3)]
927+ token_ids = [token.id for token in tokens]
928+ access_tokens_url = canonical_url(
929+ self.target, view_name="+access-tokens")
930+ browser = self.getUserBrowser(access_tokens_url, user=self.owner)
931+ for token_id in token_ids:
932+ self.assertThat(
933+ browser.getForm(name="revoke-%s" % token_id).controls,
934+ MatchesListwise([
935+ MatchesStructure.byEquality(
936+ type="hidden", name="token_id", value=str(token_id)),
937+ MatchesStructure.byEquality(
938+ type="submit", name="field.actions.revoke",
939+ value="Revoke"),
940+ ]))
941+ browser.getForm(name="revoke-%s" % token_ids[1]).getControl(
942+ "Revoke").click()
943+ login_person(self.owner)
944+ self.assertEqual(access_tokens_url, browser.url)
945+ self.assertThat(tokens[0], MatchesStructure(
946+ id=Equals(token_ids[0]),
947+ date_expires=Is(None),
948+ revoked_by=Is(None)))
949+ self.assertThat(tokens[1], MatchesStructure(
950+ id=Equals(token_ids[1]),
951+ date_expires=Not(Is(None)),
952+ revoked_by=Equals(self.owner)))
953+ self.assertThat(tokens[2], MatchesStructure(
954+ id=Equals(token_ids[2]),
955+ date_expires=Is(None),
956+ revoked_by=Is(None)))
957+
958+
959+class TestAccessTokenViewGitRepository(
960+ TestAccessTokenViewBase, TestCaseWithFactory):
961+
962+ def makeTarget(self):
963+ return self.factory.makeGitRepository()
964+
965+ def getTraversalStack(self, obj):
966+ return [obj.target, obj]
967diff --git a/lib/lp/services/auth/tests/test_model.py b/lib/lp/services/auth/tests/test_model.py
968index c64dbd9..805b02c 100644
969--- a/lib/lp/services/auth/tests/test_model.py
970+++ b/lib/lp/services/auth/tests/test_model.py
971@@ -251,6 +251,87 @@ class TestAccessTokenSet(TestCaseWithFactory):
972 targets[target_index],
973 visible_by_user=owners[owner_index]))
974
975+ def test_findByTarget_excludes_expired(self):
976+ target = self.factory.makeGitRepository()
977+ _, current_token = self.factory.makeAccessToken(target=target)
978+ _, expires_soon_token = self.factory.makeAccessToken(
979+ target=target,
980+ date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
981+ _, expired_token = self.factory.makeAccessToken(
982+ target=target,
983+ date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
984+ self.assertContentEqual(
985+ [current_token, expires_soon_token],
986+ getUtility(IAccessTokenSet).findByTarget(target))
987+
988+ def test_getByTargetAndID(self):
989+ targets = [self.factory.makeGitRepository() for _ in range(3)]
990+ tokens = [
991+ self.factory.makeAccessToken(target=targets[0])[1],
992+ self.factory.makeAccessToken(target=targets[0])[1],
993+ self.factory.makeAccessToken(target=targets[1])[1],
994+ ]
995+ self.assertEqual(
996+ tokens[0],
997+ getUtility(IAccessTokenSet).getByTargetAndID(
998+ targets[0], removeSecurityProxy(tokens[0]).id))
999+ self.assertEqual(
1000+ tokens[1],
1001+ getUtility(IAccessTokenSet).getByTargetAndID(
1002+ targets[0], removeSecurityProxy(tokens[1]).id))
1003+ self.assertIsNone(
1004+ getUtility(IAccessTokenSet).getByTargetAndID(
1005+ targets[0], removeSecurityProxy(tokens[2]).id))
1006+
1007+ def test_getByTargetAndID_visible_by_user(self):
1008+ targets = [self.factory.makeGitRepository() for _ in range(3)]
1009+ owners = [self.factory.makePerson() for _ in range(3)]
1010+ tokens = [
1011+ self.factory.makeAccessToken(
1012+ owner=owners[owner_index], target=targets[target_index])[1]
1013+ for owner_index, target_index in (
1014+ (0, 0), (0, 0), (1, 0), (1, 1), (2, 1))]
1015+ for owner_index, target_index, expected_tokens in (
1016+ (0, 0, tokens[:2]),
1017+ (0, 1, []),
1018+ (0, 2, []),
1019+ (1, 0, [tokens[2]]),
1020+ (1, 1, [tokens[3]]),
1021+ (1, 2, []),
1022+ (2, 0, []),
1023+ (2, 1, [tokens[4]]),
1024+ (2, 2, []),
1025+ ):
1026+ for token in tokens:
1027+ fetched_token = getUtility(IAccessTokenSet).getByTargetAndID(
1028+ targets[target_index], removeSecurityProxy(token).id,
1029+ visible_by_user=owners[owner_index])
1030+ if token in expected_tokens:
1031+ self.assertEqual(token, fetched_token)
1032+ else:
1033+ self.assertIsNone(fetched_token)
1034+
1035+ def test_getByTargetAndID_excludes_expired(self):
1036+ target = self.factory.makeGitRepository()
1037+ _, current_token = self.factory.makeAccessToken(target=target)
1038+ _, expires_soon_token = self.factory.makeAccessToken(
1039+ target=target,
1040+ date_expires=datetime.now(pytz.UTC) + timedelta(hours=1))
1041+ _, expired_token = self.factory.makeAccessToken(
1042+ target=target,
1043+ date_expires=datetime.now(pytz.UTC) - timedelta(minutes=1))
1044+ self.assertEqual(
1045+ current_token,
1046+ getUtility(IAccessTokenSet).getByTargetAndID(
1047+ target, removeSecurityProxy(current_token).id))
1048+ self.assertEqual(
1049+ expires_soon_token,
1050+ getUtility(IAccessTokenSet).getByTargetAndID(
1051+ target, removeSecurityProxy(expires_soon_token).id))
1052+ self.assertIsNone(
1053+ getUtility(IAccessTokenSet).getByTargetAndID(
1054+ target, removeSecurityProxy(expired_token).id))
1055+
1056
1057 class TestAccessTokenTargetBase:
1058
1059diff --git a/lib/lp/services/auth/tests/test_yuitests.py b/lib/lp/services/auth/tests/test_yuitests.py
1060new file mode 100644
1061index 0000000..069cc9d
1062--- /dev/null
1063+++ b/lib/lp/services/auth/tests/test_yuitests.py
1064@@ -0,0 +1,23 @@
1065+# Copyright 2021 Canonical Ltd. This software is licensed under the
1066+# GNU Affero General Public License version 3 (see the file LICENSE).
1067+
1068+"""Run YUI.test tests."""
1069+
1070+__all__ = []
1071+
1072+from lp.testing import (
1073+ build_yui_unittest_suite,
1074+ YUIUnitTestCase,
1075+ )
1076+from lp.testing.layers import YUITestLayer
1077+
1078+
1079+class AuthYUIUnitTestCase(YUIUnitTestCase):
1080+
1081+ layer = YUITestLayer
1082+ suite_name = "AuthYUIUnitTests"
1083+
1084+
1085+def test_suite():
1086+ app_testing_path = "lp/services/auth"
1087+ return build_yui_unittest_suite(app_testing_path, AuthYUIUnitTestCase)
1088diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
1089index 9b500b4..2856fa0 100644
1090--- a/lib/lp/testing/factory.py
1091+++ b/lib/lp/testing/factory.py
1092@@ -4538,6 +4538,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
1093 token = getUtility(IAccessTokenSet).new(
1094 secret, owner, description, target, scopes,
1095 date_expires=date_expires)
1096+ IStore(token).flush()
1097 return secret, token
1098
1099 def makeCVE(self, sequence, description=None,

Subscribers

People subscribed via source and target branches

to status/vote changes: