Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

settings/tokens/new: Add "Crates" section #6432

Merged
merged 1 commit into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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