diff --git a/core/config.js b/core/config.js index 9daa4e77..99572287 100644 --- a/core/config.js +++ b/core/config.js @@ -96,8 +96,16 @@ function getDefaultConfig() { usernameMin : 2, usernameMax : 16, // Note that FidoNet wants 36 max usernamePattern : '^[A-Za-z0-9~!@#$%^&*()\\-\\_+]+$', + passwordMin : 6, passwordMax : 128, + + realNameMax : 32, + locationMax : 32, + affilsMax : 32, + emailMax : 255, + webMax : 255, + requireActivation : true, // require SysOp activation? invalidUsernames : [], diff --git a/core/mask_edit_text_view.js b/core/mask_edit_text_view.js index a25a2553..f99774e6 100644 --- a/core/mask_edit_text_view.js +++ b/core/mask_edit_text_view.js @@ -103,6 +103,14 @@ MaskEditTextView.maskPatternCharacterRegEx = { '&' : /[\w\d\s]/, // Any "printable" 32-126, 128-255 }; +MaskEditTextView.prototype.setText = function(text) { + MaskEditTextView.super_.prototype.setText.call(this, text); + + if(this.patternArray) { // :TODO: This is a hack - see TextView ctor note about setText() + this.patternArrayPos = this.patternArray.length; + } +}; + MaskEditTextView.prototype.setMaskPattern = function(pattern) { this.dimens.width = pattern.length; diff --git a/core/spinner_menu_view.js b/core/spinner_menu_view.js index 8050015b..65ac10af 100644 --- a/core/spinner_menu_view.js +++ b/core/spinner_menu_view.js @@ -64,6 +64,12 @@ SpinnerMenuView.prototype.setFocus = function(focused) { this.redraw(); }; +SpinnerMenuView.prototype.setFocusItemIndex = function(index) { + SpinnerMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + this.updateSelection(); // will redraw +}; + SpinnerMenuView.prototype.onKeyPress = function(ch, key) { if(key) { if(this.isKeyMapped('up', key.name)) { diff --git a/core/system_view_validate.js b/core/system_view_validate.js index 5600e2b6..90fdab15 100644 --- a/core/system_view_validate.js +++ b/core/system_view_validate.js @@ -14,7 +14,7 @@ function validateNonEmpty(data, cb) { function validateMessageSubject(data, cb) { cb(data && data.length > 1 ? null : new Error('Subject too short')); -}; +} function validateUserNameAvail(data, cb) { if(data.length < Config.users.usernameMin) { diff --git a/core/text_view.js b/core/text_view.js index bea8570c..96261ec5 100644 --- a/core/text_view.js +++ b/core/text_view.js @@ -89,6 +89,7 @@ function TextView(options) { return this.position.col + offset; }; + // :TODO: Whatever needs init here should be done separately from setText() since it redraws/etc. this.setText(options.text || ''); } @@ -97,7 +98,9 @@ util.inherits(TextView, View); TextView.prototype.redraw = function() { TextView.super_.prototype.redraw.call(this); - this.drawText(this.text); + if(_.isString(this.text)) { + this.drawText(this.text); + } }; TextView.prototype.setFocus = function(focused) { diff --git a/core/theme.js b/core/theme.js index 8ada3fe1..ae6e1ca6 100644 --- a/core/theme.js +++ b/core/theme.js @@ -16,8 +16,10 @@ var async = require('async'); var _ = require('lodash'); var assert = require('assert'); + exports.loadTheme = loadTheme; exports.getThemeArt = getThemeArt; +exports.getAvailableThemes = getAvailableThemes; exports.getRandomTheme = getRandomTheme; exports.initAvailableThemes = initAvailableThemes; exports.displayThemeArt = displayThemeArt; @@ -72,7 +74,7 @@ function refreshThemeHelpers(theme) { return format; } - } + }; } function loadTheme(themeID, cb) { @@ -83,8 +85,11 @@ function loadTheme(themeID, cb) { if(err) { cb(err); } else { - if(!_.isObject(theme.info)) { - cb(new Error('Invalid theme or missing \'info\' section')); + if(!_.isObject(theme.info) || + !_.isString(theme.info.name) || + !_.isString(theme.info.author)) + { + cb(new Error('Invalid or missing "info" section!')); return; } @@ -128,6 +133,8 @@ function initAvailableThemes(cb) { }); Log.debug( { info : theme.info }, 'Theme loaded'); + } else { + Log.warn( { themeId : themeId, error : err.toString() }, 'Failed to load theme'); } }); @@ -146,6 +153,10 @@ function initAvailableThemes(cb) { ); } +function getAvailableThemes() { + return availableThemes; +} + function getRandomTheme() { if(Object.getOwnPropertyNames(availableThemes).length > 0) { var themeIds = Object.keys(availableThemes); diff --git a/core/user.js b/core/user.js index 54edc3af..ffc0b140 100644 --- a/core/user.js +++ b/core/user.js @@ -375,6 +375,25 @@ User.prototype.persistAllProperties = function(cb) { */ }; +User.prototype.setNewAuthCredentials = function(password, cb) { + var self = this; + + generatePasswordDerivedKeyAndSalt(password, function dkAndSalt(err, info) { + if(err) { + cb(err); + } else { + var newProperties = { + pw_pbkdf2_salt : info.salt, + pw_pbkdf2_dk : info.dk, + }; + + self.persistProperties(newProperties, function persisted(err) { + cb(err); + }); + } + }); +}; + User.prototype.getAge = function() { if(_.has(this.properties, 'birthdate')) { return moment().diff(this.properties.birthdate, 'years'); diff --git a/core/user_config.js b/core/user_config.js index ffda190b..5dfb85e5 100644 --- a/core/user_config.js +++ b/core/user_config.js @@ -3,6 +3,8 @@ var MenuModule = require('./menu_module.js').MenuModule; var ViewController = require('./view_controller.js').ViewController; +var theme = require('./theme.js'); +var sysValidate = require('./system_view_validate.js'); var async = require('async'); var assert = require('assert'); @@ -18,35 +20,134 @@ exports.moduleInfo = { }; var MciCodeIds = { - Email : 1, - Loc : 2, - Web : 3, - Affils : 4, - - BirthDate : 5, - Sex : 6, - - Theme : 10, - ScreenSize : 11, + RealName : 1, + BirthDate : 2, + Sex : 3, + Loc : 4, + Affils : 5, + Email : 6, + Web : 7, + TermHeight : 8, + Theme : 9, + Password : 10, + PassConfirm : 11, + ThemeInfo : 20, + ErrorMsg : 21, + + SaveCancel : 25, }; function UserConfigModule(options) { MenuModule.call(this, options); var self = this; + + self.getView = function(viewId) { + return self.viewControllers.menu.getView(viewId); + }; self.setViewText = function(viewId, text) { - var v = self.viewControllers.menu.getView(viewId); + var v = self.getView(viewId); if(v) { v.setText(text); } }; this.menuMethods = { - exitKeyPressed : function(formData, extraArgs) { - // :TODO: save/etc. - self.prevMenu(); - } + // + // Validation support + // + validateEmailAvail : function(data, cb) { + // + // If nothing changed, we know it's OK + // + if(self.client.user.properties.email_address.toLowerCase() === data.toLowerCase()) { + return cb(null); + } + + // Otherwise we can use the standard system method + return sysValidate.validateEmailAvail(data, cb); + }, + + validatePassword : function(data, cb) { + // + // Blank is OK - this means we won't be changing it + // + if(!data || 0 === data.length) { + return cb(null); + } + + // Otherwise we can use the standard system method + return sysValidate.validatePasswordSpec(data, cb); + }, + + validatePassConfirmMatch : function(data, cb) { + var passwordView = self.getView(MciCodeIds.Password); + cb(passwordView.getData() === data ? null : new Error('Passwords do not match')); + }, + + viewValidationListener : function(err, cb) { + var errMsgView = self.getView(MciCodeIds.ErrorMsg); + var newFocusId; + if(errMsgView) { + if(err) { + errMsgView.setText(err.message); + + if(err.view.getId() === MciCodeIds.PassConfirm) { + newFocusId = MciCodeIds.Password; + var passwordView = self.getView(MciCodeIds.Password); + passwordView.clearText(); + err.view.clearText(); + } + } else { + errMsgView.clearText(); + } + } + cb(newFocusId); + }, + + saveChanges : function(formData, extraArgs) { + assert(formData.value.password === formData.value.passwordConfirm); + + var newProperties = { + real_name : formData.value.realName, + birthdate : new Date(Date.parse(formData.value.birthdate)).toISOString(), + sex : formData.value.sex, + location : formData.value.location, + affiliation : formData.value.affils, + email_address : formData.value.email, + web_address : formData.value.web, + term_height : formData.value.termHeight.toString(), + theme_id : self.availThemeInfo[formData.value.theme].themeId, + }; + + self.client.user.persistProperties(newProperties, function persisted(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'Failed persisting updated properties'); + // :TODO: warn end user! + self.prevMenu(); + } else { + // + // New password if it's not empty + // + self.client.log.info('User updated properties'); + + if(formData.value.password.length > 0) { + self.client.user.setNewAuthCredentials(formData.value.password, function newAuthStored(err) { + if(err) { + // :TODO: warn the end user! + self.client.log.warn( { error : err.toString() }, 'Failed storing new authentication credentials'); + } else { + self.client.log.info('User changed authentication credentials'); + } + self.prevMenu(); + }); + } else { + self.prevMenu(); + } + } + }); + }, }; } @@ -55,6 +156,8 @@ require('util').inherits(UserConfigModule, MenuModule); UserConfigModule.prototype.mciReady = function(mciData, cb) { var self = this; var vc = self.viewControllers.menu = new ViewController( { client : self.client} ); + + var currentThemeIdIndex = 0; async.series( [ @@ -64,17 +167,57 @@ UserConfigModule.prototype.mciReady = function(mciData, cb) { function loadFromConfig(callback) { vc.loadFromMenuConfig( { callingMenu : self, mciMap : mciData.menu }, callback); }, + function prepareAvailableThemes(callback) { + self.availThemeInfo = _.sortBy(_.map(theme.getAvailableThemes(), function makeThemeInfo(t, themeId) { + return { + themeId : themeId, + name : t.info.name, + author : t.info.author, + desc : _.isString(t.info.desc) ? t.info.desc : '', + group : _.isString(t.info.group) ? t.info.group : '', + }; + }), 'name'); + + currentThemeIdIndex = _.findIndex(self.availThemeInfo, function cmp(ti) { + return ti.themeId === self.client.user.properties.theme_id; + }); + + callback(null); + }, function populateViews(callback) { var user = self.client.user; - self.setViewText(MciCodeIds.Email, user.properties.email_address); - self.setViewText(MciCodeIds.Loc, user.properties.location); - self.setViewText(MciCodeIds.Web, user.properties.web_address); - self.setViewText(MciCodeIds.Affils, user.properties.affiliation); + self.setViewText(MciCodeIds.RealName, user.properties.real_name); self.setViewText(MciCodeIds.BirthDate, moment(user.properties.birthdate).format('YYYYMMDD')); self.setViewText(MciCodeIds.Sex, user.properties.sex); - + self.setViewText(MciCodeIds.Loc, user.properties.location); + self.setViewText(MciCodeIds.Affils, user.properties.affiliation); + self.setViewText(MciCodeIds.Email, user.properties.email_address); + self.setViewText(MciCodeIds.Web, user.properties.web_address); + self.setViewText(MciCodeIds.TermHeight, user.properties.term_height.toString()); + + + var themeView = self.getView(MciCodeIds.Theme); + if(themeView) { + themeView.setItems(_.map(self.availThemeInfo, 'name')); + themeView.setFocusItemIndex(currentThemeIdIndex); + } + + var realNameView = self.getView(MciCodeIds.RealName); + if(realNameView) { + realNameView.setFocus(true); // :TODO: HACK! menu.hjson sets focus, but manual population above breaks this. Needs a real fix! + } + + callback(null); } - ] + ], + function complete(err) { + if(err) { + self.client.log.warn( { error : err.toString() }, 'User configuration failed to init'); + self.prevMenu(); + } else { + cb(null); + } + } ); }; diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index 72bc2a40..6e290969 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -146,6 +146,12 @@ VerticalMenuView.prototype.setFocus = function(focused) { this.redraw(); }; +VerticalMenuView.prototype.setFocusItemIndex = function(index) { + VerticalMenuView.super_.prototype.setFocusItemIndex.call(this, index); // sets this.focusedItemIndex + + this.redraw(); +}; + VerticalMenuView.prototype.onKeyPress = function(ch, key) { if(key) { diff --git a/core/view.js b/core/view.js index e5a9c502..1889c98e 100644 --- a/core/view.js +++ b/core/view.js @@ -103,7 +103,7 @@ function View(options) { this.restoreCursor = function() { //this.client.term.write(ansi.setCursorStyle(this.cursorStyle)); this.client.term.rawWrite('show' === this.cursor ? ansi.showCursor() : ansi.hideCursor()); - }; + }; } util.inherits(View, events.EventEmitter); diff --git a/mods/menu.hjson b/mods/menu.hjson index 6dbd0386..c00d4716 100644 --- a/mods/menu.hjson +++ b/mods/menu.hjson @@ -190,7 +190,7 @@ } ET2: { argName: realName - maxLength: 32 + maxLength: @config:users.realNameMax validate: @systemMethod:validateNonEmpty } MET3: { @@ -206,21 +206,21 @@ } ET5: { argName: location - maxLength: 32 + maxLength: @config:users.locationMax validate: @systemMethod:validateNonEmpty } ET6: { argName: affils - maxLength: 32 + maxLength: @config:users.affilsMax } ET7: { argName: email - maxLength: 255 + maxLength: @config:users.emailMax validate: @systemMethod:validateEmailAvail } ET8: { argName: web - maxLength: 255 + maxLength: @config:users.webMax } ET9: { argName: password @@ -287,7 +287,7 @@ } ET2: { argName: realName - maxLength: 32 + maxLength: @config:users.realNameMax validate: @systemMethod:validateNonEmpty } MET3: { @@ -303,21 +303,21 @@ } ET5: { argName: location - maxLength: 32 + maxLength: @config:users.locationMax validate: @systemMethod:validateNonEmpty } ET6: { argName: affils - maxLength: 32 + maxLength: @config:users.affilsMax } ET7: { argName: email - maxLength: 255 + maxLength: @config:users.emailMax validate: @systemMethod:validateEmailAvail } ET8: { argName: web - maxLength: 255 + maxLength: @config:users.webMax } ET9: { argName: password @@ -625,6 +625,7 @@ } } } + mainMenuUserConfig: { module: @systemModule:user_config art: CONFSCR @@ -632,30 +633,83 @@ 0: { mci: { ET1: { - argName: email + argName: realName + maxLength: @config:users.realNameMax + validate: @systemMethod:validateNonEmpty + focus: true } - ET2: { - argName: location + ME2: { + argName: birthdate + maskPattern: "####/##/##" } - ET3: { - argName: webAddress + ME3: { + argName: sex + maskPattern: A + textStyle: upper + validate: @systemMethod:validateNonEmpty } ET4: { + argName: location + maxLength: @config:users.locationMax + validate: @systemMethod:validateNonEmpty + } + ET5: { argName: affils + maxLength: @config:users.affilsMax } - ME5: { - maskPattern: "####/##/##" - argName: birthdate + ET6: { + argName: email + maxLength: @config:users.emailMax + validate: @method:validateEmailAvail } - ME11: { - maskPattern: "##x##" - argName: termSize + ET7: { + argName: web + maxLength: @config:users.webMax + } + ME8: { + maskPattern: "##" + argName: termHeight + validate: @systemMethod:validateNonEmpty + } + SM9: { + argName: theme + } + ET10: { + argName: password + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassword + } + ET11: { + argName: passwordConfirm + maxLength: @config:users.passwordMax + password: true + validate: @method:validatePassConfirmMatch + } + TM25: { + argName: submission + items: [ "save", "cancel" ] + submit: true } } + + submit: { + *: [ + { + value: { submission: 0 } + action: @method:saveChanges + } + { + value: { submission: 1 } + action: @systemMethod:prevMenu + } + ] + } + actionKeys: [ { keys: [ "escape" ] - action: @method:exitKeyPressed + action: @systemMethod:prevMenu } ] } diff --git a/mods/nua.js b/mods/nua.js index ca33c970..c2a955f2 100644 --- a/mods/nua.js +++ b/mods/nua.js @@ -14,7 +14,7 @@ exports.getModule = NewUserAppModule; exports.moduleInfo = { name : 'NUA', desc : 'New User Application', -} +}; var MciViewIds = { userName : 1, diff --git a/mods/themes/luciano_blocktronics/CONFSCR.ANS b/mods/themes/luciano_blocktronics/CONFSCR.ANS index 0e9dc5c4..0bf72430 100644 Binary files a/mods/themes/luciano_blocktronics/CONFSCR.ANS and b/mods/themes/luciano_blocktronics/CONFSCR.ANS differ diff --git a/mods/themes/luciano_blocktronics/theme.hjson b/mods/themes/luciano_blocktronics/theme.hjson index 53a33ce5..fdc783ad 100644 --- a/mods/themes/luciano_blocktronics/theme.hjson +++ b/mods/themes/luciano_blocktronics/theme.hjson @@ -273,6 +273,28 @@ ST8: { width: 17 } } } + + mainMenuUserConfig: { + mci: { + ET1: { width: 27 } + ME2: { width: 27 } + ME3: { width: 27 } + ET4: { width: 27 } + ET5: { width: 27 } + ET6: { width: 27 } + ET7: { width: 27 } + ET8: { width: 27 } + ET9: { width: 27 } + ET10: { width: 18 } + ET11: { width: 18 } + TL20: { width: 71 } + TL21: { width: 43 } + + TM25: { + focusTextStyle: first lower + } + } + } } } } \ No newline at end of file