diff --git a/app/components/api-token-row.js b/app/components/api-token-row.js index 280d1abad60..686c10ada0e 100644 --- a/app/components/api-token-row.js +++ b/app/components/api-token-row.js @@ -1,10 +1,13 @@ import Component from '@ember/component'; +import { inject as service } from '@ember/service'; import { empty, or } from '@ember/object/computed'; export default Component.extend({ emptyName: empty('api_token.name'), disableCreate: or('api_token.isSaving', 'emptyName'), serverError: null, + session: service(), + store: service(), didInsertElement() { let input = this.element.querySelector('input'); @@ -17,6 +20,7 @@ export default Component.extend({ async saveToken() { try { await this.api_token.save(); + this.set('session.currentUser.has_tokens', true); this.set('serverError', null); } catch (err) { let msg; @@ -31,6 +35,11 @@ export default Component.extend({ async revokeToken() { try { + // To avoid error on destroy we need to set before destroying of api-token + // that's why we need to set length of api-tokens to 1 in check + if ((await this.store.query('api-token', {})).length == 1) { + this.set('session.currentUser.has_tokens', false); + } await this.api_token.destroyRecord(); } catch (err) { let msg; diff --git a/app/components/welcome-message.hbs b/app/components/welcome-message.hbs new file mode 100644 index 00000000000..8c91120f20a --- /dev/null +++ b/app/components/welcome-message.hbs @@ -0,0 +1,3 @@ +

+ Welcome to crates.io! Visit account settings to {{this.text}} +

\ No newline at end of file diff --git a/app/components/welcome-message.js b/app/components/welcome-message.js new file mode 100644 index 00000000000..fd8f179bd7f --- /dev/null +++ b/app/components/welcome-message.js @@ -0,0 +1,26 @@ +import Component from '@ember/component'; +import { inject as service } from '@ember/service'; +import { computed } from '@ember/object'; +import { notEmpty } from '@ember/object/computed'; + +export default Component.extend({ + session: service(), + + text: computed('session.currentUser.{email_verified,has_tokens}', function() { + const user = this.get('session.currentUser'); + if (!user || (user.email_verified && user.has_tokens)) return ''; + + const textArray = [ + !user.email_verified && 'verify your email address', + !user.email_verified && !user.has_tokens && ' and ', + !user.has_tokens && 'create an API token', + '!', + ].filter(e => !!e); + + return textArray.join(''); + }), + + showMessage: notEmpty('text').readOnly(), + + tagName: '', +}); diff --git a/app/components/welcome-message.module.scss b/app/components/welcome-message.module.scss new file mode 100644 index 00000000000..6c7dce346ab --- /dev/null +++ b/app/components/welcome-message.module.scss @@ -0,0 +1,18 @@ +.welcome-message { + display: none; + font-weight: bold; + font-size: 110%; + text-align: center; + margin: 0 0 10px 0; + padding: 10px; + border-radius: 5px; + + &.shown { + display: block; + } + + &.info { + background-color: $main-bg-dark; + border: 2px solid #62865f; + } +} diff --git a/app/models/user.js b/app/models/user.js index 7fa7fd1f8f8..e991b53e223 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -9,6 +9,7 @@ export default Model.extend({ avatar: attr('string'), url: attr('string'), kind: attr('string'), + has_tokens: attr('boolean'), stats() { return this.store.adapterFor('user').stats(this.id); diff --git a/app/routes/me/index.js b/app/routes/me/index.js index b35a921b122..11794e29ffc 100644 --- a/app/routes/me/index.js +++ b/app/routes/me/index.js @@ -5,12 +5,10 @@ import AuthenticatedRoute from '../../mixins/authenticated-route'; export default Route.extend(AuthenticatedRoute, { actions: { willTransition: function() { - this.controller - .setProperties({ - emailNotificationsSuccess: false, - emailNotificationsError: false, - }) - .clear(); + this.controller.setProperties({ + emailNotificationsSuccess: false, + emailNotificationsError: false, + }); }, }, model() { diff --git a/app/services/flash-messages.js b/app/services/flash-messages.js index 21ea24e08c5..ed00ae4edc0 100644 --- a/app/services/flash-messages.js +++ b/app/services/flash-messages.js @@ -4,8 +4,9 @@ export default class FlashMessagesService extends Service { message = null; _nextMessage = null; - show(message) { + show(message, options = { type: 'warning' }) { this.set('message', message); + this.set('options', options); } queue(message) { diff --git a/app/templates/catch-all.hbs b/app/templates/catch-all.hbs index 41ff2a09f10..b918f0251b8 100644 --- a/app/templates/catch-all.hbs +++ b/app/templates/catch-all.hbs @@ -3,13 +3,6 @@

Perhaps a search of the site may help? - -

+ +

\ No newline at end of file diff --git a/app/templates/index.hbs b/app/templates/index.hbs index 207840e9c05..a7485afb30a 100644 --- a/app/templates/index.hbs +++ b/app/templates/index.hbs @@ -1,8 +1,10 @@ +

The Rust community’s crate registry

- + {{svg-jar "button-download"}} Install Cargo @@ -24,12 +26,14 @@
{{svg-jar "download"}} - {{if this.hasData (format-num this.model.num_downloads) "---,---,---"}} + {{if this.hasData (format-num this.model.num_downloads) "---,---,---"}} Downloads
{{svg-jar "crate"}} - {{if this.hasData (format-num this.model.num_crates) "---,---"}} + {{if this.hasData (format-num this.model.num_crates) "---,---"}} Crates in stock
@@ -53,11 +57,13 @@
-

Popular Keywords (see all)

+

Popular Keywords (see all) +

-

Popular Categories (see all)

+

Popular Categories (see all) +

-
+
\ No newline at end of file diff --git a/mirage/factories/user.js b/mirage/factories/user.js index 66ed4e55f74..d494b1dde31 100644 --- a/mirage/factories/user.js +++ b/mirage/factories/user.js @@ -1,16 +1,29 @@ -import { Factory } from 'ember-cli-mirage'; -import { dasherize } from '@ember/string'; +import { Factory, trait } from 'ember-cli-mirage'; +import faker from 'faker'; export default Factory.extend({ - name: i => `User ${i + 1}`, - + email_verified: false, + email_verification_sent: true, + name() { + return faker.name.findName(); + }, login() { - return dasherize(this.name); + return faker.internet.userName(); + }, + avatar() { + return faker.image.imageUrl(); }, - url() { - return `https://github.com/${this.login}`; + return faker.internet.url(); }, + kind: 'user', + has_tokens: false, + + withVerifiedEmail: trait({ + email_verified: true, + }), - avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', + withTokens: trait({ + has_tokens: true, + }), }); diff --git a/package-lock.json b/package-lock.json index 5a6a853fdd7..faa9fec7ab1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38923,6 +38923,12 @@ "integrity": "sha512-Kn2WYYS6cDBS5jq/voOfSGCA0TafOYAUPbEp8mUVpD/DVV5bQIDjlq+MLLvNUokkbTpjBVlLDaM5PnX+PwZMlw==", "dev": true }, + "faker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/faker/-/faker-4.1.0.tgz", + "integrity": "sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=", + "dev": true + }, "fast-deep-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", diff --git a/package.json b/package.json index 0860e31d1b2..eedc536af5e 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "eslint-config-prettier": "^6.10.0", "eslint-plugin-ember": "^7.10.1", "eslint-plugin-prettier": "^3.1.2", + "faker": "^4.1.0", "loader.js": "^4.7.0", "normalize.css": "^8.0.1", "nyc": "^15.0.0", diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs index dfd1fc1c6b6..07de33b8872 100644 --- a/src/controllers/user/me.rs +++ b/src/controllers/user/me.rs @@ -6,9 +6,9 @@ use crate::controllers::helpers::*; use crate::email; use crate::models::{ - CrateOwner, Email, Follow, NewEmail, OwnerKind, User, Version, VersionOwnerAction, + ApiToken, CrateOwner, Email, Follow, NewEmail, OwnerKind, User, Version, VersionOwnerAction, }; -use crate::schema::{crate_owners, crates, emails, follows, users, versions}; +use crate::schema::{api_tokens, crate_owners, crates, emails, follows, users, versions}; use crate::views::{EncodableMe, EncodableVersion, OwnedCrate}; /// Handles the `GET /me` route. @@ -27,6 +27,11 @@ pub fn me(req: &mut dyn Request) -> AppResult { )) .first::<(User, Option, Option, bool)>(&*conn)?; + let tokens: Vec = ApiToken::belonging_to(req.user()?) + .filter(api_tokens::revoked.eq(false)) + .load(&*conn)?; + let has_tokens = !tokens.is_empty(); + let owned_crates = CrateOwner::by_owner_kind(OwnerKind::User) .inner_join(crates::table) .filter(crate_owners::owner_id.eq(user_id)) @@ -44,7 +49,7 @@ pub fn me(req: &mut dyn Request) -> AppResult { let verified = verified.unwrap_or(false); let verification_sent = verified || verification_sent; Ok(req.json(&EncodableMe { - user: user.encodable_private(email, verified, verification_sent), + user: user.encodable_private(email, verified, verification_sent, has_tokens), owned_crates, })) } diff --git a/src/models/user.rs b/src/models/user.rs index d3f1dd3e381..90128e951ac 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -171,6 +171,7 @@ impl User { email: Option, email_verified: bool, email_verification_sent: bool, + has_tokens: bool, ) -> EncodablePrivateUser { let User { id, @@ -186,6 +187,7 @@ impl User { email, email_verified, email_verification_sent, + has_tokens, avatar: gh_avatar, login: gh_login, name, diff --git a/src/tests/user.rs b/src/tests/user.rs index ee1e77bc7b4..57a8c61e16a 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -5,7 +5,7 @@ use crate::{ OkBool, TestApp, }; use cargo_registry::{ - models::{Email, NewUser, User}, + models::{ApiToken, Email, NewUser, User}, schema::crate_owners, views::{EncodablePrivateUser, EncodablePublicUser, EncodableVersion, OwnedCrate}, }; @@ -745,3 +745,19 @@ fn test_update_email_notifications_not_owned() { // There should be no change to the `email_notifications` value for a crate not belonging to me assert!(email_notifications); } + +#[test] +fn shows_that_user_has_tokens() { + let (app, _, user) = TestApp::init().with_user(); + + let user_id = user.as_model().id; + app.db(|conn| { + vec![ + t!(ApiToken::insert(conn, user_id, "bar")), + t!(ApiToken::insert(conn, user_id, "baz")), + ] + }); + + let json = user.show_me(); + assert!(json.user.has_tokens); +} diff --git a/src/views.rs b/src/views.rs index 86e042d350e..38c2feb6998 100644 --- a/src/views.rs +++ b/src/views.rs @@ -176,6 +176,7 @@ pub struct EncodablePrivateUser { pub email: Option, pub avatar: Option, pub url: Option, + pub has_tokens: bool, } /// The serialization format for the `User` model. diff --git a/tests/integration/components/flesh-message-test.js b/tests/integration/components/flesh-message-test.js new file mode 100644 index 00000000000..4ce750b8033 --- /dev/null +++ b/tests/integration/components/flesh-message-test.js @@ -0,0 +1,17 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; + +module('Integration | Component | flesh-message', function(hooks) { + setupRenderingTest(hooks); + + test('it renders', async function(assert) { + assert.expect(2); + + await render(hbs``); + + assert.dom('[data-test-flash-message]').hasText('test text'); + assert.dom('[data-test-flash-message]').isVisible(); + }); +}); diff --git a/tests/integration/components/welcome-message-test.js b/tests/integration/components/welcome-message-test.js new file mode 100644 index 00000000000..c6077b5f5ed --- /dev/null +++ b/tests/integration/components/welcome-message-test.js @@ -0,0 +1,67 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import hbs from 'htmlbars-inline-precompile'; +import setupMirage from '../../helpers/setup-mirage'; + +module('Integration | Component | welcome-message', function(hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + test('it renders', async function(assert) { + assert.expect(2); + const user = this.server.create('user'); + + this.session = this.owner.lookup('service:session'); + this.session.loginUser(user); + + await render(hbs`{{welcome-message}}`); + + assert + .dom('[data-test-welcome-message]') + .hasText('Welcome to crates.io! Visit account settings to verify your email address and create an API token!'); + assert.dom('[data-test-welcome-message]').isVisible(); + }); + + test('it show reminder about email only if user has tokens', async function(assert) { + assert.expect(2); + const user = this.server.create('user', 'withTokens'); + + this.session = this.owner.lookup('service:session'); + this.session.loginUser(user); + + await render(hbs`{{welcome-message}}`); + + assert + .dom('[data-test-welcome-message]') + .hasText('Welcome to crates.io! Visit account settings to verify your email address!'); + assert.dom('[data-test-welcome-message]').isVisible(); + }); + + test('it show reminder about tokens only if user has verified email', async function(assert) { + assert.expect(2); + const user = this.server.create('user', 'withVerifiedEmail'); + + this.session = this.owner.lookup('service:session'); + this.session.loginUser(user); + + await render(hbs`{{welcome-message}}`); + + assert + .dom('[data-test-welcome-message]') + .hasText('Welcome to crates.io! Visit account settings to create an API token!'); + assert.dom('[data-test-welcome-message]').isVisible(); + }); + + test('it not shows if user has tokens and verified email', async function(assert) { + assert.expect(1); + const user = this.server.create('user', 'withTokens', 'withVerifiedEmail'); + + this.session = this.owner.lookup('service:session'); + this.session.loginUser(user); + + await render(hbs`{{welcome-message}}`); + + assert.dom('[data-test-welcome-message]').isNotVisible(); + }); +}); diff --git a/tests/mirage/crates-test.js b/tests/mirage/crates-test.js index 78f51d5a279..df767e2e906 100644 --- a/tests/mirage/crates-test.js +++ b/tests/mirage/crates-test.js @@ -684,25 +684,14 @@ module('Mirage | Crates', function(hooks) { }); test('returns the list of users that own the specified crate', async function(assert) { - let user = this.server.create('user', { name: 'John Doe' }); + let user = this.server.create('user'); this.server.create('crate', { name: 'rand', userOwners: [user] }); let response = await fetch('/api/v1/crates/rand/owner_user'); assert.equal(response.status, 200); let responsePayload = await response.json(); - assert.deepEqual(responsePayload, { - users: [ - { - id: '1', - avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', - kind: 'user', - login: 'john-doe', - name: 'John Doe', - url: 'https://github.com/john-doe', - }, - ], - }); + assert.deepEqual(JSON.stringify(responsePayload), JSON.stringify({ users: [user] })); }); }); diff --git a/tests/mirage/users-test.js b/tests/mirage/users-test.js index 8766a5c98a3..a698ec7ad31 100644 --- a/tests/mirage/users-test.js +++ b/tests/mirage/users-test.js @@ -18,21 +18,14 @@ module('Mirage | Users', function(hooks) { }); test('returns a user object for known users', async function(assert) { - let user = this.server.create('user', { name: 'John Doe' }); + let user = this.server.create('user'); let response = await fetch(`/api/v1/users/${user.login}`); assert.equal(response.status, 200); let responsePayload = await response.json(); - assert.deepEqual(responsePayload, { - user: { - id: '1', - avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', - login: 'john-doe', - name: 'John Doe', - url: 'https://github.com/john-doe', - }, - }); + + assert.deepEqual(JSON.stringify(responsePayload), JSON.stringify({ user })); }); }); });