chromium/chrome/browser/resources/ash/settings/os_people_page/os_sync_subpage.ts

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

/**
 * @fileoverview
 * 'os-settings-sync-subpage' is the settings page containing sync settings.
 */

import '//resources/ash/common/cr_elements/cr_button/cr_button.js';
import '//resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import '//resources/ash/common/cr_elements/cr_input/cr_input.js';
import '//resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import '//resources/ash/common/cr_elements/icons.html.js';
import '//resources/ash/common/cr_elements/cr_shared_style.css.js';
import '//resources/ash/common/cr_elements/cr_shared_vars.css.js';
import '//resources/ash/common/cr_elements/cr_expand_button/cr_expand_button.js';
import '//resources/polymer/v3_0/iron-collapse/iron-collapse.js';
import '//resources/polymer/v3_0/iron-icon/iron-icon.js';
import '//resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
// <if expr="_google_chrome">
import './os_personalization_options.js';
// </if>
import './os_sync_encryption_options.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';

import {CrInputElement} from '//resources/ash/common/cr_elements/cr_input/cr_input.js';
import {WebUiListenerMixin} from '//resources/ash/common/cr_elements/web_ui_listener_mixin.js';
import {assert, assertNotReached} from '//resources/js/assert.js';
import {IronCollapseElement} from '//resources/polymer/v3_0/iron-collapse/iron-collapse.js';
import {PolymerElement} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PageStatus, SignedInState, StatusAction, SyncBrowserProxy, SyncBrowserProxyImpl, SyncPrefs, SyncStatus} from '/shared/settings/people_page/sync_browser_proxy.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {Route, Router, routes} from '../router.js';

import {OsSettingsPersonalizationOptionsElement} from './os_personalization_options.js';
import {OsSettingsSyncEncryptionOptionsElement} from './os_sync_encryption_options.js';
import {getTemplate} from './os_sync_subpage.html.js';

export interface OsSettingsSyncSubpageElement {
  $: {
    encryptionCollapse: IronCollapseElement,
  };
}

const OsSettingsSyncSubpageElementBase =
    RouteOriginMixin(WebUiListenerMixin(I18nMixin(PolymerElement)));

export class OsSettingsSyncSubpageElement extends
    OsSettingsSyncSubpageElementBase {
  static get is() {
    return 'os-settings-sync-subpage' as const;
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      /**
       * Preferences state.
       */
      prefs: {
        type: Object,
        notify: true,
      },

      pageStatusEnum_: {
        type: Object,
        value: PageStatus,
        readOnly: true,
      },

      /**
       * The current page status. Defaults to |CONFIGURE| such that the
       * searching algorithm can search useful content when the page is not
       * visible to the user.
       */
      pageStatus_: {
        type: String,
        value: PageStatus.CONFIGURE,
      },

      /**
       * The current sync preferences, supplied by SyncBrowserProxy.
       */
      syncPrefs: Object,

      syncStatus: Object,

      dataEncrypted_: {
        type: Boolean,
        computed: 'computeDataEncrypted_(syncPrefs.encryptAllData)',
      },

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

      /** If true, override |encryptionExpanded_| to be true. */
      forceEncryptionExpanded: {
        type: Boolean,
        value: false,
      },

      /**
       * The existing passphrase input field value.
       */
      existingPassphrase_: {
        type: String,
        value: '',
      },

      /*
       * Whether enter existing passphrase UI should be shown.
       */
      showExistingPassphraseBelowAccount_: {
        type: Boolean,
        value: false,
        computed: 'computeShowExistingPassphraseBelowAccount_(' +
            'syncStatus.signedInState, syncPrefs.passphraseRequired)',
      },

      signedIn_: {
        type: Boolean,
        value: true,
        computed: 'computeSignedIn_(syncStatus.signedInState)',
      },

      syncDisabledByAdmin_: {
        type: Boolean,
        value: false,
        computed: 'computeSyncDisabledByAdmin_(syncStatus.managed)',
      },

      syncSectionDisabled_: {
        type: Boolean,
        value: false,
        computed: 'computeSyncSectionDisabled_(' +
            'syncStatus.signedInState, syncStatus.disabled, ' +
            'syncStatus.hasError, syncStatus.statusAction, ' +
            'syncPrefs.trustedVaultKeysRequired)',
      },

      enterPassphraseLabel_: {
        type: String,
        computed: 'computeEnterPassphraseLabel_(syncPrefs.encryptAllData,' +
            'syncPrefs.explicitPassphraseTime)',
      },

      existingPassphraseLabel_: {
        type: String,
        computed: 'computeExistingPassphraseLabel_(syncPrefs.encryptAllData,' +
            'syncPrefs.explicitPassphraseTime)',
      },

      /**
       * Whether to show the new UI for OS Sync Settings
       * which include sublabel and Apps toggle
       * shared between Ash and Lacros.
       */
      showSyncSettingsRevamp_: {
        type: Boolean,
        value: loadTimeData.getBoolean('showSyncSettingsRevamp'),
        readOnly: true,
      },
    };
  }

  static get observers() {
    return [
      'expandEncryptionIfNeeded_(dataEncrypted_, forceEncryptionExpanded)',
    ];
  }

  prefs: {[key: string]: any};
  private pageStatus_: PageStatus;
  syncPrefs?: SyncPrefs;
  syncStatus: SyncStatus;
  private dataEncrypted_: boolean;
  private encryptionExpanded_: boolean;
  forceEncryptionExpanded: boolean;
  private existingPassphrase_: string;
  private showSyncSettingsRevamp_: boolean;
  private signedIn_: boolean;
  private syncDisabledByAdmin_: boolean;
  private syncSectionDisabled_: boolean;

  private enterPassphraseLabel_: TrustedHTML;
  private existingPassphraseLabel_: TrustedHTML;

  private browserProxy_: SyncBrowserProxy = SyncBrowserProxyImpl.getInstance();
  private collapsibleSectionsInitialized_: boolean;
  private didAbort_: boolean;
  private setupCancelConfirmed_: boolean;
  private beforeunloadCallback_: ((e: Event) => void)|null;
  private unloadCallback_: (() => void)|null;

  constructor() {
    super();

    /** RouteOriginMixin override */
    this.route = routes.SYNC;

    /**
     * The beforeunload callback is used to show the 'Leave site' dialog. This
     * makes sure that the user has the chance to go back and confirm the sync
     * opt-in before leaving.
     *
     * This property is non-null if the user is currently navigated on the sync
     * settings route.
     */
    this.beforeunloadCallback_ = null;

    /**
     * The unload callback is used to cancel the sync setup when the user hits
     * the browser back button after arriving on the page.
     * Note = Cases like closing the tab or reloading don't need to be handled;
     * because they are already caught in |PeopleHandler::~PeopleHandler|
     * from the C++ code.
     */
    this.unloadCallback_ = null;

    /**
     * Whether the initial layout for collapsible sections has been computed. It
     * is computed only once; the first time the sync status is updated.
     */
    this.collapsibleSectionsInitialized_ = false;

    /**
     * Whether the user decided to abort sync.
     */
    this.didAbort_ = true;

    /**
     * Whether the user confirmed the cancellation of sync.
     */
    this.setupCancelConfirmed_ = false;
  }

  override connectedCallback(): void {
    super.connectedCallback();

    this.addWebUiListener(
        'page-status-changed', this.handlePageStatusChanged_.bind(this));
    this.addWebUiListener(
        'sync-prefs-changed', this.handleSyncPrefsChanged_.bind(this));

    const router = Router.getInstance();
    if (router.currentRoute === this.route) {
      this.onNavigateToPage_();
    }
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    const router = Router.getInstance();
    if (routes.SYNC.contains(router.currentRoute)) {
      this.onNavigateAwayFromPage_();
    }

    if (this.beforeunloadCallback_) {
      window.removeEventListener('beforeunload', this.beforeunloadCallback_);
      this.beforeunloadCallback_ = null;
    }
    if (this.unloadCallback_) {
      window.removeEventListener('unload', this.unloadCallback_);
      this.unloadCallback_ = null;
    }
  }

  override ready(): void {
    super.ready();

    this.addFocusConfig(routes.OS_SYNC, '#syncAdvancedRow');
  }

  getEncryptionOptions(): OsSettingsSyncEncryptionOptionsElement|null {
    return this.shadowRoot!.querySelector(
        'os-settings-sync-encryption-options');
  }

  getPersonalizationOptions(): OsSettingsPersonalizationOptionsElement|null {
    // <if expr="_google_chrome">
    return null;
    // </if>
    // <if expr="not _google_chrome">
    return this.shadowRoot!.querySelector(
        'os-settings-personalization-options');
    // </if>
  }

  private showActivityControls_(): boolean {
    // Should be hidden in OS settings.
    return false;
  }

  private computeSignedIn_(): boolean {
    return this.syncStatus.signedInState === SignedInState.SYNCING;
  }

  private computeSyncSectionDisabled_(): boolean {
    return this.syncStatus !== undefined &&
        (this.syncStatus.signedInState !== SignedInState.SYNCING ||
         !!this.syncStatus.disabled ||
         (!!this.syncStatus.hasError &&
          this.syncStatus.statusAction !== StatusAction.ENTER_PASSPHRASE &&
          this.syncStatus.statusAction !==
              StatusAction.RETRIEVE_TRUSTED_VAULT_KEYS));
  }

  private computeSyncDisabledByAdmin_(): boolean {
    return this.syncStatus !== undefined && !!this.syncStatus.managed;
  }

  override currentRouteChanged(newRoute: Route, oldRoute?: Route): void {
    super.currentRouteChanged(newRoute, oldRoute);

    if (newRoute === this.route) {
      this.onNavigateToPage_();
      return;
    }

    if (routes.SYNC.contains(newRoute)) {
      return;
    }

    const searchParams =
        Router.getInstance().getQueryParameters().get('search');
    if (searchParams) {
      // User navigated away via searching. Cancel sync without showing
      // confirmation dialog.
      this.onNavigateAwayFromPage_();
      return;
    }

    this.onNavigateAwayFromPage_();
  }

  private isStatus_(expectedPageStatus: PageStatus): boolean {
    return expectedPageStatus === this.pageStatus_;
  }

  private onNavigateToPage_(): void {
    assert(Router.getInstance().currentRoute === this.route);
    if (this.beforeunloadCallback_) {
      return;
    }

    this.collapsibleSectionsInitialized_ = false;

    // Display loading page until the settings have been retrieved.
    this.pageStatus_ = PageStatus.SPINNER;

    this.browserProxy_.didNavigateToSyncPage();

    this.beforeunloadCallback_ = event => {
      // When the user tries to leave the sync setup, show the 'Leave site'
      // dialog.
      if (this.syncStatus && this.syncStatus.firstSetupInProgress) {
        event.preventDefault();

        chrome.metricsPrivate.recordUserAction(
            'Signin_Signin_AbortAdvancedSyncSettings');
      }
    };
    window.addEventListener('beforeunload', this.beforeunloadCallback_);

    this.unloadCallback_ = this.onNavigateAwayFromPage_.bind(this);
    window.addEventListener('unload', this.unloadCallback_);
  }

  private onNavigateAwayFromPage_(): void {
    if (!this.beforeunloadCallback_) {
      return;
    }

    // Reset the status to CONFIGURE such that the searching algorithm can
    // search useful content when the page is not visible to the user.
    this.pageStatus_ = PageStatus.CONFIGURE;

    this.browserProxy_.didNavigateAwayFromSyncPage(this.didAbort_);

    window.removeEventListener('beforeunload', this.beforeunloadCallback_);
    this.beforeunloadCallback_ = null;

    if (this.unloadCallback_) {
      window.removeEventListener('unload', this.unloadCallback_);
      this.unloadCallback_ = null;
    }
  }

  /**
   * Handler for when the sync preferences are updated.
   */
  private handleSyncPrefsChanged_(syncPrefs: SyncPrefs): void {
    this.syncPrefs = syncPrefs;
    this.pageStatus_ = PageStatus.CONFIGURE;
  }

  private onManageChromeBrowserSyncClick_(): void {
    chrome.send('OpenBrowserSyncSettings');
  }

  private getManageSyncedDataSubtitle_(): string {
    if (this.showSyncSettingsRevamp_) {
      return this.i18n('manageSyncedDataSubtitle');
    }
    return '';
  }

  private getSyncAdvancedTitle_(): string {
    if (this.showSyncSettingsRevamp_) {
      return this.i18n('syncAdvancedDevicePageTitle');
    }
    return this.i18n('syncAdvancedPageTitle');
  }

  private onSyncDashboardLinkClick_(): void {
    window.open(loadTimeData.getString('syncDashboardUrl'));
  }

  private computeDataEncrypted_(): boolean {
    return !!this.syncPrefs && this.syncPrefs.encryptAllData;
  }

  private computeEnterPassphraseLabel_(): TrustedHTML {
    if (!this.syncPrefs || !this.syncPrefs.encryptAllData) {
      return window.trustedTypes!.emptyHTML;
    }

    if (!this.syncPrefs.explicitPassphraseTime) {
      // TODO(crbug.com/40765539): There's no reason why this dateless label
      // shouldn't link to 'syncErrorsHelpUrl' like the other one.
      return this.i18nAdvanced('enterPassphraseLabel');
    }

    return this.i18nAdvanced('enterPassphraseLabelWithDate', {
      tags: ['a'],
      substitutions: [
        loadTimeData.getString('syncErrorsHelpUrl'),
        this.syncPrefs.explicitPassphraseTime,
      ],
    });
  }

  private computeExistingPassphraseLabel_(): TrustedHTML {
    if (!this.syncPrefs || !this.syncPrefs.encryptAllData) {
      return window.trustedTypes!.emptyHTML;
    }

    if (!this.syncPrefs.explicitPassphraseTime) {
      return this.i18nAdvanced('existingPassphraseLabel');
    }

    return this.i18nAdvanced('existingPassphraseLabelWithDate', {
      substitutions: [this.syncPrefs.explicitPassphraseTime],
    });
  }

  /**
   * Whether the encryption dropdown should be expanded by default.
   */
  private expandEncryptionIfNeeded_(): void {
    // Force the dropdown to expand.
    if (this.forceEncryptionExpanded) {
      this.forceEncryptionExpanded = false;
      this.encryptionExpanded_ = true;
      return;
    }

    this.encryptionExpanded_ = this.dataEncrypted_;
  }

  private onResetSyncClick_(event: Event): void {
    if ((event.target as HTMLElement).tagName === 'A') {
      // Stop the propagation of events as the |cr-expand-button|
      // prevents the default which will prevent the navigation to the link.
      event.stopPropagation();
    }
  }

  /**
   * Sends the user-entered existing password to re-enable sync.
   */
  private onSubmitExistingPassphraseClick_(e: KeyboardEvent): void {
    if (e.type === 'keypress' && e.key !== 'Enter') {
      return;
    }

    this.browserProxy_.setDecryptionPassphrase(this.existingPassphrase_)
        .then(
            sucessfullySet => this.handlePageStatusChanged_(
                this.computePageStatusAfterPassphraseChange_(sucessfullySet)));

    this.existingPassphrase_ = '';
  }

  private onPassphraseChanged_(e: CustomEvent<{didChange: boolean}>): void {
    this.handlePageStatusChanged_(
        this.computePageStatusAfterPassphraseChange_(e.detail.didChange));
  }

  private computePageStatusAfterPassphraseChange_(successfullyChanged: boolean):
      PageStatus {
    if (!successfullyChanged) {
      return PageStatus.PASSPHRASE_FAILED;
    }

    // Stay on the setup page if the user hasn't approved sync settings yet.
    // Otherwise, close sync setup.
    return this.syncStatus && this.syncStatus.firstSetupInProgress ?
        PageStatus.CONFIGURE :
        PageStatus.DONE;
  }

  /**
   * Called when the page status updates.
   */
  private handlePageStatusChanged_(pageStatus: PageStatus): void {
    const router = Router.getInstance();
    switch (pageStatus) {
      case PageStatus.SPINNER:
      case PageStatus.CONFIGURE:
        this.pageStatus_ = pageStatus;
        return;
      case PageStatus.DONE:
        if (router.currentRoute === routes.SYNC) {
          router.navigateTo(routes.OS_PEOPLE);
        }
        return;
      case PageStatus.PASSPHRASE_FAILED:
        if (this.pageStatus_ === PageStatus.CONFIGURE && this.syncPrefs &&
            this.syncPrefs.passphraseRequired) {
          const passphraseInput =
              this.shadowRoot!.querySelector<CrInputElement>(
                  '#existingPassphraseInput')!;
          passphraseInput.invalid = true;
          passphraseInput.focusInput();
        }
        return;
      default:
        assertNotReached();
    }
  }

  private onLearnMoreClick_(event: Event): void {
    if ((event.target as HTMLElement).tagName === 'A') {
      // Stop the propagation of events, so that clicking on links inside
      // checkboxes or radio buttons won't change the value.
      event.stopPropagation();
    }
  }

  private computeShowExistingPassphraseBelowAccount_(): boolean {
    return this.syncStatus !== undefined &&
        this.syncStatus.signedInState === SignedInState.SYNCING &&
        this.syncPrefs !== undefined && !!this.syncPrefs.passphraseRequired;
  }

  private onSyncAdvancedClick_(): void {
    const router = Router.getInstance();
    router.navigateTo(routes.OS_SYNC);
  }

  /**
   * Focuses the passphrase input element if it is available and the page is
   * visible.
   */
  private focusPassphraseInput_(): void {
    const passphraseInput = this.shadowRoot!.querySelector<CrInputElement>(
        '#existingPassphraseInput');
    const router = Router.getInstance();
    if (passphraseInput && router.currentRoute === routes.SYNC) {
      passphraseInput.focus();
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    [OsSettingsSyncSubpageElement.is]: OsSettingsSyncSubpageElement;
  }
}

customElements.define(
    OsSettingsSyncSubpageElement.is, OsSettingsSyncSubpageElement);