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 @@
+
+ base-notification(
+ :can-remove="false",
+ :has-icon="false",
+ :read-after-click="false",
+ :notification="{}",
+ @click="action",
+ )
+ div.text-center(slot="content")
+ div.username-notification-title {{ $t('setUsernameNotificationTitle') }}
+ div {{ $t('setUsernameNotificationBody') }}
+ div.current-username-container.mx-auto
+ label.font-weight-bold {{ $t('currentUsername') + " " }}
+ label @
+ label {{ user.auth.local.username }}
+ .notifications-buttons
+ .btn.btn-small.btn-secondary(@click.stop="changeUsername()") {{ $t('goToSettings') }}
+
+
+
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') }}
-