From ebf3b4aa471624a84fd4bf58a6234b9474a6da61 Mon Sep 17 00:00:00 2001 From: Phillip Thelen Date: Tue, 2 Oct 2018 23:17:06 +0200 Subject: [PATCH] Username announcement (#10729) * Change update username API call The call no longer requires a password and also validates the username. * Implement API call to verify username without setting it * Improve coding style * Apply username verification to registration * Update error messages * Validate display names. * Fix API early Stat Point allocation (#10680) * Refactor hasClass check to common so it can be used in shared & server-side code * Check that user has selected class before allocating stat points * chore(event): end Ember Hatching Potions * chore(analytics): reenable navigation tracking * update bcrypt * Point achievement modal links to main site (#10709) * Animal ears after death (#10691) * Animal Ears purchasable with Gold if lost in Death * remove ears from pinned items when set is bought * standardise css and error handling for gems and coins * revert accidental new line * fix client tests * Reduce margin-bottom of checklist-item from 10px to -3px. (#10684) * chore(i18n): update locales * 4.61.1 * feat(content): Subscriber Items and Magic Potions * chore(sprites): compile * chore(i18n): update locales * 4.62.0 * Display notification for users to confirm their username * fix typo * WIP(usernames): Changes to address #10694 * WIP(usernames): Further changes for #10694 * fix(usernames): don't show spurious headings * Change verify username notification to new version * Improve feedback for invalid usernames * Allow user to set their username again to confirm it * Improve validation display for usernames * Temporarily move display name validation outside of schema * Improve rendering banner about sleeping in the inn See #10695 * Display settings in one column * Position inn banner when window is resized * Update inn banner handling * Fix banner offset on initial load * Fix minor issues. * Issue: 10660 - Fixed. Changed default to Please Enter A Value (#10718) * Issue: 10660 - Fixed. Changed default to Please Enter A Value * Issue: 10660 - Fixed/revision 2 Changed default to Enter A Value * chore(news): Bailey announcements * chore(i18n): update locales * 4.62.1 * adjust wiki link for usernameInfo string https://github.com/HabitRPG/habitica-private/issues/7#issuecomment-425405425 * raise coverage for tasks api calls (#10029) * - updates a group task - approval is required - updates a group task with checklist * add expect to test the new checklist length * - moves tasks to a specified position out of length * remove unused line * website getter tasks tests * re-add sanitizeUserChallengeTask * change config.json.example variable to be a string not a boolean * fix tests - pick the text / up/down props too * fix test - remove changes on text/up/down - revert sanitize condition - revert sanitization props * Change update username API call The call no longer requires a password and also validates the username. * feat(content): Subscriber Items and Magic Potions * Re-add register call * Fix merge issue * Fix issue with setting username * Implement new alert style * Display username confirmation status in settings * Add disclaimer to change username field * validate username in settings * Allow specific fields to be focused when opening site settings * Implement requested changes. * Fix merge issue * Fix failing tests * verify username when users register with username and password * Set ID for change username notification * Disable submit button if username is invalid * Improve username confirmation handling * refactor(settings): address remaining code comments on auth form * Revert "refactor(settings): address remaining code comments on auth form" This reverts commit 9b6609ad646b23d9e3e394c1856f149d9a2d0995. * Social user username (#10620) * Refactored private functions to library * Refactored social login code * Added username to social registration * Changed id library * Added new local auth check * Fixed export error. Fixed password check error * fix(settings): password not available on client * refactor(settings): more sensible placement of methods * chore(migration): script to hand out procgen usernames * fix(migration): don't give EVERYONE new names you doofus * fix(migration): limit data retrieved, be extra careful about updates * fix(migration): use missing field, not migration tag, for query * fix(migration): unused var * fix(usernames): only generate 20 characters * fix(migration): set lowerCaseUsername --- migrations/users/generate-usernames.js | 99 ++++++++ package-lock.json | 14 ++ package.json | 1 + test/api/v3/integration/user/PUT-user.test.js | 20 +- .../user/auth/POST-register_local.test.js | 17 ++ .../user/auth/POST-user_auth_social.test.js | 5 +- test/api/v4/user/PUT-user.test.js | 2 +- .../auth/POST-user_verify_username.test.js | 89 +++++++ .../auth/PUT-user_update_username.test.js | 224 ++++++++++++++++++ website/client/assets/scss/iconalert.scss | 56 +++++ website/client/assets/scss/index.scss | 1 + .../client/assets/svg/for-css/alert-white.svg | 3 + website/client/components/auth/authForm.vue | 55 ++++- .../components/auth/registerLoginReset.vue | 62 ++++- .../header/notifications/guildInvitation.vue | 2 +- .../header/notifications/verifyUsername.vue | 74 ++++++ .../header/notificationsDropdown.vue | 13 + website/client/components/settings/site.vue | 147 +++++++++--- website/client/components/static/home.vue | 45 +++- website/client/store/actions/auth.js | 9 + website/common/locales/en/character.json | 4 +- website/common/locales/en/front.json | 12 +- website/common/locales/en/messages.json | 2 +- website/common/locales/en/settings.json | 26 +- website/server/controllers/api-v3/auth.js | 128 ++-------- website/server/controllers/api-v3/news.js | 3 + website/server/controllers/api-v4/auth.js | 96 +++++++- website/server/libs/auth/index.js | 37 ++- website/server/libs/auth/social.js | 108 +++++++++ website/server/libs/auth/utils.js | 30 +++ website/server/libs/forbiddenUsernames.js | 20 ++ website/server/libs/user/index.js | 11 +- website/server/libs/user/validation.js | 37 +++ website/server/middlewares/appRoutes.js | 2 + website/server/models/user/schema.js | 1 + 35 files changed, 1279 insertions(+), 176 deletions(-) create mode 100644 migrations/users/generate-usernames.js create mode 100644 test/api/v4/user/auth/POST-user_verify_username.test.js create mode 100644 test/api/v4/user/auth/PUT-user_update_username.test.js create mode 100644 website/client/assets/scss/iconalert.scss create mode 100644 website/client/assets/svg/for-css/alert-white.svg create mode 100644 website/client/components/header/notifications/verifyUsername.vue create mode 100644 website/server/libs/auth/social.js create mode 100644 website/server/libs/auth/utils.js create mode 100644 website/server/libs/forbiddenUsernames.js create mode 100644 website/server/libs/user/validation.js diff --git a/migrations/users/generate-usernames.js b/migrations/users/generate-usernames.js new file mode 100644 index 0000000000..85f2688017 --- /dev/null +++ b/migrations/users/generate-usernames.js @@ -0,0 +1,99 @@ +let authorName = 'Sabe'; // in case script author needs to know when their ... +let authorUuid = '7f14ed62-5408-4e1b-be83-ada62d504931'; // ... own data is done + +/* + * Generate usernames for users who lack them + */ + +import monk from 'monk'; +import nconf from 'nconf'; +import { generateUsername } from '../../website/server/libs/auth/utils'; +const CONNECTION_STRING = nconf.get('MIGRATION_CONNECT_STRING'); // FOR TEST DATABASE +let dbUsers = monk(CONNECTION_STRING).get('users', { castIds: false }); + +function processUsers (lastId) { + // specify a query to limit the affected users (empty for all users): + let query = { + 'auth.local.username': {$exists: false}, + 'auth.timestamps.loggedin': {$gt: new Date('2018-04-01')}, // Initial coverage for users active within last 6 months + }; + + if (lastId) { + query._id = { + $gt: lastId, + }; + } + + dbUsers.find(query, { + sort: {_id: 1}, + limit: 250, + fields: [ + 'auth', + ], // specify fields we are interested in to limit retrieved data (empty if we're not reading data): + }) + .then(updateUsers) + .catch((err) => { + console.log(err); + return exiting(1, `ERROR! ${ err}`); + }); +} + +let progressCount = 1000; +let count = 0; + +function updateUsers (users) { + if (!users || users.length === 0) { + console.warn('All appropriate users found and modified.'); + displayData(); + return; + } + + let userPromises = users.map(updateUser); + let lastUser = users[users.length - 1]; + + return Promise.all(userPromises) + .then(() => { + processUsers(lastUser._id); + }); +} + +function updateUser (user) { + count++; + + if (!user.auth.local.username) { + const newName = generateUsername(); + dbUsers.update( + {_id: user._id}, + {$set: + { + 'auth.local.username': newName, + 'auth.local.lowerCaseUsername': newName, + } + } + ); + } + if (count % progressCount === 0) console.warn(`${count } ${ user._id}`); + if (user._id === authorUuid) console.warn(`${authorName } processed`); +} + +function displayData () { + console.warn(`\n${ count } users processed\n`); + return exiting(0); +} + +function exiting (code, msg) { + code = code || 0; // 0 = success + if (code && !msg) { + msg = 'ERROR!'; + } + if (msg) { + if (code) { + console.error(msg); + } else { + console.log(msg); + } + } + process.exit(code); +} + +module.exports = processUsers; diff --git a/package-lock.json b/package-lock.json index 69b5f99e27..78d5c4cbc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -769,6 +769,11 @@ "ansi-wrap": "0.1.0" } }, + "any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==" + }, "anymatch": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", @@ -23763,6 +23768,15 @@ "rechoir": "^0.6.2" } }, + "short-uuid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/short-uuid/-/short-uuid-3.0.0.tgz", + "integrity": "sha512-AMyVEBZ6xlqlRYXaBlYlejjOeSEGfW9fBeRq95itPLt0eq4CDVsuOAgMYDx4TgPBuCQAMQ/NjNfW/lJKfdWejQ==", + "requires": { + "any-base": "^1.1.0", + "uuid": "^3.2.1" + } + }, "sigmund": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", diff --git a/package.json b/package.json index b004b1bdf0..d40f43e2c7 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "rimraf": "^2.4.3", "sass-loader": "^7.0.0", "shelljs": "^0.8.2", + "short-uuid": "^3.0.0", "smartbanner.js": "^1.9.1", "stripe": "^5.9.0", "superagent": "^3.8.3", diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js index 9642d3bb61..bef5ec25d0 100644 --- a/test/api/v3/integration/user/PUT-user.test.js +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -54,7 +54,7 @@ describe('PUT /user', () => { }); - it('profile.name cannot be an empty string or null', async () => { + it('validates profile.name', async () => { await expect(user.put('/user', { 'profile.name': ' ', // string should be trimmed })).to.eventually.be.rejected.and.eql({ @@ -76,7 +76,23 @@ describe('PUT /user', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'User validation failed', + message: t('invalidReqParams'), + }); + + await expect(user.put('/user', { + 'profile.name': 'this is a very long display name that will not be allowed due to length', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('displaynameIssueLength'), + }); + + await expect(user.put('/user', { + 'profile.name': 'TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('displaynameIssueSlur'), }); }); }); diff --git a/test/api/v3/integration/user/auth/POST-register_local.test.js b/test/api/v3/integration/user/auth/POST-register_local.test.js index 643ef3d9c2..1552ae8358 100644 --- a/test/api/v3/integration/user/auth/POST-register_local.test.js +++ b/test/api/v3/integration/user/auth/POST-register_local.test.js @@ -41,6 +41,23 @@ describe('POST /user/auth/local/register', () => { expect(user.newUser).to.eql(true); }); + it('registers a new user and sets verifiedUsername to true', async () => { + let username = generateRandomUserName(); + let email = `${username}@example.com`; + let password = 'password'; + + let user = await api.post('/user/auth/local/register', { + username, + email, + password, + confirmPassword: password, + }); + + expect(user._id).to.exist; + expect(user.apiToken).to.exist; + expect(user.flags.verifiedUsername).to.eql(true); + }); + xit('remove spaces from username', async () => { // TODO can probably delete this test now let username = ' usernamewithspaces '; diff --git a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js index dc399c619c..992dd0c6f5 100644 --- a/test/api/v3/integration/user/auth/POST-user_auth_social.test.js +++ b/test/api/v3/integration/user/auth/POST-user_auth_social.test.js @@ -39,7 +39,7 @@ describe('POST /user/auth/social', () => { }); it('registers a new user', async () => { - let response = await api.post(endpoint, { + const response = await api.post(endpoint, { authResponse: {access_token: randomAccessToken}, // eslint-disable-line camelcase network, }); @@ -47,7 +47,10 @@ describe('POST /user/auth/social', () => { expect(response.apiToken).to.exist; expect(response.id).to.exist; expect(response.newUser).to.be.true; + expect(response.username).to.exist; + await expect(getProperty('users', response.id, 'profile.name')).to.eventually.equal('a facebook user'); + await expect(getProperty('users', response.id, 'auth.local.lowerCaseUsername')).to.exist; }); it('logs an existing user in', async () => { diff --git a/test/api/v4/user/PUT-user.test.js b/test/api/v4/user/PUT-user.test.js index edd4a601ca..9ea1c145dd 100644 --- a/test/api/v4/user/PUT-user.test.js +++ b/test/api/v4/user/PUT-user.test.js @@ -76,7 +76,7 @@ describe('PUT /user', () => { })).to.eventually.be.rejected.and.eql({ code: 400, error: 'BadRequest', - message: 'User validation failed', + message: t('invalidReqParams'), }); }); }); diff --git a/test/api/v4/user/auth/POST-user_verify_username.test.js b/test/api/v4/user/auth/POST-user_verify_username.test.js new file mode 100644 index 0000000000..c17b789a41 --- /dev/null +++ b/test/api/v4/user/auth/POST-user_verify_username.test.js @@ -0,0 +1,89 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v4'; + +const ENDPOINT = '/user/auth/verify-username'; + +describe('POST /user/auth/verify-username', async () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully verifies username', async () => { + let newUsername = 'new-username'; + let response = await user.post(ENDPOINT, { + username: newUsername, + }); + expect(response).to.eql({ isUsable: true }); + }); + + it('successfully verifies username with allowed characters', async () => { + let newUsername = 'new-username_123'; + let response = await user.post(ENDPOINT, { + username: newUsername, + }); + expect(response).to.eql({ isUsable: true }); + }); + + context('errors', async () => { + it('errors if username is not provided', async () => { + await expect(user.post(ENDPOINT, { + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('errors if username is a slur', async () => { + await expect(user.post(ENDPOINT, { + username: 'TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); + }); + + it('errors if username contains a slur', async () => { + await expect(user.post(ENDPOINT, { + username: 'TESTPLACEHOLDERSLURWORDHERE_otherword', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); + await expect(user.post(ENDPOINT, { + username: 'something_TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); + await expect(user.post(ENDPOINT, { + username: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength'), t('usernameIssueSlur')] }); + }); + + it('errors if username is not allowed', async () => { + await expect(user.post(ENDPOINT, { + username: 'support', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueForbidden')] }); + }); + + it('errors if username is not allowed regardless of casing', async () => { + await expect(user.post(ENDPOINT, { + username: 'SUppORT', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueForbidden')] }); + }); + + it('errors if username has incorrect length', async () => { + await expect(user.post(ENDPOINT, { + username: 'thisisaverylongusernameover20characters', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueLength')] }); + }); + + it('errors if username contains invalid characters', async () => { + await expect(user.post(ENDPOINT, { + username: 'Eichhörnchen', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueInvalidCharacters')] }); + await expect(user.post(ENDPOINT, { + username: 'test.name', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueInvalidCharacters')] }); + await expect(user.post(ENDPOINT, { + username: '🤬', + })).to.eventually.eql({ isUsable: false, issues: [t('usernameIssueInvalidCharacters')] }); + }); + }); +}); diff --git a/test/api/v4/user/auth/PUT-user_update_username.test.js b/test/api/v4/user/auth/PUT-user_update_username.test.js new file mode 100644 index 0000000000..26a622cf04 --- /dev/null +++ b/test/api/v4/user/auth/PUT-user_update_username.test.js @@ -0,0 +1,224 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v4'; +import { + bcryptCompare, + sha1MakeSalt, + sha1Encrypt as sha1EncryptPassword, +} from '../../../../../website/server/libs/password'; + +const ENDPOINT = '/user/auth/update-username'; + +describe('PUT /user/auth/update-username', async () => { + let user; + let password = 'password'; // from habitrpg/test/helpers/api-integration/v4/object-generators.js + + beforeEach(async () => { + user = await generateUser(); + }); + + it('successfully changes username with password', async () => { + let newUsername = 'new-username'; + let response = await user.put(ENDPOINT, { + username: newUsername, + password, + }); + expect(response).to.eql({ username: newUsername }); + await user.sync(); + expect(user.auth.local.username).to.eql(newUsername); + }); + + it('successfully changes username without password', async () => { + let newUsername = 'new-username-nopw'; + let response = await user.put(ENDPOINT, { + username: newUsername, + }); + expect(response).to.eql({ username: newUsername }); + await user.sync(); + expect(user.auth.local.username).to.eql(newUsername); + }); + + it('successfully changes username containing number and underscore', async () => { + let newUsername = 'new_username9'; + let response = await user.put(ENDPOINT, { + username: newUsername, + }); + expect(response).to.eql({ username: newUsername }); + await user.sync(); + expect(user.auth.local.username).to.eql(newUsername); + }); + + it('sets verifiedUsername when changing username', async () => { + user.flags.verifiedUsername = false; + await user.sync(); + let newUsername = 'new-username-verify'; + let response = await user.put(ENDPOINT, { + username: newUsername, + }); + expect(response).to.eql({ username: newUsername }); + await user.sync(); + expect(user.flags.verifiedUsername).to.eql(true); + }); + + it('converts user with SHA1 encrypted password to bcrypt encryption', async () => { + let myNewUsername = 'my-new-username'; + let textPassword = 'mySecretPassword'; + let salt = sha1MakeSalt(); + let sha1HashedPassword = sha1EncryptPassword(textPassword, salt); + + await user.update({ + 'auth.local.hashed_password': sha1HashedPassword, + 'auth.local.passwordHashMethod': 'sha1', + 'auth.local.salt': salt, + }); + + await user.sync(); + expect(user.auth.local.passwordHashMethod).to.equal('sha1'); + expect(user.auth.local.salt).to.equal(salt); + expect(user.auth.local.hashed_password).to.equal(sha1HashedPassword); + + // update email + let response = await user.put(ENDPOINT, { + username: myNewUsername, + password: textPassword, + }); + expect(response).to.eql({ username: myNewUsername }); + + await user.sync(); + + expect(user.auth.local.username).to.eql(myNewUsername); + expect(user.auth.local.passwordHashMethod).to.equal('bcrypt'); + expect(user.auth.local.salt).to.be.undefined; + expect(user.auth.local.hashed_password).not.to.equal(sha1HashedPassword); + + let isValidPassword = await bcryptCompare(textPassword, user.auth.local.hashed_password); + expect(isValidPassword).to.equal(true); + }); + + context('errors', async () => { + it('prevents username update if new username is already taken', async () => { + let existingUsername = 'existing-username'; + await generateUser({'auth.local.username': existingUsername, 'auth.local.lowerCaseUsername': existingUsername }); + + await expect(user.put(ENDPOINT, { + username: existingUsername, + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameTaken'), + }); + }); + + it('errors if password is wrong', async () => { + let newUsername = 'new-username'; + await expect(user.put(ENDPOINT, { + username: newUsername, + password: 'wrong-password', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('wrongPassword'), + }); + }); + + it('errors if new username is not provided', async () => { + await expect(user.put(ENDPOINT, { + password, + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('errors if new username is a slur', async () => { + await expect(user.put(ENDPOINT, { + username: 'TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + }); + }); + + it('errors if new username contains a slur', async () => { + await expect(user.put(ENDPOINT, { + username: 'TESTPLACEHOLDERSLURWORDHERE_otherword', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + }); + await expect(user.put(ENDPOINT, { + username: 'something_TESTPLACEHOLDERSLURWORDHERE', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + }); + await expect(user.put(ENDPOINT, { + username: 'somethingTESTPLACEHOLDERSLURWORDHEREotherword', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: [t('usernameIssueLength'), t('usernameIssueSlur')].join(' '), + }); + }); + + it('errors if new username is not allowed', async () => { + await expect(user.put(ENDPOINT, { + username: 'support', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueForbidden'), + }); + }); + + it('errors if new username is not allowed regardless of casing', async () => { + await expect(user.put(ENDPOINT, { + username: 'SUppORT', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueForbidden'), + }); + }); + + it('errors if username has incorrect length', async () => { + await expect(user.put(ENDPOINT, { + username: 'thisisaverylongusernameover20characters', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueLength'), + }); + }); + + it('errors if new username contains invalid characters', async () => { + await expect(user.put(ENDPOINT, { + username: 'Eichhörnchen', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueInvalidCharacters'), + }); + await expect(user.put(ENDPOINT, { + username: 'test.name', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueInvalidCharacters'), + }); + await expect(user.put(ENDPOINT, { + username: '🤬', + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('usernameIssueInvalidCharacters'), + }); + }); + }); +}); diff --git a/website/client/assets/scss/iconalert.scss b/website/client/assets/scss/iconalert.scss new file mode 100644 index 0000000000..cfcdbf56b9 --- /dev/null +++ b/website/client/assets/scss/iconalert.scss @@ -0,0 +1,56 @@ +.iconalert { + position: relative; + padding: $alert-padding-y $alert-padding-x; + margin-bottom: $alert-margin-bottom; + border-radius: 2px; + color: white; + font-family: Roboto; + font-size: 14px; + line-height: 1.43; + padding-left: 60px +} + +.iconalert::before { + height:100%; + content:' '; + position: absolute; + top: 0; + left: 0; + width: 44px; + background-repeat: no-repeat; + background-position: center center; + margin-right: $alert-padding-x; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; +} + +.iconalert-success { + background-color: #24cc8f; +} + +.iconalert-success::before { + background-image: url(~client/assets/svg/for-css/checkbox-white.svg); + background-size: 13px 10px; + background-color: #1ca372; +} + +.iconalert-warning::before, .iconalert-error::before { + background-image: url(~client/assets/svg/for-css/alert-white.svg); + background-size: 16px 16px; +} + +.iconalert-warning { + background-color: #ffa623; +} + +.iconalert-warning::before { + background-color: #ee9109; +} + +.iconalert-error { + background-color: #f74e52; +} + +.iconalert-error::before { + background-color: #de3f3f; +} diff --git a/website/client/assets/scss/index.scss b/website/client/assets/scss/index.scss index 6c2fa94f5a..522f385a16 100644 --- a/website/client/assets/scss/index.scss +++ b/website/client/assets/scss/index.scss @@ -34,3 +34,4 @@ @import './progress-bar'; @import './pin'; @import './animals'; +@import './iconalert'; diff --git a/website/client/assets/svg/for-css/alert-white.svg b/website/client/assets/svg/for-css/alert-white.svg new file mode 100644 index 0000000000..3b46fd43e1 --- /dev/null +++ b/website/client/assets/svg/for-css/alert-white.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/client/components/auth/authForm.vue b/website/client/components/auth/authForm.vue index 505ba837b2..5c55c800cb 100644 --- a/website/client/components/auth/authForm.vue +++ b/website/client/components/auth/authForm.vue @@ -11,20 +11,20 @@ span {{registering ? $t('signUpWithSocial', {social: 'Google'}) : $t('loginWithSocial', {social: 'Google'})}} .form-group(v-if='registering') label(for='usernameInput', v-once) {{$t('username')}} - input#usernameInput.form-control(type='text', :placeholder='$t("usernamePlaceholder")', v-model='username') + input#usernameInput.form-control(type='text', :placeholder='$t("usernamePlaceholder")', v-model='username', :class='{"input-valid": usernameValid, "input-invalid": usernameInvalid}') .form-group(v-if='!registering') label(for='usernameInput', v-once) {{$t('emailOrUsername')}} input#usernameInput.form-control(type='text', :placeholder='$t("emailOrUsername")', v-model='username') .form-group(v-if='registering') label(for='emailInput', v-once) {{$t('email')}} - input#emailInput.form-control(type='email', :placeholder='$t("emailPlaceholder")', v-model='email') + input#emailInput.form-control(type='email', :placeholder='$t("emailPlaceholder")', v-model='email', :class='{"input-invalid": emailInvalid, "input-valid": emailValid}') .form-group label(for='passwordInput', v-once) {{$t('password')}} a.float-right.forgot-password(v-once, v-if='!registering', @click='forgotPassword = true') {{$t('forgotPassword')}} input#passwordInput.form-control(type='password', :placeholder='$t(registering ? "passwordPlaceholder" : "password")', v-model='password') .form-group(v-if='registering') label(for='confirmPasswordInput', v-once) {{$t('confirmPassword')}} - input#confirmPasswordInput.form-control(type='password', :placeholder='$t("confirmPasswordPlaceholder")', v-model='passwordConfirm') + input#confirmPasswordInput.form-control(type='password', :placeholder='$t("confirmPasswordPlaceholder")', v-model='passwordConfirm', :class='{"input-invalid": passwordConfirmInvalid, "input-valid": passwordConfirmValid}') small.form-text(v-once, v-html="$t('termsAndAgreement')") .text-center .btn.btn-info(@click='register()', v-if='registering', v-once) {{$t('joinHabitica')}} @@ -71,12 +71,17 @@ small.form-text { text-align: center; } + + .input-valid { + color: #fff; + } } \ No newline at end of file + diff --git a/website/client/components/header/notifications/verifyUsername.vue b/website/client/components/header/notifications/verifyUsername.vue new file mode 100644 index 0000000000..cb848322e8 --- /dev/null +++ b/website/client/components/header/notifications/verifyUsername.vue @@ -0,0 +1,74 @@ + + + diff --git a/website/client/components/header/notificationsDropdown.vue b/website/client/components/header/notificationsDropdown.vue index 458ad12b42..3132f9e9b9 100644 --- a/website/client/components/header/notificationsDropdown.vue +++ b/website/client/components/header/notificationsDropdown.vue @@ -93,6 +93,7 @@ import CARD_RECEIVED from './notifications/cardReceived'; import NEW_INBOX_MESSAGE from './notifications/newInboxMessage'; import NEW_CHAT_MESSAGE from './notifications/newChatMessage'; import WORLD_BOSS from './notifications/worldBoss'; +import VERIFY_USERNAME from './notifications/verifyUsername'; export default { components: { @@ -105,6 +106,7 @@ export default { UNALLOCATED_STATS_POINTS, NEW_MYSTERY_ITEMS, CARD_RECEIVED, NEW_INBOX_MESSAGE, NEW_CHAT_MESSAGE, WorldBoss: WORLD_BOSS, + VERIFY_USERNAME, }, data () { return { @@ -127,6 +129,7 @@ export default { 'QUEST_INVITATION', 'GROUP_TASK_APPROVAL', 'GROUP_TASK_APPROVED', 'NEW_MYSTERY_ITEMS', 'CARD_RECEIVED', 'NEW_INBOX_MESSAGE', 'NEW_CHAT_MESSAGE', 'UNALLOCATED_STATS_POINTS', + 'VERIFY_USERNAME', ], }; }, @@ -179,6 +182,16 @@ export default { }); } + if (this.user.flags.verifiedUsername !== true) { + notifications.push({ + type: 'VERIFY_USERNAME', + data: { + username: this.user.auth.local.username, + }, + id: 'custom-change-username', + }); + } + const orderMap = this.notificationsOrder; // Push the notifications stored in user.notifications diff --git a/website/client/components/settings/site.vue b/website/client/components/settings/site.vue index 26410ec7ef..d01dc6a76b 100644 --- a/website/client/components/settings/site.vue +++ b/website/client/components/settings/site.vue @@ -115,13 +115,9 @@ button.btn.btn-primary.mb-2(disabled='disabled', v-if='!hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('registeredWithSocial', {network: network.name}) }} button.btn.btn-danger(@click='deleteSocialAuth(network)', v-if='hasBackupAuthOption(network.key) && user.auth[network.key].id') {{ $t('detachSocial', {network: network.name}) }} hr - div(v-if='!user.auth.local.username') + div(v-if='!user.auth.local.email') p {{ $t('addLocalAuth') }} - p {{ $t('usernameLimitations') }} .form(name='localAuth', novalidate) - //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted') {{ $t('fillAll') }} - .form-group - input.form-control(type='text', :placeholder="$t('username')", v-model='localAuth.username', required) .form-group input.form-control(type='text', :placeholder="$t('email')", v-model='localAuth.email', required) .form-group @@ -130,37 +126,37 @@ input.form-control(type='password', :placeholder="$t('confirmPass')", v-model='localAuth.confirmPassword', required) button.btn.btn-primary(type='submit', @click='addLocalAuth()') {{ $t('submit') }} - .usersettings(v-if='user.auth.local.username') - p {{ $t('username') }} - |: {{user.auth.local.username}} - p - small.muted - | {{ $t('loginNameDescription') }} - p {{ $t('email') }} - |: {{user.auth.local.email}} - hr + .usersettings + h5 {{ $t('changeDisplayName') }} + .form(name='changeDisplayName', novalidate) + .form-group + input#changeDisplayname.form-control(type='text', :placeholder="$t('newDisplayName')", v-model='temporaryDisplayName') + button.btn.btn-primary(type='submit', @click='changeDisplayName(temporaryDisplayName)') {{ $t('submit') }} h5 {{ $t('changeUsername') }} - .form(v-if='user.auth.local', name='changeUsername', novalidate) - //-.alert.alert-danger(ng-messages='changeUsername.$error && changeUsername.submitted') {{ $t('fillAll') }} + .form(name='changeUsername', novalidate) + .iconalert.iconalert-success(v-if='verifiedUsername') {{ $t('usernameVerifiedConfirmation', {'username': user.auth.local.username}) }} + .iconalert.iconalert-warning(v-else) + div.align-middle + span {{ $t('usernameNotVerified') }} + button.btn.btn-secondary.btn-small.float-right(@click='changeUser("username", {username: user.auth.local.username})') {{ $t('confirmUsername') }} .form-group - input.form-control(type='text', :placeholder="$t('newUsername')", v-model='usernameUpdates.username') + input#changeUsername.form-control(type='text', :placeholder="$t('newUsername')", v-model='usernameUpdates.username', :class='{"is-invalid input-invalid": usernameInvalid}') + .input-error(v-for="issue in usernameIssues") {{ issue }} + small.form-text.text-muted {{ $t('changeUsernameDisclaimer') }} + button.btn.btn-primary(type='submit', @click='changeUser("username", usernameUpdates)', :disabled='usernameCanSubmit') {{ $t('submit') }} + h5(v-if='user.auth.local.email') {{ $t('changeEmail') }} + .form(v-if='user.auth.local.email', name='changeEmail', novalidate) .form-group - input.form-control(type='password', :placeholder="$t('password')", v-model='usernameUpdates.password') - button.btn.btn-primary(type='submit', @click='changeUser("username", usernameUpdates)') {{ $t('submit') }} - - h5 {{ $t('changeEmail') }} - .form(v-if='user.auth.local', name='changeEmail', novalidate) - .form-group - input.form-control(type='text', :placeholder="$t('newEmail')", v-model='emailUpdates.newEmail') + input#changeEmail.form-control(type='text', :placeholder="$t('newEmail')", v-model='emailUpdates.newEmail') .form-group input.form-control(type='password', :placeholder="$t('password')", v-model='emailUpdates.password') button.btn.btn-primary(type='submit', @click='changeUser("email", emailUpdates)') {{ $t('submit') }} - h5 {{ $t('changePass') }} - .form(v-if='user.auth.local', name='changePassword', novalidate) + h5(v-if='user.auth.local.email') {{ $t('changePass') }} + .form(v-if='user.auth.local.email', name='changePassword', novalidate) .form-group - input.form-control(type='password', :placeholder="$t('oldPass')", v-model='passwordUpdates.password') + input#changePassword.form-control(type='password', :placeholder="$t('oldPass')", v-model='passwordUpdates.password') .form-group input.form-control(type='password', :placeholder="$t('newPass')", v-model='passwordUpdates.newPassword') .form-group @@ -177,10 +173,33 @@ popover-trigger='mouseenter', v-b-popover.hover.auto="$t('deleteAccPop')") {{ $t('deleteAccount') }} -