chromium/ash/webui/common/resources/network/network_property_list_mojo.js

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Polymer element for displaying a list of network properties
 * in a list. This also supports editing fields inline for fields listed in
 * editFieldTypes.
 */
import '//resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import '//resources/ash/common/cr_elements/cr_input/cr_input.js';
import '//resources/ash/common/cr_elements/cr_shared_style.css.js';
import './cr_policy_network_indicator_mojo.js';
import './network_shared.css.js';

import {assert} from '//resources/ash/common/assert.js';
import {I18nBehavior} from '//resources/ash/common/i18n_behavior.js';
import {ActivationStateType, SecurityType, SubjectAltName, VpnType} from '//resources/mojo/chromeos/services/network_config/public/mojom/cros_network_config.mojom-webui.js';
import {OncSource, PolicySource, PortalState} from '//resources/mojo/chromeos/services/network_config/public/mojom/network_types.mojom-webui.js';
import {flush, Polymer} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {CrPolicyNetworkBehaviorMojo} from './cr_policy_network_behavior_mojo.js';
import {getTemplate} from './network_property_list_mojo.html.js';
import {FAKE_CREDENTIAL, OncMojo} from './onc_mojo.js';

Polymer({
  _template: getTemplate(),
  is: 'network-property-list-mojo',

  behaviors: [I18nBehavior, CrPolicyNetworkBehaviorMojo],

  properties: {
    /**
     * The dictionary containing the properties to display.
     * @type {!Object|undefined}
     */
    propertyDict: {
      type: Object,
      observer: 'onPropertyDictChanged_',
    },

    /**
     * Fields to display.
     * @type {!Array<string>}
     */
    fields: {
      type: Array,
      value() {
        return [];
      },
    },

    /**
     * Edit type of editable fields. May contain a property for any field in
     * |fields|. Other properties will be ignored. Property values can be:
     *   'String' - A text input will be displayed.
     *   'StringArray' - A text input will be displayed that expects a comma
     *       separated list of strings.
     *   'Password' - A string with input type = password.
     * When a field changes, the 'property-change' event will be fired with
     * the field name and the new value provided in the event detail.
     * @type {!Object<string>}
     */
    editFieldTypes: {
      type: Object,
      value() {
        return {};
      },
    },

    /** Prefix used to look up property key translations. */
    prefix: {
      type: String,
      value: '',
    },

    /**
     * Whether all CrInputs are automatically read-only, and none are
     * editable by the user.
     */
    allFieldsReadOnly: {
      type: Boolean,
      value: true,
      readonly: true,
      observer: 'onAllFieldsReadOnlyChanged_',
    },

    disabled: {
      type: Boolean,
      value: false,
    },

    /**
     * Whether any of the CrInputElements have been visibly focused since
     * |allFieldsReadOnly| becoming true.
     * @private
     */
    hasAnyInputFocused_: {
      type: Boolean,
      value: false,
    },
  },

  /** @private */
  onAllFieldsReadOnlyChanged_() {
    if (this.allFieldsReadOnly) {
      return;
    }

    this.hasAnyInputFocused_ = false;

    // If this focus attempt fails (e.g. when other updates affect focus), the
    // call in onPropertyDictChanged_ will set the focus.
    setTimeout(() => {
      this.attemptToFocusFirstEditableCrInput_();
    });
  },

  /**
   * Since |this.propertyDict| may change multiple times after the
   * user |this.allFieldsReadOnly| becomes false (while editing the
   * properties of a connected network), the first CrInputElement should be
   * ready before it's focused.
   * @private
   */
  onPropertyDictChanged_() {
    // Do not proceed if the user has not opted for manual edit, or has
    // already made an edit.
    if (this.allFieldsReadOnly || this.hasAnyInputFocused_) {
      return;
    }

    this.attemptToFocusFirstEditableCrInput_();
  },

  /**
   * Attempts to focus the first non read-only CrInputElement.
   * @private
   */
  attemptToFocusFirstEditableCrInput_() {
    flush();

    const crInput = /** @type {?HTMLElement} */
        (this.shadowRoot.querySelector('cr-input:not([readonly])'));
    if (!crInput) {
      return;
    }

    // Note that |this.hasAnyInputFocused_| should not change here because a
    // CrInputElement's focus event may not properly fire before
    // |this.propertyDict| reaches steady state.
    /** @type {{focusInput: function():void}} */ (crInput).focusInput();
  },

  /**
   * Select the text contents of the input if
   * |this.allFieldsReadOnly| is true and the the CrInputElement
   * has not been focused before.
   * @param {!Event} e The input focus event.
   * @private
   */
  onInputFocused_(e) {
    if (this.allFieldsReadOnly) {
      return;
    }

    const crInput = /** @type {!HTMLElement} */ (e.target);
    // Subsequent focuses to the same CrInputElement after the first will not
    // select the entire text.
    if (crInput.getAttribute('edited') === 'true') {
      return;
    }

    // Set |edited| attribute to true so that the next time the user focuses
    // on the CrInputElement while |this.allFieldsReadOnly| is
    // still true, the entire contents are not selected.
    crInput.setAttribute('edited', true);
    crInput.select();
    this.hasAnyInputFocused_ = true;
  },

  /**
   * Event triggered when an input field changes. Fires a 'property-change'
   * event with the field (property) name set to the target id, and the value
   * set to the target input value.
   * @param {!Event} event The input change event.
   * @private
   */
  onValueChange_(event) {
    if (!this.propertyDict) {
      return;
    }
    const key = event.target.id;
    let curValue = this.getProperty_(key);
    if (typeof curValue === 'object' && !Array.isArray(curValue)) {
      // Extract the property from an ONC managed dictionary.
      curValue = OncMojo.getActiveValue(
          /** @type{!OncMojo.ManagedProperty} */ (curValue));
    }
    const newValue = this.getValueFromEditField_(key, event.target.value);
    if (newValue === curValue) {
      return;
    }
    this.fire('property-change', {field: key, value: newValue});
  },

  /**
   * Converts mojo keys to ONC keys. TODO(stevenjb): Remove this and update
   * string ids once everything is converted to mojo.
   * @param {string} key
   * @param {string=} opt_prefix
   * @return {string}
   * @private
   */
  getOncKey_(key, opt_prefix) {
    if (opt_prefix) {
      key = opt_prefix + key.charAt(0).toUpperCase() + key.slice(1);
    }
    let result = '';
    const subKeys = key.split('.');
    subKeys.forEach(subKey => {
      // Check for exceptions to CamelCase vs camelCase naming conventions.
      if (subKey === 'ipv4' || subKey === 'ipv6') {
        result += subKey;
      } else if (subKey === 'apn') {
        result += 'APN';
      } else if (subKey === 'ipAddress') {
        result += 'IPAddress';
      } else if (subKey === 'ipSec') {
        result += 'IPSec';
      } else if (subKey === 'l2tp') {
        result += 'L2TP';
      } else if (subKey === 'modelId') {
        result += 'ModelID';
      } else if (subKey === 'openVpn') {
        result += 'OpenVPN';
      } else if (subKey === 'otp') {
        result += 'OTP';
      } else if (subKey === 'ssid') {
        result += 'SSID';
      } else if (subKey === 'bssid') {
        result += 'BSSID';
      } else if (subKey === 'serverCa') {
        result += 'ServerCA';
      } else if (subKey === 'vpn') {
        result += 'VPN';
      } else if (subKey === 'wifi') {
        result += 'WiFi';
      } else if (subKey === 'iccid') {
        result += 'ICCID';
      } else if (subKey === 'imei') {
        result += 'IMEI';
      } else {
        result += subKey.charAt(0).toUpperCase() + subKey.slice(1);
      }
      result += '-';
    });
    return 'Onc' + result.slice(0, result.length - 1);
  },

  /**
   * @param {string} key The property key.
   * @return {string} The text to display for the property label.
   * @private
   */
  getPropertyLabel_(key) {
    const oncKey = this.getOncKey_(key, this.prefix);
    if (this.i18nExists(oncKey)) {
      return this.i18n(oncKey);
    }
    // We do not provide translations for every possible network property key.
    // For keys specific to a type, strip the type prefix.
    const result = this.prefix + key;
    for (const type of ['cellular', 'ethernet', 'tether', 'vpn', 'wifi']) {
      if (result.startsWith(type + '.')) {
        return result.substr(type.length + 1);
      }
    }
    return result;
  },

  /**
   * Generates a filter function dependent on propertyDict and editFieldTypes.
   * @return {!Object} A filter used by dom-repeat.
   * @private
   */
  computeFilter_() {
    return key => {
      if (this.editFieldTypes.hasOwnProperty(key)) {
        return true;
      }
      const value = this.getPropertyValue_(key);
      return value !== '';
    };
  },

  /**
   * @param {string} key The property key.
   * @return {boolean}
   * @private
   */
  isPropertyEditable_(key) {
    if (!this.propertyDict) {
      return false;
    }
    const property = this.getProperty_(key);
    if (property === undefined || property === null) {
      // Unspecified properties in policy configurations are not user
      // modifiable. https://crbug.com/819837.
      const source = this.propertyDict.source;
      return source !== OncSource.kUserPolicy &&
          source !== OncSource.kDevicePolicy;
    }
    return !this.isNetworkPolicyEnforced(property);
  },

  /**
   * @param {string} key The property key.
   * @return {boolean} True if the edit type for the key is a valid type.
   * @private
   */
  isEditType_(key) {
    const editType = this.editFieldTypes[key];
    return editType === 'String' || editType === 'StringArray' ||
        editType === 'Password';
  },

  /**
   * @param {string} key The property key.
   * @return {boolean}
   * @private
   */
  isEditable_(key) {
    return this.isEditType_(key) && this.isPropertyEditable_(key);
  },

  /**
   * @param {string} key The property key.
   * @return {boolean}
   * @private
   */
  showEditable_(key) {
    return this.isEditable_(key);
  },

  /**
   * @param {string} key The property key.
   * @return {string}
   * @private
   */
  getEditInputType_(key) {
    return this.editFieldTypes[key] === 'Password' ? 'password' : 'text';
  },

  /**
   * @param {string} key The property key.
   * @return {!OncMojo.ManagedProperty|undefined}
   * @private
   */
  getProperty_(key) {
    if (!this.propertyDict) {
      return undefined;
    }
    key = OncMojo.getManagedPropertyKey(key);
    const property = this.get(key, this.propertyDict);
    if (property === null || property === undefined) {
      return undefined;
    }
    return /** @type{!OncMojo.ManagedProperty}*/ (property);
  },

  /**
   * @param {string} key The property key.
   * @return {*} The managed property dictionary associated with |key|.
   * @private
   */
  getIndicatorProperty_(key) {
    if (!this.propertyDict) {
      return undefined;
    }
    const property = this.getProperty_(key);
    if ((property === undefined || property === null) &&
        this.propertyDict.source) {
      const policySource = OncMojo.getEnforcedPolicySourceFromOncSource(
          this.propertyDict.source);
      if (policySource !== PolicySource.kNone) {
        // If the dictionary is policy controlled, provide an empty property
        // object with the network policy source. See https://crbug.com/819837
        // for more info.
        return /** @type{!OncMojo.ManagedProperty} */ ({
          activeValue: '',
          policySource: policySource,
        });
      }
      // Otherwise just return undefined.
    }
    return property;
  },

  /**
   * @param {string} key The property key.
   * @return {string} The text to display for the property value.
   * @private
   */
  getPropertyValue_(key) {
    let value = this.getProperty_(key);
    if (value === undefined || value === null) {
      return '';
    }
    if (typeof value === 'object' && !Array.isArray(value)) {
      // Extract the property from an ONC managed dictionary
      value = OncMojo.getActiveValue(
          /** @type {!OncMojo.ManagedProperty} */ (value));
    }

    if (key === 'wifi.eap.subjectAltNameMatch') {
      return OncMojo.serializeSubjectAltNameMatch(
          /** @type {!Array<!SubjectAltName>} */ (value));
    }

    if (key === 'wifi.eap.domainSuffixMatch') {
      return OncMojo.serializeDomainSuffixMatch(
          /** @type {!Array<string>} */ (value));
    }

    if (Array.isArray(value)) {
      return value.join(', ');
    }

    const customValue = this.getCustomPropertyValue_(key, value);
    if (customValue) {
      return customValue;
    }
    if (typeof value === 'boolean') {
      return value.toString();
    }

    let valueStr;
    if (typeof value === 'number') {
      // Special case typed managed properties.
      if (key === 'cellular.activationState') {
        valueStr = OncMojo.getActivationStateTypeString(
            /** @type {!ActivationStateType}*/ (value));
      } else if (key === 'portalState') {
        valueStr = OncMojo.getPortalStateString(
            /** @type {!PortalState}*/ (value));
      } else if (key === 'vpn.type') {
        valueStr = OncMojo.getVpnTypeString(
            /** @type {!VpnType}*/ (value));
      } else if (key === 'wifi.security') {
        valueStr = OncMojo.getSecurityTypeString(
            /** @type {!SecurityType}*/ (value));
      } else {
        return value.toString();
      }
    } else {
      assert(typeof value === 'string');
      valueStr = /** @type {string} */ (value);
    }
    const oncKey = this.getOncKey_(key, this.prefix) + '_' + valueStr;
    if (this.i18nExists(oncKey)) {
      return this.i18n(oncKey);
    }
    return valueStr;
  },

  /**
   * @param {string} key The property key.
   * @return {string} CSS classes to apply to the property value container.
   * @private
   */
  getPropertyValueCssClasses_(key) {
    const classes = ['cr-secondary-text'];
    if (this.getPropertyValue_(key) === FAKE_CREDENTIAL) {
      classes.push('secure');
    }
    return classes.join(' ');
  },

  /**
   * Converts edit field values to the correct edit type.
   * @param {string} key The property key.
   * @param {*} fieldValue The value from the field.
   * @return {*}
   * @private
   */
  getValueFromEditField_(key, fieldValue) {
    const editType = this.editFieldTypes[key];
    if (editType === 'StringArray') {
      return fieldValue.toString().split(/, */);
    }
    return fieldValue;
  },

  /**
   * @param {string} key The property key.
   * @param {*} value The property value.
   * @return {string} The text to display for the property value. If the key
   *     does not correspond to a custom property, an empty string is returned.
   */
  getCustomPropertyValue_(key, value) {
    if (key === 'tether.batteryPercentage') {
      assert(typeof value === 'number');
      return this.i18n('OncTether-BatteryPercentage_Value', value.toString());
    }

    if (key === 'tether.signalStrength') {
      assert(typeof value === 'number');
      // Possible |signalStrength| values should be from 0 to 100. Add <=
      // checks for robustness.
      if (value === 0) {
        return this.i18n('OncTether-SignalStrength_None');
      }
      if (value <= 25) {
        return this.i18n('OncTether-SignalStrength_Low');
      }
      if (value <= 50) {
        return this.i18n('OncTether-SignalStrength_Medium');
      }
      return this.i18n('OncTether-SignalStrength_Strong');
    }

    if (key === 'tether.carrier') {
      assert(typeof value === 'string');
      return (!value || value === 'unknown-carrier') ?
          this.i18n('OncTether-Carrier_Unknown') :
          value;
    }

    return '';
  },
});