Skip to content

Commit 20d85b0

Browse files
committed
Allow users to create multiple API tokens
Fixes rust-lang#688 Summary of changes: * Database: * Add new `api_tokens` table that stores the named tokens, their creation date and last used date * API: * Remove singular `api_token` field from `User`, it is still in the database for easy rollback, but should be removed from there as well soon. * Remove existing `/me/reset_token` endpoint. * Update user middleware to search incoming tokens via the `api_tokens` table, updating the last used date as it does so, and to record whether the current request was authenticated via an API token or a session cookie * This also changes this search to function via `diesel` where it was previously using `pg` * Add new set of endpoints at `/me/tokens` allowing creating, listing and revoking API tokens * Listing tokens doesn't return the tokens value, that's only available once in the response from creating the token. * Tests * Frontend: * Remove the special support for an `api_token` parameter coming back from the `/me` endpoint * Add new `api-token` model * Has a special `adapter` as the endpoints are namespaced under `/me/tokens` instead of the default `/api/v1/api_tokens` * Has a special `serializer` to set the key used when creating a new API token * Update `/me/index` controller and template * Render a sorted list of all API tokens in the data store instead of the single token * Remove button to reset the token * Add button to start creating a new API token * Add new `api-token-row` component for rendering the API tokens in the list on `/me/index` * Has three major states: * New token that has yet to be saved to the API, shows a text box to enter a name and button to save the token * Newly saved token, shows the normal token data + the actual token value and details on how to save it with cargo * Normal token, shows the name, creation date and last used date along with a button to revoke * In any of those states can have an error message shown below the token data if creating or revoking the token failed * Some small general CSS changes: * A small variant for buttons that reduces the padding * Support for disabled state on buttons making them a little lighter than the specified color
1 parent 51cddd9 commit 20d85b0

File tree

25 files changed

+978
-199
lines changed

25 files changed

+978
-199
lines changed

app/adapters/api-token.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import DS from 'ember-data';
2+
3+
export default DS.RESTAdapter.extend({
4+
namespace: 'me',
5+
pathForType() {
6+
return 'tokens';
7+
}
8+
});

app/components/api-token-row.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Ember from 'ember';
2+
3+
export default Ember.Component.extend({
4+
emptyName: Ember.computed.empty('api_token.name'),
5+
disableCreate: Ember.computed.or('api_token.isSaving', 'emptyName'),
6+
serverError: null,
7+
8+
actions: {
9+
saveToken() {
10+
this.get('api_token')
11+
.save()
12+
.then(() => this.set('serverError', null))
13+
.catch(err => {
14+
let msg;
15+
if (err.errors && err.errors[0] && err.errors[0].detail) {
16+
msg = `An error occurred while saving this token, ${err.errors[0].detail}`;
17+
} else {
18+
msg = 'An unknown error occurred while saving this token';
19+
}
20+
this.set('serverError', msg);
21+
});
22+
},
23+
revokeToken() {
24+
this.get('api_token')
25+
.destroyRecord()
26+
.catch(err => {
27+
let msg;
28+
if (err.errors && err.errors[0] && err.errors[0].detail) {
29+
msg = `An error occurred while revoking this token, ${err.errors[0].detail}`;
30+
} else {
31+
msg = 'An unknown error occurred while revoking this token';
32+
}
33+
this.set('serverError', msg);
34+
});
35+
},
36+
}
37+
});

app/controllers/me/index.js

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,17 @@
11
import Ember from 'ember';
2-
import ajax from 'ic-ajax';
32

43
export default Ember.Controller.extend({
5-
isResetting: false,
4+
tokenSort: ['created_at:desc'],
5+
sortedTokens: Ember.computed.sort('model.api_tokens', 'tokenSort'),
66

7-
actions: {
8-
resetToken() {
9-
this.set('isResetting', true);
7+
newTokens: Ember.computed.filterBy('model.api_tokens', 'isNew', true),
8+
disableCreate: Ember.computed.notEmpty('newTokens'),
109

11-
ajax({
12-
dataType: 'json',
13-
url: '/me/reset_token',
14-
method: 'put',
15-
}).then((d) => {
16-
this.get('model').set('api_token', d.api_token);
17-
}).catch((reason) => {
18-
var msg;
19-
if (reason.status === 403) {
20-
msg = 'A login is required to perform this action';
21-
} else {
22-
msg = 'An unknown error occurred';
23-
}
24-
this.controllerFor('application').set('nextFlashError', msg);
25-
// TODO: this should be an action, the route state machine
26-
// should recieve signals not external transitions
27-
this.transitionToRoute('index');
28-
}).finally(() => {
29-
this.set('isResetting', false);
10+
actions: {
11+
startNewToken() {
12+
this.get('store').createRecord('api-token', {
13+
created_at: new Date(Date.now() + 2000),
3014
});
31-
}
15+
},
3216
}
3317
});

app/models/api-token.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import DS from 'ember-data';
2+
3+
export default DS.Model.extend({
4+
name: DS.attr('string'),
5+
token: DS.attr('string'),
6+
created_at: DS.attr('date'),
7+
last_used_at: DS.attr('date'),
8+
});

app/models/user.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ export default DS.Model.extend({
44
email: DS.attr('string'),
55
name: DS.attr('string'),
66
login: DS.attr('string'),
7-
api_token: DS.attr('string'),
87
avatar: DS.attr('string'),
98
url: DS.attr('string'),
109
});

app/routes/application.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ export default Ember.Route.extend({
66
if (this.session.get('isLoggedIn') &&
77
this.session.get('currentUser') === null) {
88
ajax('/me').then((response) => {
9-
var user = this.store.push(this.store.normalize('user', response.user));
10-
user.set('api_token', response.api_token);
11-
this.session.set('currentUser', user);
9+
this.session.set('currentUser', this.store.push(this.store.normalize('user', response.user)));
1210
}).catch(() => this.session.logoutUser()).finally(() => {
1311
window.currentUserDetected = true;
1412
Ember.$(window).trigger('currentUserDetected');

app/routes/login.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export default Ember.Route.extend({
5252
}
5353

5454
var user = this.store.push(this.store.normalize('user', data.user));
55-
user.set('api_token', data.api_token);
5655
var transition = this.session.get('savedTransition');
5756
this.session.loginUser(user);
5857
if (transition) {

app/routes/me/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import AuthenticatedRoute from '../../mixins/authenticated-route';
33

44
export default Ember.Route.extend(AuthenticatedRoute, {
55
model() {
6-
return this.session.get('currentUser');
6+
return {
7+
user: this.session.get('currentUser'),
8+
api_tokens: this.get('store').findAll('api-token'),
9+
};
710
},
811
});

app/serializers/api-token.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import DS from 'ember-data';
2+
3+
export default DS.RESTSerializer.extend({
4+
payloadKeyFromModelName() {
5+
return 'api_token';
6+
}
7+
});

app/styles/home.scss

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
@mixin button($start, $end) {
2-
$s2: darken($start, 5%);
3-
$e2: darken($end, 5%);
2+
$start_dark: darken($start, 5%);
3+
$end_dark: darken($end, 5%);
4+
$start_light: lighten($start, 5%);
5+
$end_light: lighten($end, 5%);
6+
47
padding: 15px 40px;
58
display: inline-block;
69
color: $main-color;
@@ -17,15 +20,24 @@
1720
margin-right: 10px;
1821
}
1922

20-
&:hover { @include vertical-gradient($s2, $e2); outline: 0; }
21-
&.active { @include vertical-gradient($s2, $e2); outline: 0; }
23+
&:hover { @include vertical-gradient($start_dark, $end_dark); outline: 0; }
24+
&.active { @include vertical-gradient($start_dark, $end_dark); outline: 0; }
25+
&[disabled] {
26+
@include vertical-gradient($start_light, $end_light);
27+
color: $main-color-light;
28+
}
2229
}
2330

2431
.yellow-button {
2532
@include button(#fede9e, #fdc452);
2633
vertical-align: middle;
2734
}
2835

36+
button.small {
37+
padding: 10px 20px;
38+
@include border-radius(30px);
39+
}
40+
2941
.tan-button {
3042
@include button(rgb(232, 227, 199), rgb(214, 205, 153));
3143
}

app/styles/me.scss

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,66 @@
8686
&:hover { background-color: darken($bg, 10%); }
8787
}
8888
}
89+
90+
.me-subheading {
91+
@include display-flex;
92+
.right {
93+
@include flex(2);
94+
@include display-flex;
95+
@include justify-content(flex-end);
96+
@include align-self(center);
97+
}
98+
}
99+
100+
#tokens {
101+
background-color: $main-bg-dark;
102+
@include display-flex;
103+
@include flex-direction(column);
104+
.row {
105+
width: 100%;
106+
border: 1px solid #d5d3cb;
107+
border-bottom-width: 0px;
108+
&:last-child { border-bottom-width: 1px; }
109+
padding: 10px 20px;
110+
@include display-flex;
111+
@include align-items(center);
112+
.name {
113+
@include flex(1);
114+
margin-right: 0.4em;
115+
font-weight: bold;
116+
}
117+
.dates {
118+
@include flex(content);
119+
@include display-flex;
120+
@include flex-direction(column);
121+
@include align-items(flex-end);
122+
margin-right: 0.4em;
123+
}
124+
.actions {
125+
@include display-flex;
126+
@include align-items(center);
127+
img { margin-left: 10px }
128+
}
129+
}
130+
.create-token {
131+
.name {
132+
input {
133+
width: 100%;
134+
}
135+
padding-right: 20px;
136+
margin-right: 0px;
137+
}
138+
background-color: $main-bg-dark;
139+
}
140+
.new-token {
141+
border-top-width: 0px;
142+
@include flex-direction(column);
143+
@include justify-content(stretch);
144+
}
145+
.error {
146+
border-top-width: 0px;
147+
font-weight: bold;
148+
color: rgb(216, 0, 41);
149+
padding: 0px 10px 10px 20px;
150+
}
151+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<div class={{if api_token.isNew "row create-token" "row"}}>
2+
<div class='name'>
3+
{{#if api_token.isNew}}
4+
{{input
5+
type="text"
6+
placeholder="New token name"
7+
disabled=api_token.isSaving
8+
value=api_token.name
9+
autofocus=true
10+
enter="newToken"}}
11+
{{else}}
12+
{{ api_token.name }}
13+
{{/if}}
14+
</div>
15+
16+
<div class='spacer'></div>
17+
18+
{{#unless api_token.isNew}}
19+
<div class='dates'>
20+
<div class='created-at'>
21+
<span class='small' title={{api_token.created_at}}>
22+
Created {{moment-from-now api_token.created_at}}
23+
</span>
24+
</div>
25+
{{#if api_token.last_used_at}}
26+
<div class='last_used_at'>
27+
<span class='small' title={{api_token.last_used_at}}>
28+
Last used {{moment-from-now api_token.last_used_at}}
29+
</span>
30+
</div>
31+
{{else}}
32+
<div class='last_used_at'>
33+
<span class='small'>Never used</span>
34+
</div>
35+
{{/if}}
36+
</div>
37+
{{/unless}}
38+
39+
<div class='actions'>
40+
{{#if api_token.isNew}}
41+
<button class='small yellow-button'
42+
disabled={{disableCreate}}
43+
title={{if emptyName "You must specify a name" ""}}
44+
{{action "saveToken"}}>Create</button>
45+
{{else}}
46+
<button class='small tan-button'
47+
disabled={{api_token.isSaving}}
48+
{{action "revokeToken"}}>Revoke</button>
49+
{{/if}}
50+
{{#if api_token.isSaving}}
51+
<img class='overlay' src="/assets/ajax-loader.gif" />
52+
{{/if}}
53+
</div>
54+
</div>
55+
56+
{{#if serverError}}
57+
<div class='row error'>
58+
<div>
59+
{{ serverError }}
60+
</div>
61+
</div>
62+
{{/if}}
63+
64+
{{#if api_token.token}}
65+
<div class='row new-token'>
66+
<div>
67+
Please record this token somewhere, you cannot retrieve
68+
its value again. For use on the command line you can save it to <code>~/.cargo/config</code>
69+
with:
70+
71+
<pre>cargo login {{ api_token.token }}</pre>
72+
</div>
73+
</div>
74+
{{/if}}

app/templates/me/index.hbs

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,36 @@
99
<h2>Profile Information</h2>
1010

1111
<div class='info'>
12-
{{#user-link user=model }} {{user-avatar user=model size='medium'}} {{/user-link}}
12+
{{#user-link user=model.user }} {{user-avatar user=model.user size='medium'}} {{/user-link}}
1313

1414
<dl>
1515
<dt>Name</dt>
16-
<dd>{{ model.name }}</dd>
16+
<dd>{{ model.user.name }}</dd>
1717
<dt>GitHub Account</dt>
18-
<dd>{{ model.login }}</dd>
18+
<dd>{{ model.user.login }}</dd>
1919
<dt>Email</dt>
20-
<dd>{{ model.email }}</dd>
20+
<dd>{{ model.user.email }}</dd>
2121
</dl>
2222
</div>
2323
</div>
2424

2525
<div id='me-api'>
26-
<h2>API Access</h2>
26+
<div class='me-subheading'>
27+
<h2>API Access</h2>
28+
<div class='right'>
29+
<button class='yellow-button' disabled={{disableCreate}} {{action "startNewToken"}}>New Token</button>
30+
</div>
31+
</div>
2732

28-
<p class='api'>Your API key is <strong>{{ model.api_token }}</strong></p>
2933
<p>
3034
If you want to use package commands from the command line, you'll need a
31-
<code>~/.cargo/config</code> which can be generated with:
35+
<code>~/.cargo/config</code>, the first step to creating this
36+
is to generate a new token using the button above.
3237
</p>
33-
<pre>cargo login {{ model.api_token }}</pre>
34-
35-
<button {{action "resetToken"}} class='yellow-button'>
36-
Reset my API key
37-
</button>
3838

39-
<img src="/assets/ajax-loader.gif"
40-
class={{if isResetting "" "hidden"}} />
39+
<div id='tokens'>
40+
{{#each sortedTokens as |api_token|}}
41+
{{api-token-row api_token=api_token}}
42+
{{/each}}
43+
</div>
4144
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE api_tokens;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
CREATE TABLE api_tokens (
2+
id SERIAL PRIMARY KEY,
3+
user_id integer NOT NULL REFERENCES users(id),
4+
token character varying DEFAULT random_string(32) NOT NULL UNIQUE,
5+
name character varying NOT NULL,
6+
created_at timestamp without time zone DEFAULT now() NOT NULL,
7+
last_used_at timestamp without time zone
8+
);
9+
10+
CREATE INDEX ON api_tokens (token);
11+
12+
INSERT INTO api_tokens (user_id, token, name)
13+
SELECT id, api_token, '' FROM users;
14+
15+
-- To be done in a cleanup migration later.
16+
-- ALTER TABLE users DROP COLUMN api_token;

0 commit comments

Comments
 (0)