web/passwordview.mjs

/**
 * @module web/PasswordView
 */
import log from 'loglevel';

/**
 * @class This class handles the rendering of
 * and interaction with the password and stats.
 *
 * @constructor
 */
class PasswordView {
  /**
   *  set the Bootstrap classes for the various values
   */
  #stats_classes = {
    GOOD: {
      display: 'Good',
      class: 'btn-success',
    },
    OK: {
      display: 'OK',
      class: 'btn-warning',
    },
    POOR: {
      display: 'Poor',
      class: 'btn-danger',
    },
    UNKNOWN: {
      display: 'Unknown',
      class: 'btn-danger',
    },
  };

  #passwordPresentation;
  #passwordPresentationRadio;
  #passwordList;
  #passwordText;
  #passwordErrorContainer;
  #passwordStatsContainer;
  #passwordLength;
  #passwordStrength;
  #blindEntropy;
  #seenEntropy;
  #entropySuggestion;
  #numberOfPasswords;

  /**
   * Constructor
   */
  constructor() {
    this.#passwordPresentation = $('#password_presentation');
    this.#passwordPresentationRadio = $('input:radio[name=pwdPresentation]');
    this.#passwordList = $('#generated_password_lst');
    this.#passwordText = $('#generated_password_txt');
    this.#passwordErrorContainer = $('#passwordErrorContainer');
    this.#passwordStatsContainer = $('#password_stats_container');
    this.#passwordLength = $('#password_length');
    this.#passwordStrength = $('#password_strength');
    this.#blindEntropy = $('#entropy_blind');
    this.#seenEntropy = $('#entropy_seen');
    this.#entropySuggestion = $('#entropy_suggestion');
    this.#numberOfPasswords = $('#selectAmount');

    // Register for changes to the password presentation mode.
    for (const item of this.#passwordPresentationRadio) {
      try {
        item.addEventListener('change', async () => {
          this.__updatePasswordUI();
        });
      }
      catch (err) {
        log.error('Error registering for password presentation events.');
      }
    }

    this.clearPasswordArea();
  };


  /**
   * Render the password and statistics
   *
   * @param {Object} passAndStats - object with passwords and stats
   * @param {number} num - number of passwords generated
   */
  renderPassword(passAndStats, num) {
    log.trace(`renderPassword: ${JSON.stringify(passAndStats)}`);

    this.__resetPasswordUI();

    // return fast if there are no passwords
    if (!passAndStats.passwords) {
      // no passwords found
      this.__hideStats();
      this.renderPasswordError('No passwords generated');
      return;
    }

    // Populate the password list.

    let htmlPwdList = '';
    // eslint-disable-next-line guard-for-in
    for (const pwdIndex in passAndStats.passwords) {
      // Make the index a number, so we can perform math as needed.
      const theIndex = Number.parseInt(pwdIndex);

      htmlPwdList = htmlPwdList.concat(`
        <button id="copyclip_${theIndex}"
          class="list-group-item list-group-item-action px-0"
          aria-label="Copy Password #${theIndex + 1}"><i
          class="me-3 bi bi-copy"></i>
            ${passAndStats.passwords[theIndex]}
        </button>`);
    }

    this.#passwordList.append(htmlPwdList);

    // Add event handlers for the copy buttons
    // eslint-disable-next-line guard-for-in
    for (const pwdIndex in passAndStats.passwords) {
      const btn = $(`#copyclip_${pwdIndex}`);
      if (btn && (btn.length > 0)) {
        btn[0].addEventListener('click', async () => {
          await navigator.clipboard.writeText(passAndStats.passwords[pwdIndex]);

          // Manage icons
          const existingListItems = this.#passwordList.find(`button`);
          for (const item of existingListItems) {
            // Determine the correct icon for the password list items.

            if (`copyclip_${pwdIndex}` !== item.id) {
              $(item).children('i').removeClass('bi-check').addClass('bi-copy');
            }
            else {
              $(item).children('i').removeClass('bi-copy').addClass('bi-check');
            }
          }
        });
      }
    }

    // Update the text area
    this.#passwordText.val(passAndStats.passwords.join('\n'));
    // Set passwordArea height to accommodate number of passwords
    this.#passwordText.attr('rows', num);

    // Update the Password UI elements.
    this.__updatePasswordUI();

    this.__renderDetailedStats(passAndStats.stats);
  };

  /**
   * bind the Generate button to the event handler
   *
   * @param {function} handle - pass control to the Controller
   */
  bindGeneratePassword(handle) {
    log.trace('bindGeneratePassword');

    $('form#generatePasswords').on('submit', (e) => {
      e.preventDefault();
      e.stopPropagation(); // stop the event bubbling

      let num = parseInt(this.#numberOfPasswords.val());
      if (isNaN(num) || num < 1) {
        num = 1;
        this.#numberOfPasswords.val(num);
      }
      handle(num);
    });
  };

  /**
   * Clear the password area
   */
  clearPasswordArea() {
    this.__resetPasswordUI();
    this.__hideStats();
  }

  /**
   * Show an alert with an error message
   * @param {string} msg - the error message
   *
   */
  renderPasswordError(msg) {
    // write the error message to the alert and show it

    /* eslint-disable max-len */
    const alertBox = [
      `<div class="alert alert-danger d-flex align-items-center alert-dismissible fade show" role="alert">`,
      `  <span class="text-danger"><i class="bi bi-exclamation-square-fill"></i>&nbsp;</span>`,
      `  <div id="generate_password_errors">${msg}</div>`,
      `  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`,
      '</div>',
    ].join('');
    /* eslint-enable max-len */

    this.#passwordErrorContainer.append(alertBox);

    this.__hideStats();
  };

  /**
   * Reset the password UI elements.
   *
   * @private
   */
  __resetPasswordUI() {
    this.#passwordPresentation.addClass('d-none');
    this.#passwordList.html('');
    this.#passwordText.val('');
    // Make both password content elements invisible.
    this.#passwordList.addClass('d-none');
    this.#passwordText.addClass('d-none');

    // Clear out existing events.
    const existingListItems = this.#passwordList.find('button');
    for (const item of existingListItems) {
      if (item.hasOwnProperty('removeEventListener')) {
        item.removeEventListener('click');
      }
    }
  }

  /**
   * Update the password UI elements
   * based upon the password presentation mode.
   *
   * @private
   */
  __updatePasswordUI() {
    // Show the presentation selection
    this.#passwordPresentation.removeClass('d-none');

    // Get the password presentation mode.
    // eslint-disable-next-line max-len
    const pwdPresentation = $('input:radio[name=pwdPresentation]:checked').val();
    if (pwdPresentation === 'lst') {
      // Hide the text area
      this.#passwordText.addClass('d-none');
      // Show the list container
      this.#passwordList.removeClass('d-none');
    }
    else {
      // Cache the current password text.
      const currentPasswords = this.#passwordText.val();
      // Clear the passwords (to help manage focus)
      this.#passwordText.val('');
      // Put the passwords back. (focus has been reset).
      this.#passwordText.val(currentPasswords);

      // Hide the list container
      this.#passwordList.addClass('d-none');
      // Show the text area container
      this.#passwordText.removeClass('d-none');
      // Select the password text.
      this.#passwordText[0].select();
    }
  }

  /**
   * hide statistics section
   *
   * @private
   */
  __hideStats() {
    this.#passwordStatsContainer.addClass('d-none');
  };

  /**
   * show statistics section
   *
   * @private
   */
  __showStats() {
    this.#passwordStatsContainer.removeClass('d-none');
  };


  /**
   * Render the details of the statistics
   * @param {Object} stats - object holding the statistics
   *
   * @private
   */
  __renderDetailedStats(stats) {
    log.trace(`entering __renderDetailedStats`);
    log.trace(`stats: ${JSON.stringify(stats)}`);

    this.__renderPasswordLength(stats.password.minLength, stats.password.maxLength);
    this.__renderPasswordStrength(stats.password.passwordStrength);

    // Render the detailed stats

    let template;
    const min = stats.entropy.minEntropyBlind;
    const max = stats.entropy.maxEntropyBlind;

    // first the blind entropy

    /* eslint-disable max-len */

    if (min.equal) {
      // make a template for one value

      template = [
        `<span class="btn btn-stats ${this.__getStatsClass(min.state)}"`,
        `id="entropy_min">${min.value} bits</span>`,
      ].join('');
    }
    else {
      // make a template for two values
      template = [
        `&nbsp;between <span class="btn btn-stats ${this.__getStatsClass(min.state)}"`,
        `id="entropy_min">${min.value} bits</span> and `,
        `<span class="btn btn-stats ${this.__getStatsClass(max.state)}"`,
        `id="entropy_max">${max.value} bits</span>`,
      ].join('');
    }
    /* eslint-enable max-len */

    log.trace(`template built: ${template}`);

    this.#blindEntropy.empty().append(template);

    // full knowledge (seen) entropy
    for (let key in this.#stats_classes) {
      this.#seenEntropy.removeClass(this.#stats_classes[key].class);
    }

    this.#seenEntropy.html(stats.entropy.entropySeen.value + ' bits')
      .addClass(this.__getStatsClass(stats.entropy.entropySeen.state));

    const suggestion =
      // eslint-disable-next-line max-len
      `(suggest keeping blind entropy above ${stats.entropy.blindThreshold} bits ` +
      `and full knowledge above ${stats.entropy.seenThreshold} bits)`;
    this.#entropySuggestion.html(suggestion);

    this.__showStats();
  };

  /**
   * Render the password length
   *
   * @param {number} minLength - minimum length of password
   * @param {number} maxLength - maximum length of password
   *
   * @private
   */
  __renderPasswordLength(minLength, maxLength) {
    let template = "";

    if (minLength === maxLength) {
      template = minLength;
    }
    else {
      template = `Min ${minLength} Max ${maxLength}`;
    }
    this.#passwordLength.text(template);
  };


  /**
   * Render the password strength
   *
   * @param {string} passwordStrength - indication of the password strength
   *
   * @private
   */
  __renderPasswordStrength(passwordStrength) {
    // we assume that the strength indicator is already calculated

    const statsText = this.__getStatsDisplay(passwordStrength);
    const statsClass = this.__getStatsClass(passwordStrength);

    // render strength
    this.#passwordStrength.text(statsText);
    for (let key in this.#stats_classes) {
      this.#passwordStrength.removeClass(this.#stats_classes[key].class);
    }

    this.#passwordStrength.addClass(statsClass);
  };

  /**
   * Return the Bootstrap class to indicate the strength
   *
   * @private
   *
   * @param {string} strength - indication of strength
   * @return {string} - css class
   */
  __getStatsClass(strength) {
    return this.#stats_classes[strength].class;
  };

  /**
   * Return the display name to indicate the strength
   *
   * @param {string} strength - indication of strength
   * @return {string} - display name
   *
   * @private
   */
  __getStatsDisplay(strength) {
    return this.#stats_classes[strength].display;
  }
}

export {PasswordView};