pyc-website
main website for pyc inc.
git clone https://9o.is/git/pyc-website.git
commit bc48886ec8e68c8aedc5fe11a7b9ad09f4b42f04 parent 6c0ba7ffc3f75f074aea9fa458727b7b3b61e8ac Author: Jul <jul@9o.is> Date: Wed, 28 May 2014 15:50:48 -0400 user settings page (issue #16) Diffstat:
| M | src/main/scala/inc/pyc/config/Site.scala | | | 3 | +++ |
| A | src/main/scala/inc/pyc/model/EmailResetToken.scala | | | 122 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main/scala/inc/pyc/snippet/UserSnip.scala | | | 182 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
| M | src/main/scala/inc/pyc/snippet/UtilSnips.scala | | | 7 | +++++++ |
| M | src/main/webapp/app/App.js | | | 100 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/main/webapp/less/overrides.less | | | 8 | ++++++++ |
| M | src/main/webapp/settings.html | | | 16 | +++++++++++++++- |
| M | src/main/webapp/templates-hidden/no-base-settings-wrap.html | | | 3 | --- |
| A | src/main/webapp/templates-hidden/parts/user-settings-email-form.html | | | 17 | +++++++++++++++++ |
| A | src/main/webapp/templates-hidden/parts/user-settings-form.html | | | 31 | +++++++++++++++++++++++++++++++ |
| M | src/main/webapp/templates-hidden/settings-wrap.html | | | 3 | --- |
11 files changed, 453 insertions(+), 39 deletions(-)
diff --git a/src/main/scala/inc/pyc/config/Site.scala b/src/main/scala/inc/pyc/config/Site.scala @@ -3,6 +3,7 @@ package config import lib.NgUIRouterFactory._ import model.User +import model.EmailResetToken._ import net.liftweb._ import common._ @@ -49,6 +50,7 @@ object Site extends Locs { val settings = MenuLoc(Menu.i("Settings") / "settings" >> SettingsGroup >> RequireLoggedIn) val password = MenuLoc(Menu.i("Password") / "settings" / "password" >> SettingsGroup >> RequireLoggedIn) + val emailResetToken = MenuLoc(buildEmailResetTokenMenu) val account = MenuLoc(Menu.i("Account") / "settings" / "account" >> SettingsGroup >> RequireLoggedIn) val editProfile = MenuLoc(Menu("EditProfile", "Profile") / "settings" / "profile" >> SettingsGroup >> RequireLoggedIn) @@ -72,6 +74,7 @@ object Site extends Locs { forgotPassword.menu, settings.menu, password.menu, + emailResetToken.menu, Menu.i("Error") / "error" >> Hidden, Menu.i("404") / "404" >> Hidden, Menu.i("Status") / "ping" >> Hidden >> CalcStateless(() => true ) >> EarlyResponse(() => Full(OkResponse())), diff --git a/src/main/scala/inc/pyc/model/EmailResetToken.scala b/src/main/scala/inc/pyc/model/EmailResetToken.scala @@ -0,0 +1,121 @@ +package inc.pyc +package model + +import config._ +import net.liftweb._ +import common._ +import record._ +import record.field._ +import http._ +import sitemap._, Loc._ +import mongodb.record._ +import mongodb.record.field._ +import util.Helpers._ +import net.liftmodules._ +import mongoauth._ +import mongoauth.field._ +import mongoauth.Locs._ +import org.joda.time.Hours +import org.bson.types.ObjectId + +/** +* This is a token for verifying new email. +*/ +class EmailResetToken extends MongoRecord[EmailResetToken] with ObjectIdPk[EmailResetToken] { + def meta = EmailResetToken + + object userId extends ObjectIdField(this) + object expires extends ExpiresField(this, meta.resetEmailTokenExpires) + object email extends EmailField(this, 64) + + def url: String = meta.url(this) +} + +object EmailResetToken extends EmailResetToken with MongoMetaRecord[EmailResetToken] { + import mongodb.BsonDSL._ + + override def collectionName = "user.emailresettokens" + + ensureIndex((userId.name -> 1)) + + private lazy val resetEmailTokenUrl = "/reset-email-token" + private lazy val resetEmailTokenExpires = Hours.hours(48) + + def url(inst: EmailResetToken): String = "%s%s?token=%s".format(S.hostAndPath, resetEmailTokenUrl, inst.id.toString) + + def createForUserIdBox(uid: ObjectId, email: String): Box[EmailResetToken] = { + createRecord.userId(uid).email(email).saveBox + } + + def deleteAllByUserIdBox(uid: ObjectId): Box[Unit] = tryo { + delete(userId.name, uid) + } + + def findByStringId(in: String): Box[EmailResetToken] = + if (ObjectId.isValid(in)) find(new ObjectId(in)) + else Failure("Invalid ObjectId: "+in) + + def sendToken(user: User, email: String): Unit = { + import net.liftweb.util.Mailer._ + + val token = EmailResetToken.createForUserIdBox(user.id.get, email) + + token.map { token => + val msgTxt = + """ + |Someone requested to change their %s account to this email. + | + |If you did not request this, you can safely ignore it. It will expire 48 hours from the time this message was sent. + | + |Follow the link below or copy and paste it into your internet browser. + | + |%s + | + |Kindest Regards, + | + |%s + """.format(MongoAuth.siteName.vend, token.url, MongoAuth.systemUsername.vend).stripMargin + + sendMail( + From(MongoAuth.systemFancyEmail), + Subject("%s Account: Reset Email".format(MongoAuth.siteName.vend)), + To(token.email.get), + PlainMailBodyType(msgTxt)) + } + } + + def handleEmailResetToken: Box[LiftResponse] = { + User.logUserOut() + val resp = S.param("token").flatMap(EmailResetToken.findByStringId) match { + case Full(at) if (at.expires.isExpired) => { + at.delete_! + RedirectResponse(Site.home.url) + } + case Full(at) => User.find(at.userId.get).map(user => { + if (user.validate.length == 0) { + user.verified(true) + user.email(at.email.get) + user.save() + at.delete_! + RedirectResponse(Site.login.url) + } + else { + at.delete_! + User.regUser(user) + RedirectResponse(Site.register.url) + } + }).openOr(RedirectResponse(Site.home.url)) + case _ => RedirectResponse(Site.home.url) + } + + Full(resp) + } + + def buildEmailResetTokenMenu = Menu(Loc( + "EmailResetToken", resetEmailTokenUrl.split("/").filter(_.length > 0).toList, + S ? "liftmodule-monogoauth.locs.emailResetToken", emailResetTokenLocParams + )) + + protected def emailResetTokenLocParams = + EarlyResponse(() => handleEmailResetToken) :: Nil +} +\ No newline at end of file diff --git a/src/main/scala/inc/pyc/snippet/UserSnip.scala b/src/main/scala/inc/pyc/snippet/UserSnip.scala @@ -6,16 +6,59 @@ import net.liftweb._ import common._ import http._ import util._ +import Helpers._ import json._ +import JsonDSL._ import JsonAST.{JValue, JString, JBool} import net.liftmodules.mongoauth.model.ExtSession -class UserLogin extends AngularSnippet { +/** + * Angular snippet when logged in. + */ +trait AngularUserSnippet extends AngularSnippet with AngularImplicits { + val user = User.currentUser + + protected def serve(snip: User => JValue): JValue = + (for { + u <- user ?~ "User not found" + } yield { + snip(u) + }): JValue + + /** Validates and saves the currently signed in user. */ + protected def validateAndSave(): JValue = serve { + user => + validateUser ({ + user.save() + NgAlert.success + }) + } + + /** Validates and updates the currently signed in user. */ + protected def validateAndUpdate(): JValue = serve { + user => + validateUser ({ + user.update + NgAlert.success + }) + } + + protected def validateUser(f: JValue): JValue = serve { + user => + user.validate match { + case Nil => f + case errors => + NgAlert.danger("The information submitted is invalid", errors) + } + } +} + +class UserLogin extends AngularSnippet with AngularImplicits { def roundTrips: List[RoundTripInfo] = List("submit" -> submit _) def submit(model: JValue): JValue = { - (for { + for { JString(e) <- model \ "email" JString(password) <- model \ "password" JBool(remember) <- model \ "remember" @@ -23,30 +66,29 @@ class UserLogin extends AngularSnippet { val email = e.toLowerCase.trim User.loginCredentials(LoginCredentials(email, remember)) - if(!(email.length > 0) || !(password.length > 0)) { - NgAlert.danger( - <i class="fa-fw fa fa-thumbs-o-down"></i> ++ - <span>Your login information could not be processed.</span>, Nil) - } - else { - User.findByEmail(email) match { + User.findByEmail(email) match { + case Full(user) if !user.verified.get => + NgAlert.danger( + <i class="fa-fw fa fa-thumbs-o-down"></i> ++ + <p>Your email has not been verified. Please verify before loggin in.</p>, Nil) + case Full(user) if (user.password.isMatch(password)) => User.logUserIn(user, remember) if (remember) User.createExtSession(user.id.get) else ExtSession.deleteExtCookie() NgAlert.success + case _ => NgAlert.danger( <i class="fa-fw fa fa-thumbs-o-down"></i> ++ - <strong>{"Invalid Credentials: "}</strong> + <p>Invalid Credentials:</p> ++ <p>Assure your email and password are correct.</p>, Nil) } - } - }).headOption.getOrElse(JNull) + } } } -class PasswordChange extends AngularSnippet { +class PasswordChange extends AngularUserSnippet with AngularImplicits { /** * Passwords must match this pattern. @@ -58,9 +100,11 @@ class PasswordChange extends AngularSnippet { def roundTrips: List[RoundTripInfo] = List("submit" -> submit _) - def submit(model: JValue): JValue = - (for(user <- User.currentUser) yield { - (for(JString(password) <- model \ "password") yield { + def submit(model: JValue): JValue = serve { + user => + for { + JString(password) <- model \ "password" + } yield { if(password matches passwordPattern) { @@ -78,34 +122,34 @@ class PasswordChange extends AngularSnippet { <ul><li>no white spaces</li><li>at least 8 characters</li><li>at least one non-alpha character</li></ul>, Nil) } - }).headOption.getOrElse(JNull) - }).openOr(JNull) + } + } } -class PasswordRecovery extends AngularSnippet { +class PasswordRecovery extends AngularSnippet with AngularImplicits { def roundTrips: List[RoundTripInfo] = List("submit" -> submit _) def submit(model: JValue): JValue = { - (for { + for { JString(e) <- model \ "email" } yield { val email = e.toLowerCase.trim User.findByEmail(email) match { - case Full(user) => - User.sendLoginToken(user) - User.loginCredentials.remove() + case Full(user) => + User.sendLoginToken(user) + User.loginCredentials.remove() - NgAlert.success( - <i class="fa-fw fa fa-thumbs-o-up"></i> ++ - <span>An email has been sent to you with instructions for accessing your account.</span>) - case _ => - NgAlert.danger( - <i class="fa-fw fa fa-thumbs-o-down"></i> ++ - <span>Your email could not be found.</span>, Nil) - } - }).headOption.getOrElse(JNull) + NgAlert.success( + <i class="fa-fw fa fa-thumbs-o-up"></i> ++ + <span>An email has been sent to you with instructions for accessing your account.</span>) + case _ => + NgAlert.danger( + <i class="fa-fw fa fa-thumbs-o-down"></i> ++ + <span>Your email could not be found.</span>, Nil) + } + } } } @@ -137,4 +181,78 @@ class UserRegistration extends AngularSnippet { ) } } +} + +class UserSettings extends AngularUserSnippet with AngularImplicits { + + def roundTrips: List[RoundTripInfo] = List( + "init" -> init _, + "fname" -> fname _, + "lname" -> lname _, + "username" -> username _) + + def init(ignore: JValue): JValue = serve { + user => + ("fname" -> user.fname.get) ~ + ("lname" -> user.lname.get) ~ + ("username" -> user.username.get) + } + + def fname(model: JValue): JValue = serve { + user => + for { + JString(fname) <- model + } yield { + info(fname) + user.fname(fname) + validateAndUpdate() + } + } + + def lname(model: JValue): JValue = serve { + user => + for { + JString(lname) <- model + } yield { + user.lname(lname) + validateAndUpdate() + } + } + + def username(model: JValue): JValue = serve { + user => + for { + JString(username) <- model + } yield { + user.username(username) + validateAndUpdate() + } + } +} + +class UserSettingsEmail extends AngularUserSnippet with AngularImplicits { + + def roundTrips: List[RoundTripInfo] = List( + "submit" -> submit _, + "init" -> init _) + + def submit(model: JValue): JValue = serve { + user => + for { + JString(e) <- model \ "email" + } yield { + val email = e.toLowerCase.trim + EmailResetToken.sendToken(user, email) + + NgAlert.success( + <i class="fa-fw fa fa-thumbs-o-up"></i> ++ + <span> An email has been sent for you to verify your new email.</span> + ) + } + } + + def init(model: JValue): JValue = serve { + user => + ("email" -> user.email.get) + } } \ No newline at end of file diff --git a/src/main/scala/inc/pyc/snippet/UtilSnips.scala b/src/main/scala/inc/pyc/snippet/UtilSnips.scala @@ -10,6 +10,7 @@ import Helpers._ import http._ import js._ import net.liftmodules.extras._, snippet._ +import net.liftweb.json.JsonAST.{JNull, JValue} /* * Base all LiftScreens off this. Currently configured to use bootstrap 3. @@ -29,3 +30,9 @@ object ProductionOnly { if (Props.productionMode) in else NodeSeq.Empty } + +trait AngularImplicits { + implicit protected def listJvalueToJvalue(in: List[JValue]): JValue = { + in.headOption.getOrElse(JNull) + } +} diff --git a/src/main/webapp/app/App.js b/src/main/webapp/app/App.js @@ -83,20 +83,91 @@ app.controller('AlertCtrl', ['$scope', function($scope) { }]); app.controller('FormCtrl', ['$scope', function($scope) { + + /* Client-side data */ + $scope.model = {}; + + /* Success inputs for ng-class */ $scope.stateSuccess = function(el) { return "{'state-success':form."+el+".$valid && !form."+el+".$pristine}"; }; + /* Success and Failure inputs in ng-class */ $scope.stateSuccessError = function(el) { return "{'state-error':form."+el+".$invalid && !form."+el+".$pristine,'state-success':form."+el+".$valid && !form."+el+".$pristine}"; }; + /* Resets client-side data. */ $scope.reset = function() { $scope.model = {}; $scope.form.$setPristine(); }; }]); +/* Form controller that is initialized with server-side data. */ +app.controller('LoadedFormCtrl', ['$scope', '$controller', function($scope, $controller) { + $controller('FormCtrl', {$scope: $scope}); + + /* assumed server-side data */ + $scope.master = {}; + + /* Checks whether the client-side data is different from */ + $scope.diff = function(name) { + return $scope.master[name] !== $scope.model[name]; + }; + + /* Initiates the form with existing data from the server. */ + $scope.init = function(className, funcName) { + window[className][funcName]().then(function(data) { + $scope.$apply(function() { + $scope.model = angular.copy(data); + $scope.master = angular.copy(data); + }); + }); + }; +}]); + +/* Form controller to easily update (server-side data) inputs immediately. */ +app.controller('AutoUpdateFormCtrl', ['$scope', '$controller', '$rootScope', function($scope, $controller, $rootScope) { + $controller('LoadedFormCtrl', {$scope: $scope, $controller: $controller}); + + /* Updates server-side data with client-side's data corresponding to a change for 'name'. */ + $scope.update = function(className, funcName, successFunc, failureFunc) { + // if values are different, update + if($scope.diff(funcName)) { + $scope[funcName+"_loading"] = true; + window[className][funcName]($scope.model[funcName]).then(function(alert) { + $scope.$apply(function() { + $scope[funcName+"_loading"] = false; + if(alert.msg_type === "success") { + $scope.master= angular.copy($scope.model); + $scope.reset(); + + if(typeof(successFunc) === "function") { + successFunc(); + } + } else { + $rootScope.$broadcast('alertDialog', alert); + + if(typeof(failureFunc) === "function") { + failureFunc(); + } + } + }); + }); + } else { + $scope.reset(); + } + }; + + /* Resets sets form to pristine. */ + $scope.reset = function() { + $scope.model = {}; + $scope.form.$setPristine(); + $scope.model= angular.copy($scope.master); + }; +}]); + app.controller('NearAtmNotifyCtrl', ['$scope', '$controller', '$rootScope', function($scope, $controller, $rootScope) { $controller('FormCtrl', {$scope: $scope}); $scope.zip_code_regex = ZIP_CODE_REGEXP; @@ -241,6 +312,35 @@ app.controller('PasswordChangeCtrl', ['$scope', '$controller', '$rootScope', fun }; }]); +app.controller('UserSettingsCtrl', ['$scope', '$controller', '$rootScope', function($scope, $controller, $rootScope) { + $controller('AutoUpdateFormCtrl', {$scope: $scope, $controller: $controller, $rootScope: $rootScope}); + $scope.init('UserSettings','init'); + + $scope.updateUserSettings = function(funcName) { + $scope.update('UserSettings', funcName); + }; +}]); +app.controller('UserSettingsEmailCtrl', ['$scope', '$controller', '$rootScope', function($scope, $controller, $rootScope) { + $controller('LoadedFormCtrl', {$scope: $scope, $controller: $controller}); + $scope.init('UserSettingsEmail', 'init'); + + $scope.save = function() { + $scope.loading = true; + window.UserSettingsEmail.submit($scope.model).then(function(alert) { + $scope.$apply(function() { + $scope.loading = false; + if(alert.msg_type === "success") { + $rootScope.$broadcast('alertDialog', alert); + $scope.reset(); + + } else { + $rootScope.$broadcast('alertDialog', alert); + } + }); + }); + }; +}]); + app.controller('GMapCtrl', ['$scope', function($scope) { $scope.lat = 40.778202; $scope.long = -74.122381; diff --git a/src/main/webapp/less/overrides.less b/src/main/webapp/less/overrides.less @@ -220,3 +220,10 @@ body { } } } + +/* Removes borders from loading-ajax icon. */ +.smart-form { + .icon-append, .icon-prepend { + border: none !important; + } +} +\ No newline at end of file diff --git a/src/main/webapp/settings.html b/src/main/webapp/settings.html @@ -1,6 +1,20 @@ <div data-lift="NgUIRouter.surround?withAjax=no-base-settings-wrap&with=settings-wrap&at=content"> <div class="padding-20"> - <h1>You are logged in</h1> + <div class="row"> + <div class="col-xs-12 col-sm-6"> + <h4>Change your user settings</h4> + <div class="well"> + <span data-lift="embed?what=/templates-hidden/parts/user-settings-form"></span> + </div> + </div> + + <div class="col-xs-12 col-sm-6"> + <h4>Change your email address</h4> + <div class="well"> + <span data-lift="embed?what=/templates-hidden/parts/user-settings-email-form"></span> + </div> + </div> + </div> </div> <div ui-view="viewA"></div> </div> \ No newline at end of file diff --git a/src/main/webapp/templates-hidden/no-base-settings-wrap.html b/src/main/webapp/templates-hidden/no-base-settings-wrap.html @@ -11,9 +11,6 @@ <span lift="CurrentUser.name"></span> </h3> </div> - <div style="display:none; float:right;" id="ajax-spinner"> - <i class="fa fa-spinner fa-spin"></i> - </div> </div> <div class="row"> <div class="settings-secondary"> diff --git a/src/main/webapp/templates-hidden/parts/user-settings-email-form.html b/src/main/webapp/templates-hidden/parts/user-settings-email-form.html @@ -0,0 +1,16 @@ +<div data-lift="UserSettingsEmail" ng-controller="UserSettingsEmailCtrl" ng-cloak> + <form name="form" class="smart-form client-form" ng-submit="save()" novalidate> + <fieldset> + <section> + <label>Email Address</label> + <label class="input" ng-class="{{ stateSuccessError('email') }}"> + <input name="email" ng-model="model.email" type="email"> + </label> + </section> + </fieldset> + + <footer> + <button type="submit" class="btn btn-primary" ng-disabled="form.$invalid || form.$pristine" disabler ng-model="loading">Change Email Address</button> + </footer> + </form> +</div> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/parts/user-settings-form.html b/src/main/webapp/templates-hidden/parts/user-settings-form.html @@ -0,0 +1,30 @@ +<div data-lift="UserSettings" ng-controller="UserSettingsCtrl" ng-cloak> +<form name="form" class="smart-form client-form" novalidate> + <fieldset> + <section> + <label>First Name</label> + <label class="input" ng-class="{{ stateSuccessError('fname') }}"> + <i ng-show="fname_loading" class="icon-append fa fa-spinner fa-spin"></i> + <input name="fname" ng-model="model.fname" type="text" ng-blur="updateUserSettings('fname')"> + </label> + </section> + + <section> + <label>Last Name</label> + <label class="input" ng-class="{{ stateSuccessError('lname') }}"> + <i ng-show="lname_loading" class="icon-append fa fa-spinner fa-spin"></i> + <input name="lname" ng-model="model.lname" type="text" ng-blur="updateUserSettings('lname')"> + </label> + </section> + + <section> + <label>Username</label> + <label class="input" ng-class="{{ stateSuccessError('username') }}"> + <i ng-show="username_loading" class="icon-append fa fa-spinner fa-spin"></i> + <i ng-hide="username_loading" class="icon-append fa fa-user"></i> + <input name="username" ng-model="model.username" type="text" ng-blur="updateUserSettings('username')"> + </label> + </section> + </fieldset> +</form> +</div> +\ No newline at end of file diff --git a/src/main/webapp/templates-hidden/settings-wrap.html b/src/main/webapp/templates-hidden/settings-wrap.html @@ -11,9 +11,6 @@ <span lift="CurrentUser.name"></span> </h3> </div> - <div style="display:none; float:right;" id="ajax-spinner"> - <i class="fa fa-spinner fa-spin"></i> - </div> </div> <div class="row"> <div class="settings-secondary">