chromium/ash/webui/multidevice_debug/resources/proximity_auth.js

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

/**
 * An object containing information about the Chromebook's latest Enrollment or
 * DeviceSync call to the CryptAuth server.
 * @typedef {{
 *   lastSuccessTime: number,
 *   nextRefreshTime: number,
 *   recoveringFromFailure: boolean,
 *   operationInProgress: boolean,
 * }}
 */
let SyncState;

/**
 * @typedef {{
 *   userPresent: number,
 *   secureScreenLock: number,
 *   trustAgent: number,
 *}}
 */
let RemoteState;

/**
 * An object containing data for devices returned by CryptAuth DeviceSync.
 * @typedef {{
 *   publicKey: string,
 *   publicKeyTruncated: string,
 *   friendlyDeviceName: string,
 *   noPiiName: string,
 *   unlockKey: boolean,
 *   hasMobileHotspot: boolean,
 *   connectionStatus: string,
 *   featureStates: string,
 *   remoteState: (!RemoteState|undefined),
 *   isArcPlusPlusEnrollment: (boolean|undefined),
 *   isPixelPhone: (boolean|undefined),
 *   bluetoothAddress: (string|undefined),
 * }}
 */
let RemoteDevice;

const ProximityAuth = {
  cryptauthController_: null,
  remoteDevicesController_: null,

  /**
   * Initializes all UI elements of the ProximityAuth debug page.
   */
  init: function() {
    ProximityAuth.cryptauthController_ = new CryptAuthController();
    ProximityAuth.remoteDevicesController_ = new DeviceListController(
        document.querySelector('#remote-devices-control'));
    WebUI.getLocalState();
  },
};

/**
 * Controller for the CryptAuth controls section.
 */
class CryptAuthController {
  constructor() {
    this.elements_ = {
      localDeviceId: document.querySelector('#local-device-id'),
      gcmRegistration: document.querySelector('#gcm-registration'),
      currentEid: document.querySelector('#current-eid'),
      enrollmentTitle: document.querySelector('#enrollment-title'),
      lastEnrollment: document.querySelector('#last-enrollment'),
      nextEnrollment: document.querySelector('#next-enrollment'),
      enrollmentButton: document.querySelector('#force-enrollment'),
      deviceSyncTitle: document.querySelector('#device-sync-title'),
      lastDeviceSync: document.querySelector('#last-device-sync'),
      nextDeviceSync: document.querySelector('#next-device-sync'),
      deviceSyncButton: document.querySelector('#force-device-sync'),
      newUserNotifButton: document.querySelector('#show-new-user-notif'),
      existingUserNewHostNotifButton:
          document.querySelector('#show-existing-user-new-host-notif'),
      existingUserNewChromebookNotifButton:
          document.querySelector('#show-existing-user-new-chromebook-notif'),
    };

    this.elements_.enrollmentButton.onclick = this.forceEnrollment_.bind(this);
    this.elements_.deviceSyncButton.onclick = this.forceDeviceSync_.bind(this);
    this.elements_.newUserNotifButton.onclick =
        this.showNewUserNotification_.bind(this);
    this.elements_.existingUserNewHostNotifButton.onclick =
        this.showExistingUserNewHostNotification_.bind(this);
    this.elements_.existingUserNewChromebookNotifButton.onclick =
        this.showExistingUserNewChromebookNotification_.bind(this);

    this.multiDeviceSetup =
        ash.multideviceSetup.mojom.MultiDeviceSetup.getRemote();
  }

  /**
   * Sets the local device's ID. Note that this value is truncated since the
   * full value is very long and does not cleanly fit on the screen.
   * @param {string} deviceIdTruncated
   */
  setLocalDeviceId(deviceIdTruncated) {
    this.elements_.localDeviceId.textContent = deviceIdTruncated;
  }

  /**
   * Update the enrollment state in the UI.
   * @param {!SyncState} state
   */
  updateEnrollmentState(state) {
    this.elements_.lastEnrollment.textContent =
        this.getLastSyncTimeString_(state, 'Never enrolled');
    this.elements_.nextEnrollment.textContent =
        this.getNextRefreshString_(state);

    if (state.recoveringFromFailure) {
      this.elements_.enrollmentTitle.setAttribute('state', 'failure');
    } else if (state.operationInProgress) {
      this.elements_.enrollmentTitle.setAttribute('state', 'in-progress');
    } else {
      this.elements_.enrollmentTitle.setAttribute('state', 'synced');
    }
  }

  /**
   * Updates the device sync state in the UI.
   * @param {!SyncState} state
   */
  updateDeviceSyncState(state) {
    this.elements_.lastDeviceSync.textContent =
        this.getLastSyncTimeString_(state, 'Never synced');
    this.elements_.nextDeviceSync.textContent =
        this.getNextRefreshString_(state);

    if (state.recoveringFromFailure) {
      this.elements_.deviceSyncTitle.setAttribute('state', 'failure');
    } else if (state.operationInProgress) {
      this.elements_.deviceSyncTitle.setAttribute('state', 'in-progress');
    } else {
      this.elements_.deviceSyncTitle.setAttribute('state', 'synced');
    }
  }

  /**
   * Returns the formatted string of the time of the last sync to be displayed.
   * @param {!SyncState} syncState
   * @return {string}
   */
  getLastSyncTimeString_(syncState, neverSyncedString) {
    if (syncState.lastSuccessTime == 0) {
      return neverSyncedString;
    }
    const date = new Date(syncState.lastSuccessTime);
    return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
  }

  /**
   * Returns the formatted string of the next time to refresh to be displayed.
   * @param {!SyncState} syncState
   * @return {string}
   */
  getNextRefreshString_(syncState) {
    const deltaMillis = syncState.nextRefreshTime;
    if (deltaMillis == null) {
      return 'unknown';
    }
    if (deltaMillis == 0) {
      return 'sync in progress...';
    }

    const seconds = deltaMillis / 1000;
    if (seconds < 60) {
      return Math.round(seconds) + ' seconds to refresh';
    }

    const minutes = seconds / 60;
    if (minutes < 60) {
      return Math.round(minutes) + ' minutes to refresh';
    }

    const hours = minutes / 60;
    if (hours < 24) {
      return Math.round(hours) + ' hours to refresh';
    }

    const days = hours / 24;
    return Math.round(days) + ' days to refresh';
  }

  /**
   * Forces a CryptAuth enrollment on button click.
   */
  forceEnrollment_() {
    WebUI.forceEnrollment();
  }

  /**
   * Forces a device sync on button click.
   */
  forceDeviceSync_() {
    WebUI.forceDeviceSync();
  }

  /**
   * Shows the "new user, potential host exists" notification.
   */
  showNewUserNotification_() {
    this.showMultiDeviceSetupPromoNotification_(
        ash.multideviceSetup.mojom.EventTypeForDebugging
            .kNewUserPotentialHostExists);
  }

  /**
   * Shows the "existing user, new host" notification.
   */
  showExistingUserNewHostNotification_() {
    this.showMultiDeviceSetupPromoNotification_(
        ash.multideviceSetup.mojom.EventTypeForDebugging
            .kExistingUserConnectedHostSwitched);
  }

  /**
   * Shows the "existing user, new Chromebook" notification.
   */
  showExistingUserNewChromebookNotification_() {
    this.showMultiDeviceSetupPromoNotification_(
        ash.multideviceSetup.mojom.EventTypeForDebugging
            .kExistingUserNewChromebookAdded);
  }

  /**
   * Shows a "MultiDevice Setup" notification of the given type.
   * @param {!ash.multideviceSetup.mojom.EventTypeForDebugging} type
   */
  showMultiDeviceSetupPromoNotification_(type) {
    this.multiDeviceSetup.triggerEventForDebugging(type)
        .then(function(responseParams) {
          if (responseParams.success) {
            console.log(
                'Successfully triggered notification for type ' + type + '.');
          } else {
            console.error(
                'Failed to trigger notification for type ' + type +
                '; no NotificationPresenter has been registered.');
          }
        })
        .catch(function(error) {
          console.error('Failed to trigger notification type. ' + error);
        });
  }
}

/**
 * Controller for a list of remote devices. These lists are displayed in a
 * number of locations on the debug page.
 */
class DeviceListController {
  constructor(rootElement) {
    this.rootElement_ = rootElement;
    this.remoteDeviceTemplate_ =
        document.querySelector('#remote-device-template');
  }

  /**
   * Updates the UI with the given remote devices.
   * @param {!Array<!RemoteDevice>} remoteDevices
   */
  updateRemoteDevices(remoteDevices) {
    const existingItems = this.rootElement_.querySelectorAll('.remote-device');
    for (let i = 0; i < existingItems.length; ++i) {
      existingItems[i].remove();
    }

    for (let i = 0; i < remoteDevices.length; ++i) {
      this.rootElement_.appendChild(
          this.createRemoteDeviceItem_(remoteDevices[i]));
    }

    this.rootElement_.setAttribute('device-count', remoteDevices.length);
  }

  /**
   * Creates a DOM element for a given remote device.
   * @param {!RemoteDevice} remoteDevice
   * @return {!Node}
   */
  createRemoteDeviceItem_(remoteDevice) {
    const isUnlockKey = !!remoteDevice.unlockKey;
    const hasMobileHotspot = !!remoteDevice.hasMobileHotspot;
    const isArcPlusPlusEnrollment = !!remoteDevice.isArcPlusPlusEnrollment;
    const isPixelPhone = !!remoteDevice.isPixelPhone;

    const t = this.remoteDeviceTemplate_.content;
    t.querySelector('.device-connection-status')
        .setAttribute('state', remoteDevice.connectionStatus);
    t.querySelector('.device-name').textContent =
        remoteDevice.friendlyDeviceName;
    t.querySelector('.no-pii-name').textContent = remoteDevice.noPiiName;
    t.querySelector('.device-id').textContent = remoteDevice.publicKeyTruncated;
    t.querySelector('.software-features').textContent =
        remoteDevice.featureStates;
    t.querySelector('.is-unlock-key').textContent = isUnlockKey;
    t.querySelector('.supports-mobile-hotspot').textContent = hasMobileHotspot;
    t.querySelector('.is-arc-plus-plus-enrollment').textContent =
        isArcPlusPlusEnrollment;
    t.querySelector('.is-pixel-phone').textContent = isPixelPhone;
    if (remoteDevice.bluetoothAddress) {
      t.querySelector('.bluetooth-address-row').classList.remove('hidden');
      t.querySelector('.bluetooth-address').textContent =
          remoteDevice.bluetoothAddress;
    }

    return document.importNode(this.remoteDeviceTemplate_.content, true);
  }
}

/**
 * Interface for the native WebUI to call into our JS.
 */
const LocalStateInterface = {
  /**
   * @param {string} localDeviceId
   * @param {!SyncState} enrollmentState
   * @param {!SyncState} deviceSyncState
   * @param {!Array<!RemoteDevice>} remoteDevices
   */
  onGotLocalState: function(
      localDeviceId, enrollmentState, deviceSyncState, remoteDevices) {
    LocalStateInterface.setLocalDeviceId(localDeviceId);
    LocalStateInterface.onEnrollmentStateChanged(enrollmentState);
    LocalStateInterface.onDeviceSyncStateChanged(deviceSyncState);
    LocalStateInterface.onRemoteDevicesChanged(remoteDevices);
  },

  /** @param {string} localDeviceId */
  setLocalDeviceId: function(localDeviceId) {
    ProximityAuth.cryptauthController_.setLocalDeviceId(localDeviceId);
  },

  /** @param {!SyncState} enrollmentState */
  onEnrollmentStateChanged: function(enrollmentState) {
    ProximityAuth.cryptauthController_.updateEnrollmentState(enrollmentState);
  },

  /** @param {!SyncState} deviceSyncState */
  onDeviceSyncStateChanged: function(deviceSyncState) {
    ProximityAuth.cryptauthController_.updateDeviceSyncState(deviceSyncState);
  },

  /** @param {!Array<!RemoteDevice>} remoteDevices */
  onRemoteDevicesChanged: function(remoteDevices) {
    ProximityAuth.remoteDevicesController_.updateRemoteDevices(remoteDevices);
  },
};

document.addEventListener('DOMContentLoaded', function() {
  WebUI.onWebContentsInitialized();
  Logs.init();
  ProximityAuth.init();
});