Skip to content

Commit

Permalink
Merge pull request #6432 from Turbo87/crate-scopes
Browse files Browse the repository at this point in the history
settings/tokens/new: Add "Crates" section
  • Loading branch information
Turbo87 authored May 5, 2023
2 parents ed5150b + 4e1d0a2 commit 1a9fdcc
Show file tree
Hide file tree
Showing 4 changed files with 314 additions and 2 deletions.
90 changes: 88 additions & 2 deletions app/controllers/settings/tokens/new.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { htmlSafe } from '@ember/template';
import { tracked } from '@glimmer/tracking';

import { task } from 'ember-concurrency';
import { TrackedArray } from 'tracked-built-ins';

export default class NewTokenController extends Controller {
@service notifications;
Expand All @@ -15,6 +17,7 @@ export default class NewTokenController extends Controller {
@tracked nameInvalid;
@tracked scopes;
@tracked scopesInvalid;
@tracked crateScopes;

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

let token = this.store.createRecord('api-token', { name, endpoint_scopes: scopes });
let crateScopes = this.crateScopes.map(it => it.pattern);
if (crateScopes.length === 0) {
crateScopes = null;
}

let token = this.store.createRecord('api-token', {
name,
endpoint_scopes: scopes,
crate_scopes: crateScopes,
});

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

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

return !this.nameInvalid && !this.scopesInvalid;
return !this.nameInvalid && !this.scopesInvalid && crateScopesValid;
}

@action resetNameValidation() {
Expand All @@ -77,4 +91,76 @@ export default class NewTokenController extends Controller {
this.scopes = this.scopes.includes(id) ? this.scopes.filter(it => it !== id) : [...this.scopes, id];
this.scopesInvalid = false;
}

@action addCratePattern() {
this.crateScopes.push(new CratePattern(''));
}

@action removeCrateScope(index) {
this.crateScopes.splice(index, 1);
}
}

class CratePattern {
@tracked pattern;
@tracked showAsInvalid = false;

constructor(pattern) {
this.pattern = pattern;
}

get isValid() {
return isValidPattern(this.pattern);
}

get hasWildcard() {
return this.pattern.endsWith('*');
}

get description() {
if (!this.pattern) {
return 'Please enter a crate name pattern';
} else if (this.pattern === '*') {
return 'Matches all crates on crates.io';
} else if (!this.isValid) {
return 'Invalid crate name pattern';
} else if (this.hasWildcard) {
return htmlSafe(`Matches all crates starting with <strong>${this.pattern.slice(0, -1)}</strong>`);
} else {
return htmlSafe(`Matches only the <strong>${this.pattern}</strong> crate`);
}
}

@action resetValidation() {
this.showAsInvalid = false;
}

@action validate(ignoreEmpty = true) {
let valid = this.isValid || (ignoreEmpty && this.pattern === '');
this.showAsInvalid = !valid;
return valid;
}
}

function isValidIdent(pattern) {
return (
[...pattern].every(c => isAsciiAlphanumeric(c) || c === '_' || c === '-') &&
pattern[0] !== '_' &&
pattern[0] !== '-'
);
}

function isValidPattern(pattern) {
if (!pattern) return false;
if (pattern === '*') return true;

if (pattern.endsWith('*')) {
pattern = pattern.slice(0, -1);
}

return isValidIdent(pattern);
}

function isAsciiAlphanumeric(c) {
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
}
98 changes: 98 additions & 0 deletions app/styles/settings/tokens/new.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,104 @@
display: inline-block;
}

.crates-list {
list-style: none;
padding: 0;
margin: 0;
background-color: white;
border: 1px solid var(--gray-border);
border-radius: var(--space-3xs);

> * + * {
border-top: inherit;
}
}

.crates-unrestricted {
padding: var(--space-xs) var(--space-s);
font-size: 0.9em;
}

.crates-scope {
display: flex;

> div {
padding: var(--space-xs) var(--space-s);
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
font-size: 0.9em;
flex-grow: 1;
}

input {
margin: calc(-1 * var(--space-3xs) - 2px) 0;
padding: var(--space-3xs) var(--space-2xs);
border: 1px solid var(--gray-border);
border-radius: var(--space-3xs);
}

&.invalid input {
background: #fff2f2;
border-color: red;
}

> button {
margin: 0;
padding: 0 var(--space-xs);
border: none;
background: none;
cursor: pointer;
color: var(--grey700);
flex-shrink: 0;
display: flex;
align-items: center;

&:hover {
background: var(--grey200);
color: var(--grey900);
}

svg {
height: 1.1em;
width: 1.1em;
}
}

&:first-child button {
border-top-right-radius: var(--space-3xs);
}
}

.pattern-description {
flex-grow: 1;
align-self: center;

.invalid & {
color: red;
}

> span {
font-weight: bold;
}
}

.crates-pattern-button button {
padding: var(--space-xs) var(--space-s);
font-size: 0.9em;
width: 100%;
border: none;
background: none;
border-bottom-left-radius: var(--space-3xs);
border-bottom-right-radius: var(--space-3xs);
cursor: pointer;
font-weight: bold;

&:hover {
background: var(--grey200);
}
}

.generate-button {
composes: yellow-button small from '../../../styles/shared/buttons.module.css';
border-radius: 4px;
Expand Down
61 changes: 61 additions & 0 deletions app/templates/settings/tokens/new.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,67 @@
{{/if}}
</div>

<div local-class="form-group" data-test-scopes-group>
<div local-class="form-group-name">
Crates

<a
href="https://rust-lang.github.io/rfcs/2947-crates-io-token-scopes.html"
target="_blank"
rel="noopener noreferrer"
local-class="help-link"
>
<span local-class="hidden-label">Help</span>
{{svg-jar "circle-question"}}
</a>
</div>

<ul role="list" local-class="crates-list">
{{#each this.crateScopes as |pattern index|}}
<li
local-class="crates-scope {{if pattern.showAsInvalid "invalid"}}"
data-test-crate-pattern={{index}}
>
<div>
<Input
@value={{pattern.pattern}}
aria-label="Crate name pattern"
{{on "input" pattern.resetValidation}}
{{on "blur" pattern.validate}}
/>

<span local-class="pattern-description" data-test-description>
{{pattern.description}}
</span>
</div>

<button
type="button"
data-test-remove
{{on "click" (fn this.removeCrateScope index)}}
>
<span local-class="hidden-label">Remove pattern</span>
{{svg-jar "trash"}}
</button>
</li>
{{else}}
<li local-class="crates-unrestricted" data-test-crates-unrestricted>
<strong>Unrestricted</strong> – This token can be used for all of your crates.
</li>
{{/each}}

<li local-class="crates-pattern-button">
<button
type="button"
data-test-add-crate-pattern
{{on "click" this.addCratePattern}}
>
Add pattern
</button>
</li>
</ul>
</div>

<div local-class="buttons">
<button
type="submit"
Expand Down
67 changes: 67 additions & 0 deletions tests/routes/settings/tokens/new-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,73 @@ module('/settings/tokens/new', function (hooks) {
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
});

test('crate scopes', async function (assert) {
prepare(this);

await visit('/settings/tokens/new');
assert.strictEqual(currentURL(), '/settings/tokens/new');

await fillIn('[data-test-name]', 'token-name');
await click('[data-test-scope="publish-update"]');

assert.dom('[data-test-crates-unrestricted]').exists();
assert.dom('[data-test-crate-pattern]').doesNotExist();

await click('[data-test-add-crate-pattern]');
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
assert.dom('[data-test-crate-pattern]').exists({ count: 1 });
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Please enter a crate name pattern');

await fillIn('[data-test-crate-pattern="0"] input', 'serde');
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Matches only the serde crate');

await click('[data-test-crate-pattern="0"] [data-test-remove]');
assert.dom('[data-test-crates-unrestricted]').exists();
assert.dom('[data-test-crate-pattern]').doesNotExist();

await click('[data-test-add-crate-pattern]');
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
assert.dom('[data-test-crate-pattern]').exists({ count: 1 });
assert.dom('[data-test-crate-pattern="0"] [data-test-description]').hasText('Please enter a crate name pattern');

await fillIn('[data-test-crate-pattern="0"] input', 'serde-*');
assert
.dom('[data-test-crate-pattern="0"] [data-test-description]')
.hasText('Matches all crates starting with serde-');

await click('[data-test-add-crate-pattern]');
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
assert.dom('[data-test-crate-pattern]').exists({ count: 2 });
assert.dom('[data-test-crate-pattern="1"] [data-test-description]').hasText('Please enter a crate name pattern');

await fillIn('[data-test-crate-pattern="1"] input', 'inv@lid');
assert.dom('[data-test-crate-pattern="1"] [data-test-description]').hasText('Invalid crate name pattern');

await click('[data-test-add-crate-pattern]');
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
assert.dom('[data-test-crate-pattern]').exists({ count: 3 });
assert.dom('[data-test-crate-pattern="2"] [data-test-description]').hasText('Please enter a crate name pattern');

await fillIn('[data-test-crate-pattern="2"] input', 'serde');
assert.dom('[data-test-crate-pattern="2"] [data-test-description]').hasText('Matches only the serde crate');

await click('[data-test-crate-pattern="1"] [data-test-remove]');
assert.dom('[data-test-crates-unrestricted]').doesNotExist();
assert.dom('[data-test-crate-pattern]').exists({ count: 2 });

await click('[data-test-generate]');

let token = this.server.schema.apiTokens.findBy({ name: 'token-name' });
assert.ok(Boolean(token), 'API token has been created in the backend database');
assert.strictEqual(token.name, 'token-name');
assert.deepEqual(token.crateScopes, ['serde-*', 'serde']);
assert.deepEqual(token.endpointScopes, ['publish-update']);

assert.strictEqual(currentURL(), '/settings/tokens');
assert.dom('[data-test-api-token="1"] [data-test-name]').hasText('token-name');
assert.dom('[data-test-api-token="1"] [data-test-token]').hasText(token.token);
});

test('loading and error state', async function (assert) {
prepare(this);

Expand Down

0 comments on commit 1a9fdcc

Please sign in to comment.