diff --git a/app/controllers/settings/tokens/new.js b/app/controllers/settings/tokens/new.js index 97d6ccab15d..fde3af6d7f4 100644 --- a/app/controllers/settings/tokens/new.js +++ b/app/controllers/settings/tokens/new.js @@ -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; @@ -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' }, @@ -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 @@ -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() { @@ -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 ${this.pattern.slice(0, -1)}`); + } else { + return htmlSafe(`Matches only the ${this.pattern} 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'); } diff --git a/app/styles/settings/tokens/new.module.css b/app/styles/settings/tokens/new.module.css index c4a485233c0..b31e6dd83b5 100644 --- a/app/styles/settings/tokens/new.module.css +++ b/app/styles/settings/tokens/new.module.css @@ -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; diff --git a/app/templates/settings/tokens/new.hbs b/app/templates/settings/tokens/new.hbs index f82e8e20fb1..cf91d7e48fa 100644 --- a/app/templates/settings/tokens/new.hbs +++ b/app/templates/settings/tokens/new.hbs @@ -66,6 +66,67 @@ {{/if}} +
+
+ Crates + + + Help + {{svg-jar "circle-question"}} + +
+ + +
+