Source: src/js/models/UserModel.js

define([
  "jquery",
  "underscore",
  "backbone",
  "jws",
  "models/Search",
  "collections/SolrResults",
], ($, _, Backbone, JWS, SearchModel, SearchResults) => {
  "use strict";

  /**
   * @class UserModel
   * @classcategory Models
   * @augments Backbone.Model
   * @class
   */
  const UserModel = Backbone.Model.extend(
    /** @lends UserModel.prototype */ {
      defaults() {
        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(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
        const 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() {
        // Create a search model that will retrieve data created by this person
        this.set("searchModel", new SearchModel());
        this.updateSearchModel();
      },

      updateSearchModel() {
        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
          const ids = [this.get("username")];

          _.each(this.get("identities"), (equivalentUser) => {
            ids.push(equivalentUser.get("username"));
          });
          this.get("searchModel").set("username", ids);
        }

        this.trigger("change:searchModel");
      },

      parseXML(data) {
        let 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
        let userNode = null;
        if (!username) username = $(data).children("subject").text();
        if (username) {
          const subjects = $(data).find("subject");
          for (let i = 0; i < subjects.length; i++) {
            if (
              $(subjects[i]).text().toLowerCase() === username.toLowerCase()
            ) {
              userNode = $(subjects[i]).parent();
              break;
            }
          }
        }
        if (!userNode) userNode = $(data).first();

        // Get the type of user - either a person or group
        let type = $(userNode).prop("tagName");
        if (type) type = type.toLowerCase();

        if (type == "group") {
          var fullName = $(userNode).find("groupName").first().text();
        } else if (type) {
          // Find the person's info
          var firstName = $(userNode).find("givenName").first().text();
          var lastName = $(userNode).find("familyName").first().text();
          var email = $(userNode).find("email").first().text();
          var verified = $(userNode).find("verified").first().text();
          var memberOf = this.get("isMemberOf");
          var ownerOf = this.get("isOwnerOf");
          var identities = this.get("identities");
          var equivUsernames = [];

          // Sometimes names are saved as "NA" when they are not available - translate these to false values
          if (firstName == "NA") firstName = null;
          if (lastName == "NA") lastName = null;

          // Construct the fullname from the first and last names, but watch out for falsely values
          var fullName = "";
          fullName += firstName || "";
          fullName += lastName ? ` ${lastName}` : "";

          if (!fullName) fullName = this.getNameFromSubject(username);

          // Don't get this detailed info about basic users
          if (!this.get("basicUser")) {
            // Get all the equivalent identities for this user
            const equivalentIds = $(userNode).find("equivalentIdentity");
            if (equivalentIds.length > 0)
              var allPersons = $(data).find("person subject");

            _.each(equivalentIds, (identity, i) => {
              // push onto the list
              const username = $(identity).text();
              let equivUserNode;

              // Find the matching person node in the response
              _.each(allPersons, (person) => {
                if ($(person).text().toLowerCase() == username.toLowerCase()) {
                  equivUserNode = $(person).parent().first();
                  allPersons = _.without(allPersons, person);
                }
              });

              const equivalentUser = new UserModel({
                username,
                basicUser: true,
                rawData: equivUserNode,
              });
              identities.push(equivalentUser);
              equivUsernames.push(username);
            });
          }

          // Get each group and save
          _.each($(data).find("group"), (group, i) => {
            // Save group ID
            const groupId = $(group).find("subject").first().text();
            const groupName = $(group).find("groupName").text();

            memberOf.push({ groupId, name: groupName });

            // Check if this person is a rightsholder
            const allRightsHolders = [];
            _.each($(group).children("rightsHolder"), (rightsHolder) => {
              allRightsHolders.push($(rightsHolder).text().toLowerCase());
            });
            if (_.contains(allRightsHolders, username.toLowerCase()))
              ownerOf.push(groupId);
          });
        }

        let allSubjects = _.pluck(this.get("isMemberOf"), "groupId");
        allSubjects.push(this.get("username"));
        allSubjects = allSubjects.concat(equivUsernames);

        return {
          isMemberOf: memberOf,
          isOwnerOf: ownerOf,
          identities,
          allIdentitiesAndGroups: allSubjects,
          verified,
          username,
          firstName,
          lastName,
          fullName,
          email,
          registered: true,
          type,
          rawData: data,
        };
      },

      getInfo() {
        const 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
        const url =
          MetacatUI.appModel.get("accountsUrl") +
          encodeURIComponent(this.get("username"));

        const requestSettings = {
          type: "GET",
          url,
          success(data, textStatus, xhr) {
            // Parse the XML response to get user info
            const userProperties = model.parseXML(data);
            // Filter out all the falsey values
            _.each(userProperties, (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(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", () => {
                if (!model.isNode()) {
                  model.set("fullName", model.getNameFromSubject());
                  model.set("checked", true);
                }
              });
            } else {
              // As a backup, search for this user instead
              const requestSettings = {
                type: "GET",
                url: `${MetacatUI.appModel.get(
                  "accountsUrl",
                )}?query=${encodeURIComponent(model.get("username"))}`,
                success(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() {
                  // 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() {
        if (!MetacatUI.appModel.get("pendingMapsUrl")) return false;

        const model = this;

        // Get the pending requests
        const requestSettings = {
          url:
            MetacatUI.appModel.get("pendingMapsUrl") +
            encodeURIComponent(this.get("username")),
          success(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);
            const pending = model.get("pending");
            _.each($(data).find("person"), (person, i) => {
              // Don't list yourself as a pending map request
              const personsUsername = $(person).find("subject").text();
              if (
                personsUsername.toLowerCase() ==
                model.get("username").toLowerCase()
              )
                return;

              // Create a new User Model for this pending identity
              const 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(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(username) {
        var username = username || this.get("username");
        let 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(" ")) {
          const 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(orcid) {
        const 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
         */
        let total = 0;
        const baseDigits = username.replace(/-/g, "").substr(0, 15);

        for (let i = 0; i < baseDigits.length; i++) {
          const digit = parseInt(baseDigits.charAt(i));
          total = (total + digit) * 2;
        }

        const remainder = total % 11;
        const result = (12 - remainder) % 11;
        const checkDigit = result == 10 ? "X" : result.toString();
        const isOrcid = checkDigit == username.charAt(username.length - 1);

        if (isOrcid) this.set("orcid", username);

        return isOrcid;
      },

      isNode() {
        const model = this;
        const node = _.find(
          MetacatUI.nodeModel.get("members"),
          (nodeModel) =>
            nodeModel.shortIdentifier.toLowerCase() ==
            model.get("username").toLowerCase(),
        );

        return node && node !== undefined;
      },

      // Will check if this user is a Member Node. If so, it will save the MN info to the model
      saveAsNode() {
        if (!this.isNode()) return;

        const model = this;
        const node = _.find(
          MetacatUI.nodeModel.get("members"),
          (nodeModel) =>
            nodeModel.shortIdentifier.toLowerCase() ==
            model.get("username").toLowerCase(),
        );

        this.set({
          type: "node",
          logo: node.logo,
          description: node.description,
          node,
          fullName: node.name,
          usernameReadable: this.get("username"),
        });
        this.updateSearchModel();
        this.set("checked", true);
      },

      loginLdap(formData, success, error) {
        if (!formData || !appModel.get("signInUrlLdap")) return false;

        const model = this;

        const requestSettings = {
          type: "POST",
          url: MetacatUI.appModel.get("signInUrlLdap") + window.location.href,
          data: formData,
          success(data, textStatus, xhr) {
            if (success) success(this);

            model.getToken();
          },
          error() {
            /* if(error)
						error(this);
					*/
            model.getToken();
          },
        };

        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      logout() {
        // Construct the sign out url and redirect
        let signOutUrl = MetacatUI.appModel.get("signOutUrl");
        let target = Backbone.history.location.href;

        // DO NOT include the route otherwise we have an infinite redirect
        // target  = target.split("#")[0];
        target = target.slice(0, -8);

        // make sure to include the target
        signOutUrl += `?target=${target}`;

        // do it!
        window.location.replace(signOutUrl);
      },

      // call Metacat or the DataONE CN to validate the session and tell us the user's name
      checkStatus(onSuccess, onError) {
        const model = this;

        if (!MetacatUI.appModel.get("tokenUrl")) {
          // look up the URL
          const metacatUrl = MetacatUI.appModel.get("metacatServiceUrl");

          // ajax call to validate the session/get the user info
          const requestSettings = {
            type: "POST",
            url: metacatUrl,
            data: { action: "validatesession" },
            success(data, textStatus, xhr) {
              // the Metacat (XML) response should have a fullName element
              const username = $(data).find("name").text();

              // set in the model
              model.set("username", username);

              // Are we logged in?
              if (username) {
                model.set("loggedIn", true);
                model.getInfo();
              } else {
                model.set("loggedIn", false);
                model.trigger("change:loggedIn");
                model.set("checked", true);
              }

              if (onSuccess) onSuccess(data);
            },
            error(data, textStatus, xhr) {
              // User is not logged in
              model.reset();

              if (onError) onError();
            },
          };

          $.ajax(
            _.extend(
              requestSettings,
              MetacatUI.appUserModel.createAjaxSettings(),
            ),
          );
        } else {
          // use the token method for checking authentication
          this.getToken();
        }
      },

      getToken(customCallback) {
        this.set("checked", false);
        this.set("tokenChecked", false);
        this.set("error", null);

        const tokenUrl = MetacatUI.appModel.get("tokenUrl");
        const model = this;

        if (!tokenUrl) return false;

        // Set up the function that will be called when we retrieve a token
        const callback =
          typeof customCallback === "function"
            ? customCallback
            : function (data, textStatus, xhr) {
                // the response should have the token
                const payload = model.parseToken(data);
                const username = payload ? payload.userId : null;
                const fullName = payload
                  ? payload.fullName
                  : model.getNameFromSubject(username) || null;
                const token = payload ? data : null;
                const loggedIn = !!payload;

                // set in the model
                model.set("fullName", fullName);
                model.set("username", username);
                model.set("token", token);
                model.set("loggedIn", loggedIn);
                model.set("tokenChecked", true);

                model.getTokenExpiration(payload);

                if (username) model.getInfo();
                else model.set("checked", true);
              };

        // ajax call to get token
        const requestSettings = {
          type: "GET",
          dataType: "text",
          xhrFields: {
            withCredentials: true,
          },
          url: tokenUrl,
          data: {},
          success: callback,
          error(xhr, textStatus, errorThrown) {
            model.set("checked", true);
            model.set("error", textStatus);
          },
        };

        $.ajax(requestSettings);
      },

      /**
       * Returns a promise that resolves with the token when it is retrieved.
       * @param {number} [timeout] - The time in milliseconds to wait for the
       * token before rejecting the promise.
       * @returns {Promise<string>} A promise that resolves with the token or
       * rejects with an error message.
       */
      getTokenPromise(timeout = 5000) {
        const model = this;
        return new Promise((resolve, reject) => {
          const listenModel = new Backbone.Model();
          const stopListenModel = () => {
            listenModel.stopListening();
            listenModel.destroy();
          };
          listenModel.listenToOnce(model, "change:error", () => {
            stopListenModel();
            reject(model.get("error"));
          });
          listenModel.listenToOnce(model, "change:tokenChecked", () => {
            stopListenModel();
            resolve(model.get("token"));
          });
          if (timeout) {
            setTimeout(() => {
              stopListenModel();
              reject(new Error("token check timed out"));
            }, timeout);
          }
          model.getToken();
        });
      },

      getTokenExpiration(payload) {
        if (!payload && this.get("token"))
          var payload = this.parseToken(this.get("token"));
        if (!payload) return;

        // The exp claim should be standard - it is in UTC seconds
        let expires = payload.exp ? new Date(payload.exp * 1000) : null;

        // Use the issuedAt and ttl as a backup (only used in d1 2.0.0 and 2.0.1)
        if (!expires) {
          const issuedAt = payload.issuedAt ? new Date(payload.issuedAt) : null;
          const lifeSpan = payload.ttl ? payload.ttl : null;

          if (issuedAt && lifeSpan && lifeSpan > 99999)
            issuedAt.setMilliseconds(lifeSpan);
          else if (issuedAt && lifeSpan) issuedAt.setSeconds(lifeSpan);

          expires = issuedAt;
        }

        this.set("expires", expires);
      },

      checkToken(onSuccess, onError) {
        // First check if the token has expired
        if (MetacatUI.appUserModel.get("expires") > new Date()) {
          if (onSuccess) onSuccess();

          return;
        }

        const model = this;

        const url = MetacatUI.appModel.get("tokenUrl");
        if (!url) return;

        const requestSettings = {
          type: "GET",
          url,
          headers: {
            "Cache-Control": "no-cache",
          },
          success(data, textStatus, xhr) {
            if (data) {
              // the response should have the token
              const payload = model.parseToken(data);
              const username = payload ? payload.userId : null;
              const fullName = payload ? payload.fullName : null;
              const token = payload ? data : null;
              const loggedIn = !!payload;

              // 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(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(token) {
        if (typeof token === "undefined") var token = this.get("token");

        const jws = new KJUR.jws.JWS();
        let result = 0;
        try {
          result = jws.parseJWS(token);
        } catch (ex) {
          result = 0;
        }

        if (!jws.parsedJWS) return "";

        const payload = $.parseJSON(jws.parsedJWS.payloadS);
        return payload;
      },

      update(onSuccess, onError) {
        const model = this;

        const person =
          `<?xml version="1.0" encoding="UTF-8"?>` +
          `<d1:person xmlns:d1="http://ns.dataone.org/service/types/v1">` +
          `<subject>${this.get("username")}</subject>` +
          `<givenName>${this.get("firstName")}</givenName>` +
          `<familyName>${this.get("lastName")}</familyName>` +
          `<email>${this.get("email")}</email>` +
          `</d1:person>`;

        const xmlBlob = new Blob([person], { type: "application/xml" });
        const formData = new FormData();
        formData.append("subject", this.get("username"));
        formData.append("person", xmlBlob, "person");

        const updateUrl =
          MetacatUI.appModel.get("accountsUrl") +
          encodeURIComponent(this.get("username"));

        // ajax call to update
        const requestSettings = {
          type: "PUT",
          cache: false,
          contentType: false,
          processData: false,
          url: updateUrl,
          data: formData,
          success(data, textStatus, xhr) {
            if (typeof onSuccess !== "undefined") onSuccess(data);

            // model.getInfo();
          },
          error(data, textStatus, xhr) {
            if (typeof onError !== "undefined") onError(data);
          },
        };

        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      confirmMapRequest(otherUsername, onSuccess, onError) {
        if (!otherUsername) return;

        const mapUrl =
          MetacatUI.appModel.get("pendingMapsUrl") +
          encodeURIComponent(otherUsername);
        const model = this;

        if (!onSuccess) var onSuccess = function () {};
        if (!onError) var onError = function () {};

        // ajax call to confirm map
        const requestSettings = {
          type: "PUT",
          url: mapUrl,
          success(data, textStatus, xhr) {
            if (onSuccess) onSuccess(data, textStatus, xhr);

            // Get updated info
            model.getInfo();
          },
          error(xhr, textStatus, error) {
            if (onError) onError(xhr, textStatus, error);
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      denyMapRequest(otherUsername, onSuccess, onError) {
        if (!otherUsername) return;

        const mapUrl =
          MetacatUI.appModel.get("pendingMapsUrl") +
          encodeURIComponent(otherUsername);
        const model = this;

        // ajax call to reject map
        const requestSettings = {
          type: "DELETE",
          url: mapUrl,
          success(data, textStatus, xhr) {
            if (typeof onSuccess === "function")
              onSuccess(data, textStatus, xhr);

            model.getInfo();
          },
          error(xhr, textStatus, error) {
            if (typeof onError === "function") onError(xhr, textStatus, error);
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      addMap(otherUsername, onSuccess, onError) {
        if (!otherUsername) return;

        let mapUrl = MetacatUI.appModel.get("pendingMapsUrl");
        const model = this;

        if (mapUrl.charAt(mapUrl.length - 1) == "/") {
          mapUrl = mapUrl.substring(0, mapUrl.length - 1);
        }

        // ajax call to map
        const requestSettings = {
          type: "POST",
          xhrFields: {
            withCredentials: true,
          },
          headers: {
            Authorization: `Bearer ${this.get("token")}`,
          },
          url: mapUrl,
          data: {
            subject: otherUsername,
          },
          success(data, textStatus, xhr) {
            if (typeof onSuccess === "function")
              onSuccess(data, textStatus, xhr);

            model.getInfo();
          },
          error(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(otherUsername, onSuccess, onError) {
        if (!otherUsername) return;

        const mapUrl =
          MetacatUI.appModel.get("accountsMapsUrl") +
          encodeURIComponent(otherUsername);
        const model = this;

        // ajax call to remove mapping
        const requestSettings = {
          type: "DELETE",
          url: mapUrl,
          success(data, textStatus, xhr) {
            if (typeof onSuccess === "function")
              onSuccess(data, textStatus, xhr);

            model.getInfo();
          },
          error(xhr, textStatus, error) {
            if (typeof onError === "function") onError(xhr, textStatus, error);
          },
        };
        $.ajax(
          _.extend(
            requestSettings,
            MetacatUI.appUserModel.createAjaxSettings(),
          ),
        );
      },

      failedLdapLogin() {
        this.set("loggedIn", false);
        this.set("checked", true);
        this.set("ldapError", true);
      },

      pluckIdentityUsernames() {
        const models = this.get("identities");
        const usernames = [];

        _.each(models, (m) => {
          usernames.push(m.get("username").toLowerCase());
        });

        this.set("identitiesUsernames", usernames);
        this.trigger("change:identitiesUsernames");
      },

      createReadableUsername() {
        if (!this.get("username")) return;

        const username = this.get("username");
        const readableUsername =
          username.substring(
            username.indexOf("=") + 1,
            username.indexOf(","),
          ) || username;

        this.set("usernameReadable", readableUsername);
      },

      createAjaxSettings() {
        if (!this.get("token")) return {};

        return {
          xhrFields: {
            withCredentials: true,
          },
          headers: {
            Authorization: `Bearer ${this.get("token")}`,
          },
        };
      },

      /**
       * Creates the settings object to be used in a fetch() call to the DataONE
       * API
       * @returns {object} The settings object to be passed to the fetch()
       * function
       */
      createFetchSettings() {
        const token = this.get("token");
        if (!token) return {};

        return {
          credentials: "include",
          headers: {
            Authorization: `Bearer ${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(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);

        // 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() {
        // 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);
        }
        // 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.
          const 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);
          }
        }
        // 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
          const quotas = this.get("dataoneQuotas");

          // If the Quotas are still being fetched,
          if (quotas == this.defaults().dataoneQuotas && !quotas) {
            this.on("change:dataoneQuotas", this.isAuthorizedCreatePortal);
            return;
          }
          const 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
            const hasRemainingUsage = _.some(
              portalQuotas,
              (quota) => 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(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() {
        // If Bookkeeper services are disabled, exit
        if (!MetacatUI.appModel.get("enableBookkeeperServices")) {
          return;
        }

        try {
          const thisUser = this;
          require([
            "collections/bookkeeper/Quotas",
            "models/bookkeeper/Subscription",
          ], (Quotas, Subscription) => {
            // Create a Quotas collection
            const quotas = new Quotas();

            // Create a Subscription model
            const 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,
              });

              // 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", () => {
                // Save a reference to the Quotas on this UserModel
                thisUser.set("dataoneQuotas", quotas);
              });

              thisUser.listenToOnce(subscription, "sync", () => {
                // 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(type) {
        const quotas = this.get("dataoneQuotas");

        if (quotas && type) {
          return quotas.where({ quotaType: type });
        }
        if (quotas && !type) {
          return quotas;
        }
        return [];
      },

      reset() {
        const defaults = _.omit(this.defaults(), [
          "searchModel",
          "searchResults",
        ]);
        this.set(defaults);
      },
    },
  );

  return UserModel;
});