diff --git a/public/src/client/register.js b/public/src/client/register.js index 62dbc41..28ca45e 100644 --- a/public/src/client/register.js +++ b/public/src/client/register.js @@ -1,209 +1,243 @@ 'use strict'; - define('forum/register', [ - 'translator', 'slugify', 'api', 'bootbox', 'forum/login', 'zxcvbn', 'jquery-form', + 'translator', 'slugify', 'api', 'bootbox', 'forum/login', 'zxcvbn', 'jquery-form', ], function (translator, slugify, api, bootbox, Login, zxcvbn) { - const Register = {}; - let validationError = false; - const successIcon = ''; - - Register.init = function () { - const username = $('#username'); - const password = $('#password'); - const password_confirm = $('#password-confirm'); - const register = $('#register'); - - handleLanguageOverride(); - - $('#content #noscript').val('false'); - - const query = utils.params(); - if (query.token) { - $('#token').val(query.token); - } - - // Update the "others can mention you via" text - username.on('keyup', function () { - $('#yourUsername').text(this.value.length > 0 ? slugify(this.value) : 'username'); - }); - - username.on('blur', function () { - if (username.val().length) { - validateUsername(username.val()); - } - }); - - password.on('blur', function () { - if (password.val().length) { - validatePassword(password.val(), password_confirm.val()); - } - }); - - password_confirm.on('blur', function () { - if (password_confirm.val().length) { - validatePasswordConfirm(password.val(), password_confirm.val()); - } - }); - - function validateForm(callback) { - validationError = false; - validatePassword(password.val(), password_confirm.val()); - validatePasswordConfirm(password.val(), password_confirm.val()); - validateUsername(username.val(), callback); - } - - // Guard against caps lock - Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); - - register.on('click', function (e) { - const registerBtn = $(this); - const errorEl = $('#register-error-notify'); - errorEl.addClass('hidden'); - e.preventDefault(); - validateForm(function () { - if (validationError) { - return; - } - - registerBtn.addClass('disabled'); - - registerBtn.parents('form').ajaxSubmit({ - headers: { - 'x-csrf-token': config.csrf_token, - }, - success: function (data) { - registerBtn.removeClass('disabled'); - if (!data) { - return; - } - if (data.next) { - const pathname = utils.urlToLocation(data.next).pathname; - - const params = utils.params({ url: data.next }); - params.registered = true; - const qs = decodeURIComponent($.param(params)); - - window.location.href = pathname + '?' + qs; - } else if (data.message) { - translator.translate(data.message, function (msg) { - bootbox.alert(msg); - ajaxify.go('/'); - }); - } - }, - error: function (data) { - translator.translate(data.responseText, config.defaultLang, function (translated) { - if (data.status === 403 && data.responseText === 'Forbidden') { - window.location.href = config.relative_path + '/register?error=csrf-invalid'; - } else { - errorEl.find('p').text(translated); - errorEl.removeClass('hidden'); - registerBtn.removeClass('disabled'); - } - }); - }, - }); - }); - }); - - // Set initial focus - $('#username').focus(); - }; - - function validateUsername(username, callback) { - callback = callback || function () {}; - - const username_notify = $('#username-notify'); - const userslug = slugify(username); - if (username.length < ajaxify.data.minimumUsernameLength || - userslug.length < ajaxify.data.minimumUsernameLength) { - showError(username_notify, '[[error:username-too-short]]'); - } else if (username.length > ajaxify.data.maximumUsernameLength) { - showError(username_notify, '[[error:username-too-long]]'); - } else if (!utils.isUserNameValid(username) || !userslug) { - showError(username_notify, '[[error:invalid-username]]'); - } else { - Promise.allSettled([ - api.head(`/users/bySlug/${username}`, {}), - api.head(`/groups/${username}`, {}), - ]).then((results) => { - if (results.every(obj => obj.status === 'rejected')) { - showSuccess(username_notify, successIcon); - } else { - showError(username_notify, '[[error:username-taken]]'); - } - - callback(); - }); - } - } - - function validatePassword(password, password_confirm) { - const password_notify = $('#password-notify'); - const password_confirm_notify = $('#password-confirm-notify'); - - try { - utils.assertPasswordValidity(password, zxcvbn); - - if (password === $('#username').val()) { - throw new Error('[[user:password_same_as_username]]'); - } - - showSuccess(password_notify, successIcon); - } catch (err) { - showError(password_notify, err.message); - } - - if (password !== password_confirm && password_confirm !== '') { - showError(password_confirm_notify, '[[user:change_password_error_match]]'); - } - } - - function validatePasswordConfirm(password, password_confirm) { - const password_notify = $('#password-notify'); - const password_confirm_notify = $('#password-confirm-notify'); - - if (!password || password_notify.hasClass('alert-error')) { - return; - } - - if (password !== password_confirm) { - showError(password_confirm_notify, '[[user:change_password_error_match]]'); - } else { - showSuccess(password_confirm_notify, successIcon); - } - } - - function showError(element, msg) { - translator.translate(msg, function (msg) { - element.html(msg); - element.parent() - .removeClass('register-success') - .addClass('register-danger'); - element.show(); - }); - validationError = true; - } - - function showSuccess(element, msg) { - translator.translate(msg, function (msg) { - element.html(msg); - element.parent() - .removeClass('register-danger') - .addClass('register-success'); - element.show(); - }); - } - - function handleLanguageOverride() { - if (!app.user.uid && config.defaultLang !== config.userLang) { - const formEl = $('[component="register/local"]'); - const langEl = $(''); - - formEl.append(langEl); - } - } - - return Register; + const Register = {}; + let validationError = false; + const successIcon = ''; + + Register.init = function () { + const username = $('#username'); + const password = $('#password'); + const password_confirm = $('#password-confirm'); + const register = $('#register'); + + handleLanguageOverride(); + + $('#content #noscript').val('false'); + + const query = utils.params(); + if (query.token) { + $('#token').val(query.token); + } + + // Update the "others can mention you via" text + username.on('keyup', function () { + $('#yourUsername').text(this.value.length > 0 ? slugify(this.value) : 'username'); + }); + + username.on('blur', function () { + if (username.val().length) { + validateUsername(username.val()); + } + }); + + password.on('blur', function () { + if (password.val().length) { + validatePassword(password.val(), password_confirm.val()); + } + }); + + password_confirm.on('blur', function () { + if (password_confirm.val().length) { + validatePasswordConfirm(password.val(), password_confirm.val()); + } + }); + + function validateForm(callback) { + validationError = false; + validatePassword(password.val(), password_confirm.val()); + validatePasswordConfirm(password.val(), password_confirm.val()); + validateUsername(username.val(), callback); + } + + // Guard against caps lock + Login.capsLockCheck(document.querySelector('#password'), document.querySelector('#caps-lock-warning')); + + register.on('click', function (e) { + const registerBtn = $(this); + const errorEl = $('#register-error-notify'); + errorEl.addClass('hidden'); + e.preventDefault(); + validateForm(function () { + if (validationError) { + return; + } + + registerBtn.addClass('disabled'); + + registerBtn.parents('form').ajaxSubmit({ + headers: { + 'x-csrf-token': config.csrf_token, + }, + success: function (data) { + registerBtn.removeClass('disabled'); + if (!data) { + return; + } + if (data.next) { + const pathname = utils.urlToLocation(data.next).pathname; + + const params = utils.params({ url: data.next }); + params.registered = true; + const qs = decodeURIComponent($.param(params)); + + window.location.href = pathname + '?' + qs; + } else if (data.message) { + translator.translate(data.message, function (msg) { + bootbox.alert(msg); + ajaxify.go('/'); + }); + } + }, + error: function (data) { + translator.translate(data.responseText, config.defaultLang, function (translated) { + if (data.status === 403 && data.responseText === 'Forbidden') { + window.location.href = config.relative_path + '/register?error=csrf-invalid'; + } else { + errorEl.find('p').text(translated); + errorEl.removeClass('hidden'); + registerBtn.removeClass('disabled'); + } + }); + }, + }); + }); + }); + + // Set initial focus + $('#username').focus(); + }; + + /** + * Modified validateUsername: if the username is taken, try to generate an alternative suggestion. + */ + function validateUsername(username, callback) { + callback = callback || function () {}; + + const username_notify = $('#username-notify'); + const userslug = slugify(username); + if (username.length < ajaxify.data.minimumUsernameLength || + userslug.length < ajaxify.data.minimumUsernameLength) { + showError(username_notify, '[[error:username-too-short]]'); + return callback(); + } else if (username.length > ajaxify.data.maximumUsernameLength) { + showError(username_notify, '[[error:username-too-long]]'); + return callback(); + } else if (!utils.isUserNameValid(username) || !userslug) { + showError(username_notify, '[[error:invalid-username]]'); + return callback(); + } else { + // Check if the username (or group) already exists + Promise.allSettled([ + api.head(`/users/bySlug/${username}`, {}), + api.head(`/groups/${username}`, {}), + ]).then((results) => { + if (results.every(obj => obj.status === 'rejected')) { + // Username is available + showSuccess(username_notify, successIcon); + return callback(); + } else { + // Username is taken – generate an alternative suggestion + const suffix = 'suffix'; + let suggestedUsername = username + suffix; + + // Check if the first suggested username is available + api.head(`/users/bySlug/${suggestedUsername}`, {}) + .then(() => { + // If the suggestion is taken, try appending a counter until one is available + let counter = 1; + (function checkSuggestion() { + const newSuggestion = username + suffix + counter; + api.head(`/users/bySlug/${newSuggestion}`, {}) + .then(() => { + // This suggestion is also taken, increment counter and try again + counter++; + checkSuggestion(); + }) + .catch(() => { + // Found an available suggestion + showError(username_notify, '[[error:username-taken]] - Try "' + newSuggestion + '"'); + return callback(); + }); + })(); + }) + .catch(() => { + // The first suggestion is available + showError(username_notify, '[[error:username-taken]] - Try "' + suggestedUsername + '"'); + return callback(); + }); + } + }); + } + } + + function validatePassword(password, password_confirm) { + const password_notify = $('#password-notify'); + const password_confirm_notify = $('#password-confirm-notify'); + + try { + utils.assertPasswordValidity(password, zxcvbn); + + if (password === $('#username').val()) { + throw new Error('[[user:password_same_as_username]]'); + } + + showSuccess(password_notify, successIcon); + } catch (err) { + showError(password_notify, err.message); + } + + if (password !== password_confirm && password_confirm !== '') { + showError(password_confirm_notify, '[[user:change_password_error_match]]'); + } + } + + function validatePasswordConfirm(password, password_confirm) { + const password_notify = $('#password-notify'); + const password_confirm_notify = $('#password-confirm-notify'); + + if (!password || password_notify.hasClass('alert-error')) { + return; + } + + if (password !== password_confirm) { + showError(password_confirm_notify, '[[user:change_password_error_match]]'); + } else { + showSuccess(password_confirm_notify, successIcon); + } + } + + function showError(element, msg) { + translator.translate(msg, function (translatedMsg) { + element.html(translatedMsg); + element.parent() + .removeClass('register-success') + .addClass('register-danger'); + element.show(); + }); + validationError = true; + } + + function showSuccess(element, msg) { + translator.translate(msg, function (translatedMsg) { + element.html(translatedMsg); + element.parent() + .removeClass('register-danger') + .addClass('register-success'); + element.show(); + }); + } + + function handleLanguageOverride() { + if (!app.user.uid && config.defaultLang !== config.userLang) { + const formEl = $('[component="register/local"]'); + const langEl = $(''); + + formEl.append(langEl); + } + } + + return Register; }); diff --git a/src/controllers/user.js b/src/controllers/user.js index 673a8cf..19695a2 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -9,6 +9,50 @@ const accountHelpers = require('./accounts/helpers'); const userController = module.exports; +// ====================================================================== +// NEW: updateUsername method added to check for taken usernames and suggest an alternative. +// ====================================================================== +userController.updateUsername = async function (req, res, next) { + try { + // Make sure the user is logged in + if (!req.loggedIn) { + return res.status(401).json({ error: 'not-authorized' }); + } + + // Retrieve the new username from the request body + const newUsername = req.body.username; + if (!newUsername) { + return res.status(400).json({ error: '[[error:invalid-username]]' }); + } + + // Check if the username is already taken by another user. + const existingUid = await user.getUidByUsername(newUsername); + // Note: if existingUid exists and it doesn't belong to the current user, it is taken. + if (existingUid && parseInt(existingUid, 10) !== parseInt(req.uid, 10)) { + // Username is taken – generate an alternative suggestion. + const suffix = 'suffix'; // You can make this configurable. + let suggestedUsername = newUsername + suffix; + let counter = 1; + // Loop until we find an available suggestion. + while (await user.getUidByUsername(suggestedUsername)) { + suggestedUsername = newUsername + suffix + counter; + counter++; + } + // Return a 409 Conflict status along with an error code and the suggestion. + return res.status(409).json({ error: 'username-taken', suggestion: suggestedUsername }); + } + + // Username is available – update the username. + await user.updateUsername(req.uid, newUsername); + return res.json({ success: true, username: newUsername }); + } catch (err) { + return next(err); + } +}; +// ====================================================================== +// End of updateUsername method +// ====================================================================== + userController.getCurrentUser = async function (req, res) { if (!req.loggedIn) { return res.status(401).json('not-authorized'); @@ -113,6 +157,12 @@ function sendExport(filename, type, res, next) { } require('../promisify')(userController, [ - 'getCurrentUser', 'getUserByUID', 'getUserByUsername', 'getUserByEmail', - 'exportPosts', 'exportUploads', 'exportProfile', + 'getCurrentUser', + 'getUserByUID', + 'getUserByUsername', + 'getUserByEmail', + 'updateUsername', // <<-- ADDED updateUsername here for promisification. + 'exportPosts', + 'exportUploads', + 'exportProfile', ]);