/*global define */ define(['jquery', 'underscore', 'backbone', 'jws', 'models/Search', "collections/SolrResults"], function($, _, Backbone, JWS, SearchModel, SearchResults) { 'use strict'; /** * @class UserModel * @classcategory Models * @extends Backbone.Model * @constructor */ var UserModel = Backbone.Model.extend( /** @lends UserModel.prototype */{ defaults: function(){ return{ type: "person", //assume this is a person unless we are told otherwise - other possible type is a "group" checked: false, //Is set to true when we have checked the account/subject info of this user tokenChecked: false, //Is set to true when the uer auth token has been checked basicUser: false, //Set to true to only query for basic info about this user - prevents sending queries for info that will never be displayed in the UI lastName: null, firstName: null, fullName: null, email: null, logo: null, description: null, verified: null, username: null, usernameReadable: null, orcid: null, searchModel: null, searchResults: null, loggedIn: false, ldapError: false, //Was there an error logging in to LDAP registered: false, isMemberOf: [], isOwnerOf: [], identities: [], identitiesUsernames: [], allIdentitiesAndGroups: [], pending: [], token: null, expires: null, timeoutId: null, rawData: null, portalQuota: -1, isAuthorizedCreatePortal: null, dataoneQuotas: null, dataoneSubscription: null } }, initialize: function(options){ if(typeof options !== "undefined"){ if(options.username) this.set("username", options.username); if(options.rawData) this.set(this.parseXML(options.rawData)); } this.on("change:identities", this.pluckIdentityUsernames); this.on("change:username change:identities change:type", this.updateSearchModel); this.createSearchModel(); this.on("change:username", this.createReadableUsername()); //Create a search results model for this person var searchResults = new SearchResults([], { rows: 5, start: 0 }); this.set("searchResults", searchResults); if( MetacatUI.appModel.get("enableBookkeeperServices") ){ //When the user is logged in, see if they have a DataONE subscription this.on("change:loggedIn", this.fetchSubscription); } }, createSearchModel: function(){ //Create a search model that will retrieve data created by this person this.set("searchModel", new SearchModel()); this.updateSearchModel(); }, updateSearchModel: function(){ if(this.get("type") == "node"){ this.get("searchModel").set("dataSource", [this.get("node").identifier]); this.get("searchModel").set("username", []); } else{ //Get all the identities for this person var ids = [this.get("username")]; _.each(this.get("identities"), function(equivalentUser){ ids.push(equivalentUser.get("username")); }); this.get("searchModel").set("username", ids); } this.trigger("change:searchModel"); }, parseXML: function(data){ var model = this, username = this.get("username"); //Reset the group list so we don't just add it to it with push() this.set("isMemberOf", this.defaults().isMemberOf, {silent: true}); this.set("isOwnerOf", this.defaults().isOwnerOf, {silent: true}); //Reset the equivalent id list so we don't just add it to it with push() this.set("identities", this.defaults().identities, {silent: true}); //Find this person's node in the XML var userNode = null; if(!username) var username = $(data).children("subject").text(); if(username){ var subjects = $(data).find("subject"); for(var i=0; i 0) var allPersons = $(data).find("person subject"); _.each(equivalentIds, function(identity, i){ //push onto the list var username = $(identity).text(), equivUserNode; //Find the matching person node in the response _.each(allPersons, function(person){ if($(person).text().toLowerCase() == username.toLowerCase()){ equivUserNode = $(person).parent().first(); allPersons = _.without(allPersons, person); } }); var equivalentUser = new UserModel({ username: username, basicUser: true, rawData: equivUserNode }); identities.push(equivalentUser); equivUsernames.push(username); }); } //Get each group and save _.each($(data).find("group"), function(group, i){ //Save group ID var groupId = $(group).find("subject").first().text(), groupName = $(group).find("groupName").text(); memberOf.push({ groupId: groupId, name: groupName }); //Check if this person is a rightsholder var allRightsHolders = []; _.each($(group).children("rightsHolder"), function(rightsHolder){ allRightsHolders.push($(rightsHolder).text().toLowerCase()); }); if(_.contains(allRightsHolders, username.toLowerCase())) ownerOf.push(groupId); }); } var allSubjects = _.pluck( this.get("isMemberOf"), "groupId" ); allSubjects.push(this.get("username")); allSubjects = allSubjects.concat(equivUsernames); return { isMemberOf: memberOf, isOwnerOf: ownerOf, identities: identities, allIdentitiesAndGroups: allSubjects, verified: verified, username: username, firstName: firstName, lastName: lastName, fullName: fullName, email: email, registered: true, type: type, rawData: data } }, getInfo: function(){ var model = this; //If the accounts service is not on, flag this user as checked/completed if(!MetacatUI.appModel.get("accountsUrl")){ this.set("fullName", this.getNameFromSubject()); this.set("checked", true); return; } //Only proceed if there is a username if(!this.get("username")) return; //Get the user info using the DataONE API var url = MetacatUI.appModel.get("accountsUrl") + encodeURIComponent(this.get("username")); var requestSettings = { type: "GET", url: url, success: function(data, textStatus, xhr) { //Parse the XML response to get user info var userProperties = model.parseXML(data); //Filter out all the falsey values _.each(userProperties, function(v, k) { if(!v) { delete userProperties[k]; } }); model.set(userProperties); //Trigger the change events model.trigger("change:isMemberOf"); model.trigger("change:isOwnerOf"); model.trigger("change:identities"); model.set("checked", true); }, error: function(xhr, textStatus, errorThrown){ // Sometimes the node info has not been received before this getInfo() is called. // If the node info was received while this getInfo request was pending, and this user was determined // to be a node, then we can skip any further action here. if(model.get("type") == "node") return; if((xhr.status == 404) && MetacatUI.nodeModel.get("checked")){ model.set("fullName", model.getNameFromSubject()); model.set("checked", true); } else if((xhr.status == 404) && !MetacatUI.nodeModel.get("checked")){ model.listenToOnce(MetacatUI.nodeModel, "change:checked", function(){ if(!model.isNode()){ model.set("fullName", model.getNameFromSubject()); model.set("checked", true); } }); } else{ //As a backup, search for this user instead var requestSettings = { type: "GET", url: MetacatUI.appModel.get("accountsUrl") + "?query=" + encodeURIComponent(model.get("username")), success: function(data, textStatus, xhr) { //Parse the XML response to get user info model.set(model.parseXML(data)); //Trigger the change events model.trigger("change:isMemberOf"); model.trigger("change:isOwnerOf"); model.trigger("change:identities"); model.set("checked", true); }, error: function(){ //Set some blank values and flag as checked //model.set("username", ""); //model.set("fullName", ""); model.set("notFound", true); model.set("checked", true); } } //Send the request $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); } } } //Send the request $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, //Get the pending identity map requests, if the service is turned on getPendingIdentities: function(){ if(!MetacatUI.appModel.get("pendingMapsUrl")) return false; var model = this; //Get the pending requests var requestSettings = { url: MetacatUI.appModel.get("pendingMapsUrl") + encodeURIComponent(this.get("username")), success: function(data, textStatus, xhr){ //Reset the equivalent id list so we don't just add it to it with push() model.set("pending", model.defaults().pending); var pending = model.get("pending"); _.each($(data).find("person"), function(person, i) { //Don't list yourself as a pending map request var personsUsername = $(person).find("subject").text(); if(personsUsername.toLowerCase() == model.get("username").toLowerCase()) return; //Create a new User Model for this pending identity var pendingUser = new UserModel({ rawData: person }); if(pendingUser.isOrcid()) pendingUser.getInfo(); pending.push(pendingUser); }); model.set("pending", pending); model.trigger("change:pending"); //Trigger the change event }, error: function(xhr, textStatus){ if(xhr.responseText.indexOf("error code 34")){ model.set("pending", model.defaults().pending); model.trigger("change:pending"); //Trigger the change event } } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, getNameFromSubject: function(username){ var username = username || this.get("username"), fullName = ""; if(!username) return; if((username.indexOf("uid=") > -1) && (username.indexOf(",") > -1)) fullName = username.substring(username.indexOf("uid=") + 4, username.indexOf(",")); else if((username.indexOf("CN=") > -1) && (username.indexOf(",") > -1)) fullName = username.substring(username.indexOf("CN=") + 3, username.indexOf(",")); //Cut off the last string after the name when it contains digits - not part of this person's names if(fullName.lastIndexOf(" ") > fullName.indexOf(" ")){ var lastWord = fullName.substring(fullName.lastIndexOf(" ")); if(lastWord.search(/\d/) > -1) fullName = fullName.substring(0, fullName.lastIndexOf(" ")); } //Default to the username if(!fullName) fullName = this.get("fullname") || username; return fullName; }, isOrcid: function(orcid){ var username = (typeof orcid === "string")? orcid : this.get("username"); //Have we already verified this? if((typeof orcid == "undefined") && (username == this.get("orcid"))) return true; //Checks for ORCIDs using the orcid base URL as a prefix if(username.indexOf("orcid.org/") > -1){ return true; } //If the ORCID base url is not present, we will check if this is a 19-digit ORCID ID //A simple and fast check first //ORCiDs are 16 digits and 3 dashes - 19 characters if(username.length != 19) return false; /* The ORCID checksum algorithm to determine is a character string is an ORCiD * http://support.orcid.org/knowledgebase/articles/116780-structure-of-the-orcid-identifier */ var total = 0, baseDigits = username.replace(/-/g, "").substr(0, 15); for(var i=0; i 99999)) issuedAt.setMilliseconds(lifeSpan); else if(issuedAt && lifeSpan) issuedAt.setSeconds(lifeSpan); expires = issuedAt; } this.set("expires", expires); }, checkToken: function(onSuccess, onError){ //First check if the token has expired if(MetacatUI.appUserModel.get("expires") > new Date()){ if(onSuccess) onSuccess(); return; } var model = this; var url = MetacatUI.appModel.get("tokenUrl"); if(!url) return; var requestSettings = { type: "GET", url: url, headers: { "Cache-Control": "no-cache" }, success: function(data, textStatus, xhr){ if(data){ // the response should have the token var payload = model.parseToken(data), username = payload ? payload.userId : null, fullName = payload ? payload.fullName : null, token = payload ? data : null, loggedIn = payload ? true : false; // set in the model model.set('fullName', fullName); model.set('username', username); model.set("token", token); model.set("loggedIn", loggedIn); model.getTokenExpiration(payload); MetacatUI.appUserModel.set("checked", true); if(onSuccess) onSuccess(data, textStatus, xhr); } else if(onError) onError(data, textStatus, xhr); }, error: function(data, textStatus, xhr){ //If this token in invalid, then reset the user model/log out MetacatUI.appUserModel.reset(); if(onError) onError(data, textStatus, xhr); } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, parseToken: function(token) { if(typeof token == "undefined") var token = this.get("token"); var jws = new KJUR.jws.JWS(); var result = 0; try { result = jws.parseJWS(token); } catch (ex) { result = 0; } if(!jws.parsedJWS) return ""; var payload = $.parseJSON(jws.parsedJWS.payloadS); return payload; }, update: function(onSuccess, onError){ var model = this; var person = '' + '' + '' + this.get("username") + '' + '' + this.get("firstName") + '' + '' + this.get("lastName") + '' + '' + this.get("email") + '' + ''; var xmlBlob = new Blob([person], {type : 'application/xml'}); var formData = new FormData(); formData.append("subject", this.get("username")); formData.append("person", xmlBlob, "person"); var updateUrl = MetacatUI.appModel.get("accountsUrl") + encodeURIComponent(this.get("username")); // ajax call to update var requestSettings = { type: "PUT", cache: false, contentType: false, processData: false, url: updateUrl, data: formData, success: function(data, textStatus, xhr) { if(typeof onSuccess != "undefined") onSuccess(data); //model.getInfo(); }, error: function(data, textStatus, xhr) { if(typeof onError != "undefined") onError(data); } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, confirmMapRequest: function(otherUsername, onSuccess, onError){ if(!otherUsername) return; var mapUrl = MetacatUI.appModel.get("pendingMapsUrl") + encodeURIComponent(otherUsername), model = this; if(!onSuccess) var onSuccess = function(){}; if(!onError) var onError = function(){}; // ajax call to confirm map var requestSettings = { type: "PUT", url: mapUrl, success: function(data, textStatus, xhr) { if(onSuccess) onSuccess(data, textStatus, xhr); //Get updated info model.getInfo(); }, error: function(xhr, textStatus, error) { if(onError) onError(xhr, textStatus, error); } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, denyMapRequest: function(otherUsername, onSuccess, onError){ if(!otherUsername) return; var mapUrl = MetacatUI.appModel.get("pendingMapsUrl") + encodeURIComponent(otherUsername), model = this; // ajax call to reject map var requestSettings = { type: "DELETE", url: mapUrl, success: function(data, textStatus, xhr) { if(typeof onSuccess == "function") onSuccess(data, textStatus, xhr); model.getInfo(); }, error: function(xhr, textStatus, error) { if(typeof onError == "function") onError(xhr, textStatus, error); } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, addMap: function(otherUsername, onSuccess, onError){ if(!otherUsername) return; var mapUrl = MetacatUI.appModel.get("pendingMapsUrl"), model = this; if(mapUrl.charAt(mapUrl.length-1) == "/"){ mapUrl = mapUrl.substring(0, mapUrl.length-1) } // ajax call to map var requestSettings = { type: "POST", xhrFields: { withCredentials: true }, headers: { "Authorization": "Bearer " + this.get("token") }, url: mapUrl, data: { subject: otherUsername }, success: function(data, textStatus, xhr) { if(typeof onSuccess == "function") onSuccess(data, textStatus, xhr); model.getInfo(); }, error: function(xhr, textStatus, error) { //Check if the username might have been spelled or formatted incorrectly //ORCIDs, in particular, have different formats that we should account for if(xhr.responseText.indexOf("LDAP: error code 32 - No Such Object") > -1 && model.isOrcid(otherUsername)){ if(otherUsername.length == 19) model.addMap("http://orcid.org/" + otherUsername, onSuccess, onError); else if(otherUsername.indexOf("https://orcid.org") == 0) model.addMap(otherUsername.replace("https", "http"), onSuccess, onError); else if(otherUsername.indexOf("orcid.org") == 0) model.addMap("http://" + otherUsername, onSuccess, onError); else if(otherUsername.indexOf("www.orcid.org") == 0) model.addMap(otherUsername.replace("www.", "http://"), onSuccess, onError); else if(otherUsername.indexOf("http://www.orcid.org") == 0) model.addMap(otherUsername.replace("www.", ""), onSuccess, onError); else if(otherUsername.indexOf("https://www.orcid.org") == 0) model.addMap(otherUsername.replace("https://www.", "http://"), onSuccess, onError); else if(typeof onError == "function") onError(xhr, textStatus, error); } else{ if(typeof onError == "function") onError(xhr, textStatus, error); } } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, removeMap: function(otherUsername, onSuccess, onError){ if(!otherUsername) return; var mapUrl = MetacatUI.appModel.get("accountsMapsUrl") + encodeURIComponent(otherUsername), model = this; // ajax call to remove mapping var requestSettings = { type: "DELETE", url: mapUrl, success: function(data, textStatus, xhr) { if(typeof onSuccess == "function") onSuccess(data, textStatus, xhr); model.getInfo(); }, error: function(xhr, textStatus, error) { if(typeof onError == "function") onError(xhr, textStatus, error); } } $.ajax(_.extend(requestSettings, MetacatUI.appUserModel.createAjaxSettings())); }, failedLdapLogin: function(){ this.set("loggedIn", false); this.set("checked", true); this.set("ldapError", true); }, pluckIdentityUsernames: function(){ var models = this.get("identities"), usernames = []; _.each(models, function(m){ usernames.push(m.get("username").toLowerCase()); }); this.set("identitiesUsernames", usernames); this.trigger("change:identitiesUsernames"); }, createReadableUsername: function(){ if(!this.get("username")) return; var username = this.get("username"), readableUsername = username.substring(username.indexOf("=")+1, username.indexOf(",")) || username; this.set("usernameReadable", readableUsername); }, createAjaxSettings: function(){ if(!this.get("token")) return {} return { xhrFields: { withCredentials: true }, headers: { "Authorization": "Bearer " + this.get("token") } } }, /** * Checks if this user has the quota to perform the given action * @param {string} action - The action to be performed * @param {string} customerGroup - The subject or identifier of the customer/membership group * to use this quota against */ checkQuota: function(action, customerGroup){ //Temporarily reset the quota so a trigger event is changed when the XHR is complete this.set("portalQuota", -1, {silent: true}); //Start of temporary code //TODO: Replace this function with real code once the quota service is working this.set("portalQuota", 999); return; //End of temporary code /* var model = this; var requestSettings = { url: "", type: "GET", success: function(data, textStatus, xhr) { model.set("portalQuota", data.remainingQuota); }, error: function(xhr, textStatus, errorThrown) { model.set("portalQuota", 0); } } $.ajax(_.extend(requestSettings, this.createAjaxSettings())); */ }, /** * Checks if the user has authorization to perform the given action. */ isAuthorizedCreatePortal: function(){ //Reset the isAuthorized attribute silently so a change event is always triggered this.set("isAuthorizedCreatePortal", null, {silent: true}); //If the user isn't logged in, set authorization to false if( !this.get("loggedIn") ){ this.set("isAuthorizedCreatePortal", false); return; } //If creating portals has been disabled app-wide, then set to false if( MetacatUI.appModel.get("enableCreatePortals") === false ){ this.set("isAuthorizedCreatePortal", false); return; } //If creating portals has been limited to only certain subjects, check if this user is one of them else if( MetacatUI.appModel.get("limitPortalsToSubjects").length ){ if( !this.get("allIdentitiesAndGroups").length ){ this.on("change:allIdentitiesAndGroups", this.isAuthorizedCreatePortal); return; } //Find the subjects that have access to create portals. Could be specific users or groups. var subjectsThatHaveAccess = _.intersection(MetacatUI.appModel.get("limitPortalsToSubjects"), this.get("allIdentitiesAndGroups")); if( !subjectsThatHaveAccess.length ){ //If this user is not in the whitelist, set to false this.set("isAuthorizedCreatePortal", false); } else{ //If this user is in the whitelist, set to true this.set("isAuthorizedCreatePortal", true); } return; } //If anyone is allowed to create a portal, check if they have the quota to create a portal else if( MetacatUI.appModel.get("enableBookkeeperServices") ){ //Get the Quotas for this user var quotas = this.get("dataoneQuotas"), portalQuotas; //If the Quotas are still being fetched, if(quotas == this.defaults().dataoneQuotas && !quotas){ this.on("change:dataoneQuotas", this.isAuthorizedCreatePortal); return; } else{ portalQuotas = quotas.where({ quotaType: "portal" }); } //If this user has no portal Quota at all, they are not auth to create a portal if( !portalQuotas ){ this.set("isAuthorizedCreatePortal", false); } else{ //Check that there is at least one Quota where the totalUsage < softLimit var hasRemainingUsage = _.some(portalQuotas, function(quota){ return quota.get("totalUsage") < quota.get("softLimit"); }); //If there is remaining usage left in at least one Quota, then the user can create a portal if( hasRemainingUsage ){ this.set("isAuthorizedCreatePortal", true); } //Otherwise they cannot create a new portal else{ this.set("isAuthorizedCreatePortal", false); } } //@todoGet the admin group and force admins to have at least one quota left } else{ //Default to letting people create portals this.set("isAuthorizedCreatePortal", true); } }, /** * Given a list of user and/or group subjects, this function checks if this user * has an equivalent identity in that list, or is a member of a group in the list. * A single subject string can be passed instead of an array of subjects. * TODO: This needs to support nested group membership. * @param {string|string[]} subjects * @returns {boolean} */ hasIdentityOverlap: function(subjects){ try{ //If only a single subject is given, put it in an array if( typeof subjects == "string" ){ subjects = [subjects]; } //If the subjects are not a string or an array, or if it's an empty array, exit this function. else if( !Array.isArray(subjects) || !subjects.length ){ return false; } return _.intersection(this.get("allIdentitiesAndGroups"), subjects).length; } catch(e){ console.error(e); return false; } }, /** * Retrieve all the info about this user's DataONE Subscription */ fetchSubscription: function(){ //If Bookkeeper services are disabled, exit if( !MetacatUI.appModel.get("enableBookkeeperServices") ){ return; } try{ var thisUser = this; require(["collections/bookkeeper/Quotas", "models/bookkeeper/Subscription"], function(Quotas, Subscription){ //Create a Quotas collection var quotas = new Quotas(); //Create a Subscription model var subscription = new Subscription(); if( MetacatUI.appModel.get("dataonePlusPreviewMode") ){ //Create Quota models for preview mode quotas.add({ softLimit: MetacatUI.appModel.get("portalLimit"), hardLimit: MetacatUI.appModel.get("portalLimit"), quotaType: "portal", unit: "portal", subject: thisUser.get("username"), subscription: subscription }); //Default to all people being in trial mode subscription.set("status", "trialing"); //Save a reference to the Quotas on this UserModel thisUser.set("dataoneQuotas", quotas); //Save a reference to the Subscriptioin on this UserModel thisUser.set("dataoneSubscription", subscription); } else{ thisUser.listenToOnce(quotas, "reset", function(){ //Save a reference to the Quotas on this UserModel thisUser.set("dataoneQuotas", quotas); }); thisUser.listenToOnce(subscription, "sync", function(){ //Save a reference to the Subscriptioin on this UserModel thisUser.set("dataoneSubscription", subscription); }); //Fetch the Quotas quotas.fetch({ subscriber: thisUser.get("username") }); //Fetch the Subscriptioin subscription.fetch(); } }); } catch(e){ console.error("Couldn't get DataONE Subscription info. Proceeding as an unsubscribed user. ", e); } }, /** * Gets the already-fetched Quotas for the User, filters down to the type given, and returns them. * @param {string} [type] - The Quota type to return * @returns {Quota[]} The filtered array of Quota models or an empty array, if none are found */ getQuotas: function(type){ var quotas = this.get("dataoneQuotas"); if( quotas && type ){ return quotas.where({ quotaType: type }); } else if( quotas && !type ){ return quotas; } else{ return []; } }, reset: function(){ var defaults = _.omit(this.defaults(), ["searchModel", "searchResults"]); this.set(defaults); } }); return UserModel; });