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') }} -