Skip to content

Commit 1a9fdcc

Browse files
authored
Merge pull request #6432 from Turbo87/crate-scopes
settings/tokens/new: Add "Crates" section
2 parents ed5150b + 4e1d0a2 commit 1a9fdcc

File tree

4 files changed

+314
-2
lines changed

4 files changed

+314
-2
lines changed

app/controllers/settings/tokens/new.js

+88-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import Controller from '@ember/controller';
22
import { action } from '@ember/object';
33
import { inject as service } from '@ember/service';
4+
import { htmlSafe } from '@ember/template';
45
import { tracked } from '@glimmer/tracking';
56

67
import { task } from 'ember-concurrency';
8+
import { TrackedArray } from 'tracked-built-ins';
79

810
export default class NewTokenController extends Controller {
911
@service notifications;
@@ -15,6 +17,7 @@ export default class NewTokenController extends Controller {
1517
@tracked nameInvalid;
1618
@tracked scopes;
1719
@tracked scopesInvalid;
20+
@tracked crateScopes;
1821

1922
ENDPOINT_SCOPES = [
2023
{ id: 'change-owners', description: 'Invite new crate owners or remove existing ones' },
@@ -36,7 +39,16 @@ export default class NewTokenController extends Controller {
3639
if (!this.validate()) return;
3740
let { name, scopes } = this;
3841

39-
let token = this.store.createRecord('api-token', { name, endpoint_scopes: scopes });
42+
let crateScopes = this.crateScopes.map(it => it.pattern);
43+
if (crateScopes.length === 0) {
44+
crateScopes = null;
45+
}
46+
47+
let token = this.store.createRecord('api-token', {
48+
name,
49+
endpoint_scopes: scopes,
50+
crate_scopes: crateScopes,
51+
});
4052

4153
try {
4254
// Save the new API token on the backend
@@ -60,13 +72,15 @@ export default class NewTokenController extends Controller {
6072
this.nameInvalid = false;
6173
this.scopes = [];
6274
this.scopesInvalid = false;
75+
this.crateScopes = TrackedArray.of();
6376
}
6477

6578
validate() {
6679
this.nameInvalid = !this.name;
6780
this.scopesInvalid = this.scopes.length === 0;
81+
let crateScopesValid = this.crateScopes.map(pattern => pattern.validate(false)).every(Boolean);
6882

69-
return !this.nameInvalid && !this.scopesInvalid;
83+
return !this.nameInvalid && !this.scopesInvalid && crateScopesValid;
7084
}
7185

7286
@action resetNameValidation() {
@@ -77,4 +91,76 @@ export default class NewTokenController extends Controller {
7791
this.scopes = this.scopes.includes(id) ? this.scopes.filter(it => it !== id) : [...this.scopes, id];
7892
this.scopesInvalid = false;
7993
}
94+
95+
@action addCratePattern() {
96+
this.crateScopes.push(new CratePattern(''));
97+
}
98+
99+
@action removeCrateScope(index) {
100+
this.crateScopes.splice(index, 1);
101+
}
102+
}
103+
104+
class CratePattern {
105+
@tracked pattern;
106+
@tracked showAsInvalid = false;
107+
108+
constructor(pattern) {
109+
this.pattern = pattern;
110+
}
111+
112+
get isValid() {
113+
return isValidPattern(this.pattern);
114+
}
115+
116+
get hasWildcard() {
117+
return this.pattern.endsWith('*');
118+
}
119+
120+
get description() {
121+
if (!this.pattern) {
122+
return 'Please enter a crate name pattern';
123+
} else if (this.pattern === '*') {
124+
return 'Matches all crates on crates.io';
125+
} else if (!this.isValid) {
126+
return 'Invalid crate name pattern';
127+
} else if (this.hasWildcard) {
128+
return htmlSafe(`Matches all crates starting with <strong>${this.pattern.slice(0, -1)}</strong>`);
129+
} else {
130+
return htmlSafe(`Matches only the <strong>${this.pattern}</strong> crate`);
131+
}
132+
}
133+
134+
@action resetValidation() {
135+
this.showAsInvalid = false;
136+
}
137+
138+
@action validate(ignoreEmpty = true) {
139+
let valid = this.isValid || (ignoreEmpty && this.pattern === '');
140+
this.showAsInvalid = !valid;
141+
return valid;
142+
}
143+
}
144+
145+
function isValidIdent(pattern) {
146+
return (
147+
[...pattern].every(c => isAsciiAlphanumeric(c) || c === '_' || c === '-') &&
148+
pattern[0] !== '_' &&
149+
pattern[0] !== '-'
150+
);
151+
}
152+
153+
function isValidPattern(pattern) {
154+
if (!pattern) return false;
155+
if (pattern === '*') return true;
156+
157+
if (pattern.endsWith('*')) {
158+
pattern = pattern.slice(0, -1);
159+
}
160+
161+
return isValidIdent(pattern);
162+
}
163+
164+
function isAsciiAlphanumeric(c) {
165+
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
80166
}

app/styles/settings/tokens/new.module.css

+98
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,104 @@
9393
display: inline-block;
9494
}
9595

96+
.crates-list {
97+
list-style: none;
98+
padding: 0;
99+
margin: 0;
100+
background-color: white;
101+
border: 1px solid var(--gray-border);
102+
border-radius: var(--space-3xs);
103+
104+
> * + * {
105+
border-top: inherit;
106+
}
107+
}
108+
109+
.crates-unrestricted {
110+
padding: var(--space-xs) var(--space-s);
111+
font-size: 0.9em;
112+
}
113+
114+
.crates-scope {
115+
display: flex;
116+
117+
> div {
118+
padding: var(--space-xs) var(--space-s);
119+
display: flex;
120+
flex-wrap: wrap;
121+
gap: var(--space-xs);
122+
font-size: 0.9em;
123+
flex-grow: 1;
124+
}
125+
126+
input {
127+
margin: calc(-1 * var(--space-3xs) - 2px) 0;
128+
padding: var(--space-3xs) var(--space-2xs);
129+
border: 1px solid var(--gray-border);
130+
border-radius: var(--space-3xs);
131+
}
132+
133+
&.invalid input {
134+
background: #fff2f2;
135+
border-color: red;
136+
}
137+
138+
> button {
139+
margin: 0;
140+
padding: 0 var(--space-xs);
141+
border: none;
142+
background: none;
143+
cursor: pointer;
144+
color: var(--grey700);
145+
flex-shrink: 0;
146+
display: flex;
147+
align-items: center;
148+
149+
&:hover {
150+
background: var(--grey200);
151+
color: var(--grey900);
152+
}
153+
154+
svg {
155+
height: 1.1em;
156+
width: 1.1em;
157+
}
158+
}
159+
160+
&:first-child button {
161+
border-top-right-radius: var(--space-3xs);
162+
}
163+
}
164+
165+
.pattern-description {
166+
flex-grow: 1;
167+
align-self: center;
168+
169+
.invalid & {
170+
color: red;
171+
}
172+
173+
> span {
174+
font-weight: bold;
175+
}
176+
}
177+
178+
.crates-pattern-button button {
179+
padding: var(--space-xs) var(--space-s);
180+
font-size: 0.9em;
181+
width: 100%;
182+
border: none;
183+
background: none;
184+
border-bottom-left-radius: var(--space-3xs);
185+
border-bottom-right-radius: var(--space-3xs);
186+
cursor: pointer;
187+
font-weight: bold;
188+
189+
&:hover {
190+
background: var(--grey200);
191+
}
192+
}
193+
96194
.generate-button {
97195
composes: yellow-button small from '../../../styles/shared/buttons.module.css';
98196
border-radius: 4px;

app/templates/settings/tokens/new.hbs

+61
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,67 @@
6666
{{/if}}
6767
</div>
6868

69+
<div local-class="form-group" data-test-scopes-group>
70+
<div local-class="form-group-name">
71+
Crates
72+
73+
<a
74+
href="https://rust-lang.github.io/rfcs/2947-crates-io-token-scopes.html"
75+
target="_blank"
76+
rel="noopener noreferrer"
77+
local-class="help-link"
78+
>
79+
<span local-class="hidden-label">Help</span>
80+
{{svg-jar "circle-question"}}
81+
</a>
82+
</div>
83+
84+
<ul role="list" local-class="crates-list">
85+
{{#each this.crateScopes as |pattern index|}}
86+
<li
87+
local-class="crates-scope {{if pattern.showAsInvalid "invalid"}}"
88+
data-test-crate-pattern={{index}}
89+
>
90+
<div>
91+
<Input
92+
@value={{pattern.pattern}}
93+
aria-label="Crate name pattern"
94+
{{on "input" pattern.resetValidation}}
95+
{{on "blur" pattern.validate}}
96+
/>
97+
98+
<span local-class="pattern-description" data-test-description>
99+
{{pattern.description}}
100+
</span>
101+
</div>
102+
103+
<button
104+
type="button"
105+
data-test-remove
106+
{{on "click" (fn this.removeCrateScope index)}}
107+
>
108+
<span local-class="hidden-label">Remove pattern</span>
109+
{{svg-jar "trash"}}
110+
</button>
111+
</li>
112+
{{else}}
113+
<li local-class="crates-unrestricted" data-test-crates-unrestricted>
114+
<strong>Unrestricted</strong> – This token can be used for all of your crates.
115+
</li>
116+
{{/each}}
117+
118+
<li local-class="crates-pattern-button">
119+
<button
120+
type="button"
121+
data-test-add-crate-pattern
122+
{{on "click" this.addCratePattern}}
123+
>
124+
Add pattern
125+
</button>
126+
</li>
127+
</ul>
128+
</div>
129+
69130
<div local-class="buttons">
70131
<button
71132
type="submit"

tests/routes/settings/tokens/new-test.js

+67
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,73 @@ module('/settings/tokens/new', function (hooks) {
6868
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
6969
});
7070

71+
test('crate scopes', async function (assert) {
72+
prepare(this);
73+
74+
await visit('/settings/tokens/new');
75+
assert.strictEqual(currentURL(), '/settings/tokens/new');
76+
77+
await fillIn('[data-test-name]', 'token-name');
78+
await click('[data-test-scope="publish-update"]');
79+
80+
assert.dom('[data-test-crates-unrestricted]').exists();
81+
assert.dom('[data-test-crate-pattern]').doesNotExist();
82+
83+
await click('[data-test-add-crate-pattern]');
84+
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
85+
assert.dom('[data-test-crate-pattern]').exists({ count: 1 });
86+
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Please enter a crate name pattern');
87+
88+
await fillIn('[data-test-crate-pattern="0"] input', 'serde');
89+
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Matches only the serde crate');
90+
91+
await click('[data-test-crate-pattern="0"] [data-test-remove]');
92+
assert.dom('[data-test-crates-unrestricted]').exists();
93+
assert.dom('[data-test-crate-pattern]').doesNotExist();
94+
95+
await click('[data-test-add-crate-pattern]');
96+
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
97+
assert.dom('[data-test-crate-pattern]').exists({ count: 1 });
98+
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Please enter a crate name pattern');
99+
100+
await fillIn('[data-test-crate-pattern="0"] input', 'serde-*');
101+
assert
102+
.dom('[data-test-crate-pattern="0"] [data-test-description]')
103+
.hasText('Matches all crates starting with serde-');
104+
105+
await click('[data-test-add-crate-pattern]');
106+
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
107+
assert.dom('[data-test-crate-pattern]').exists({ count: 2 });
108+
assert.dom('[data-test-crate-pattern="1"] [data-test-description]').hasText('Please enter a crate name pattern');
109+
110+
await fillIn('[data-test-crate-pattern="1"] input', 'inv@lid');
111+
assert.dom('[data-test-crate-pattern="1"] [data-test-description]').hasText('Invalid crate name pattern');
112+
113+
await click('[data-test-add-crate-pattern]');
114+
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
115+
assert.dom('[data-test-crate-pattern]').exists({ count: 3 });
116+
assert.dom('[data-test-crate-pattern="2"] [data-test-description]').hasText('Please enter a crate name pattern');
117+
118+
await fillIn('[data-test-crate-pattern="2"] input', 'serde');
119+
assert.dom('[data-test-crate-pattern="2"] [data-test-description]').hasText('Matches only the serde crate');
120+
121+
await click('[data-test-crate-pattern="1"] [data-test-remove]');
122+
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
123+
assert.dom('[data-test-crate-pattern]').exists({ count: 2 });
124+
125+
await click('[data-test-generate]');
126+
127+
let token = this.server.schema.apiTokens.findBy({ name: 'token-name' });
128+
assert.ok(Boolean(token), 'API token has been created in the backend database');
129+
assert.strictEqual(token.name, 'token-name');
130+
assert.deepEqual(token.crateScopes, ['serde-*', 'serde']);
131+
assert.deepEqual(token.endpointScopes, ['publish-update']);
132+
133+
assert.strictEqual(currentURL(), '/settings/tokens');
134+
assert.dom('[data-test-api-token="1"] [data-test-name]').hasText('token-name');
135+
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
136+
});
137+
71138
test('loading and error state', async function (assert) {
72139
prepare(this);
73140

0 commit comments

Comments
 (0)