chromium/chrome/browser/resources/ash/settings/os_privacy_page/os_privacy_page.ts

// 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
 * 'os-settings-privacy-page' is the settings page containing privacy and
 * security settings.
 */

import 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/polymer/v3_0/paper-spinner/paper-spinner-lite.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';
import '../os_settings_page/os_settings_animated_pages.js';
import '../os_settings_page/os_settings_subpage.js';
import '../os_settings_page/settings_card.js';
import './metrics_consent_toggle_button.js';
import './peripheral_data_access_protection_dialog.js';
import '../os_people_page/lock_screen_password_prompt_dialog.js';
import '../os_people_page/os_sync_browser_proxy.js';

import {SignedInState, SyncBrowserProxy, SyncBrowserProxyImpl, SyncStatus} from '/shared/settings/people_page/sync_browser_proxy.js';
import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {AUTH_TOKEN_INVALID_EVENT_TYPE} from 'chrome://resources/ash/common/quick_unlock/utils.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {InSessionAuth, Reason, RequestTokenReply} from 'chrome://resources/mojo/chromeos/components/in_session_auth/mojom/in_session_auth.mojom-webui.js';
import {afterNextRender, flush, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {isAccountManagerEnabled, isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {SettingsToggleButtonElement} from '../controls/settings_toggle_button.js';
import {LockStateMixin} from '../lock_state_mixin.js';
import {recordSettingChange} from '../metrics_recorder.js';
import {Section} from '../mojom-webui/routes.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';

import {getTemplate} from './os_privacy_page.html.js';
import {PeripheralDataAccessBrowserProxy, PeripheralDataAccessBrowserProxyImpl} from './peripheral_data_access_browser_proxy.js';
import {PrivacyHubNavigationOrigin} from './privacy_hub_subpage.js';

export interface OsSettingsPrivacyPageElement {
  $: {
    verifiedAccessToggle: SettingsToggleButtonElement,
  };
}

const OsSettingsPrivacyPageElementBase = PrefsMixin(
    LockStateMixin(RouteOriginMixin(DeepLinkingMixin(PolymerElement))));

export class OsSettingsPrivacyPageElement extends
    OsSettingsPrivacyPageElementBase {
  static get is() {
    return 'os-settings-privacy-page' as const;
  }

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

  static get properties() {
    return {
      section_: {
        type: Number,
        value: Section.kPrivacyAndSecurity,
        readOnly: true,
      },

      /**
       * Authentication token.
       * This is only used if `isAuthPanelInSessionEnabled_` is set to false.
       */
      authTokenInfo_: {
        type: Object,
        observer: 'onAuthTokenChanged_',
      },

      /**
       * The variable that stores the authentication token we receive
       * from AuthPanel or ActiveSessionAuth.
       * This is only used if `isAuthPanelInSessionEnabled_`
       */
      authTokenReply_: {
        type: Object,
      },

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

      /**
       * The current sync status, supplied by SyncBrowserProxy.
       */
      syncStatus: Object,

      /**
       * Used by DeepLinkingMixin to focus this page's deep links.
       */
      supportedSettingIds: {
        type: Object,
        value: () => new Set<Setting>([
          Setting.kVerifiedAccess,
        ]),
      },

      /**
       * True if fingerprint settings should be displayed on this machine.
       */
      fingerprintUnlockEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('fingerprintUnlockEnabled');
        },
        readOnly: true,
      },

      /**
       * True if auth panel will be used for authentication instead of
       * password prompt dialog.
       */
      isAuthPanelInSessionEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isAuthPanelEnabled');
        },
        readOnly: true,
      },

      /**
       * True if snooping protection or screen lock is enabled.
       */
      isSmartPrivacyEnabled_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isSnoopingProtectionEnabled') ||
              loadTimeData.getBoolean('isQuickDimEnabled');
        },
        readOnly: true,
      },

      /**
       * True if OS is running on reven board.
       */
      isRevenBranding_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isRevenBranding');
        },
        readOnly: true,
      },

      /**
       * Whether the user is in guest mode.
       */
      isGuestMode_: {
        type: Boolean,
        value() {
          return loadTimeData.getBoolean('isGuest');
        },
        readOnly: true,
      },

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

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

      dataAccessProtectionPrefName_: {
        type: String,
        value: '',
      },

      isUserConfigurable_: {
        type: Boolean,
        value: false,
        reflectToAttribute: true,
      },

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

      profileLabel_: String,

      /**
       * Whether the secure DNS setting should be displayed.
       */
      showSecureDnsSetting_: {
        type: Boolean,
        value: function() {
          return loadTimeData.getBoolean('showSecureDnsSetting');
        },
        readOnly: true,
      },

      isHatsSurveyEnabled_: {
        type: Boolean,
        value: function() {
          return loadTimeData.getBoolean('isPrivacyHubHatsEnabled');
        },
        readOnly: true,
      },

      isAccountManagerEnabled_: {
        type: Boolean,
        value() {
          return isAccountManagerEnabled();
        },
        readOnly: true,
      },

      isRevampWayfindingEnabled_: {
        type: Boolean,
        value: () => {
          return isRevampWayfindingEnabled();
        },
        readOnly: true,
      },

      /**
       * 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,
      },

      rowIcons_: {
        type: Object,
        value() {
          if (isRevampWayfindingEnabled()) {
            return {
              privacyHub: 'os-settings:privacy-controls',
              sync: 'os-settings:sync-revamp',
              lockScreen: 'os-settings:lock-revamp',
              manageOtherPeople: 'os-settings:privacy-manage-people',
              smartPrivacy: 'os-settings:privacy-smart-privacy',
              suggestedContent: 'os-settings:content-recommend',
              verifiedAccess: 'os-settings:privacy-verified-access',
              dataAccessProtection:
                  'os-settings:privacy-data-access-protection',
            };
          }

          return {
            privacyHub: '',
            sync: '',
            lockScreen: '',
            manageOtherPeople: '',
            smartPrivacy: '',
            suggestedContent: '',
            verifiedAccess: '',
            dataAccessProtection: '',
          };
        },
      },

      isAuthenticating_: {
        type: Boolean,
        value: false,
      },
    };
  }

  static get observers() {
    return ['onDataAccessFlagsSet_(isThunderboltSupported_.*)'];
  }

  syncStatus: SyncStatus;
  private authTokenInfo_: chrome.quickUnlockPrivate.TokenInfo|undefined;
  private browserProxy_: PeripheralDataAccessBrowserProxy;
  private rowIcons_: Record<string, string>;
  private authTokenReply_: RequestTokenReply|undefined|null;

  /**
   * The timeout ID to pass to clearTimeout() to cancel auth token
   * invalidation.
   */
  private clearAccountPasswordTimeoutId_: number|undefined = undefined;
  private dataAccessProtectionPrefName_: string;
  private dataAccessShiftTabPressed_: boolean;
  private fingerprintUnlockEnabled_: boolean;
  private isAccountManagerEnabled_: boolean;
  private isAuthPanelInSessionEnabled_: boolean;
  private isGuestMode_: boolean;
  private isRevampWayfindingEnabled_: boolean;
  private isRevenBranding_: boolean;
  private isSmartPrivacyEnabled_: boolean;
  private isThunderboltSupported_: boolean;
  private isUserConfigurable_: boolean;
  private profileLabel_: string;
  private section_: Section;
  private showDisableProtectionDialog_: boolean;
  private showPasswordPromptDialog_: boolean;
  private showSecureDnsSetting_: boolean;
  private showSyncSettingsRevamp_: boolean;
  private syncBrowserProxy_: SyncBrowserProxy;
  private isAuthenticating_: boolean;

  constructor() {
    super();

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

    this.browserProxy_ = PeripheralDataAccessBrowserProxyImpl.getInstance();
    this.syncBrowserProxy_ = SyncBrowserProxyImpl.getInstance();

    if (isRevampWayfindingEnabled()) {
      // When revamp wayfinding is enabled, Sync settings is moved to the
      // privacy page, hence add the Sync deep links here.
      this.supportedSettingIds.add(Setting.kNonSplitSyncEncryptionOptions);
      this.supportedSettingIds.add(Setting.kImproveSearchSuggestions);
      this.supportedSettingIds.add(Setting.kMakeSearchesAndBrowsingBetter);
      this.supportedSettingIds.add(Setting.kGoogleDriveSearchSuggestions);
    }

    this.browserProxy_.isThunderboltSupported().then(enabled => {
      this.isThunderboltSupported_ = enabled;
      if (this.isThunderboltSupported_) {
        this.supportedSettingIds.add(Setting.kPeripheralDataAccessProtection);
      }
    });
  }

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

    if (this.isRevampWayfindingEnabled_) {
      this.syncBrowserProxy_.getSyncStatus().then(
          this.handleSyncStatus_.bind(this));
      this.addWebUiListener(
          'sync-status-changed', this.handleSyncStatus_.bind(this));
    }
  }


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

    this.addEventListener(
        AUTH_TOKEN_INVALID_EVENT_TYPE, this.onAuthTokenInvalid_);

    this.addFocusConfig(routes.ACCOUNTS, '#manageOtherPeopleRow');
    this.addFocusConfig(routes.LOCK_SCREEN, '#lockScreenRow');
    if (this.isRevampWayfindingEnabled_) {
      this.addFocusConfig(routes.SYNC, '#syncSetupRow');
    }
  }

  private afterRenderShowDeepLink_(
      settingId: Setting,
      getElementCallback: () => (HTMLElement | null)): void {
    // Wait for element to load.
    afterNextRender(this, () => {
      const deepLinkElement = getElementCallback();
      if (!deepLinkElement || deepLinkElement.hidden) {
        console.warn(`Element with deep link id ${settingId} not focusable.`);
        return;
      }
      this.showDeepLinkElement(deepLinkElement);
    });
  }

  override beforeDeepLinkAttempt(settingId: Setting): boolean {
    switch (settingId) {
      // Handle the settings within the sync setup subpage since its a shared
      // component.
      case Setting.kNonSplitSyncEncryptionOptions:
        this.afterRenderShowDeepLink_(settingId, () => {
          const syncPage =
              this.shadowRoot!.querySelector('os-settings-sync-subpage');
          // Expand the encryption collapse.
          syncPage!.forceEncryptionExpanded = true;
          flush();
          return syncPage && syncPage.getEncryptionOptions() &&
              syncPage.getEncryptionOptions()!.getEncryptionsRadioButtons();
        });
        return false;

      case Setting.kImproveSearchSuggestions:
        this.afterRenderShowDeepLink_(settingId, () => {
          const syncPage =
              this.shadowRoot!.querySelector('os-settings-sync-subpage');
          return syncPage && syncPage.getPersonalizationOptions() &&
              syncPage.getPersonalizationOptions()!.getSearchSuggestToggle();
        });
        return false;

      case Setting.kMakeSearchesAndBrowsingBetter:
        this.afterRenderShowDeepLink_(settingId, () => {
          const syncPage =
              this.shadowRoot!.querySelector('os-settings-sync-subpage');
          return syncPage && syncPage.getPersonalizationOptions() &&
              syncPage.getPersonalizationOptions()!.getUrlCollectionToggle();
        });
        return false;

      case Setting.kGoogleDriveSearchSuggestions:
        this.afterRenderShowDeepLink_(settingId, () => {
          const syncPage =
              this.shadowRoot!.querySelector('os-settings-sync-subpage');
          return syncPage && syncPage.getPersonalizationOptions() &&
              syncPage.getPersonalizationOptions()!.getDriveSuggestToggle();
        });
        return false;

      default:
        // Continue with deep linking attempt.
        return true;
    }
  }

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

    // Since the sync setup subpage is a shared subpage, so we handle deep links
    // for both this page and the sync setup subpage.
    if (newRoute === routes.SYNC || newRoute === this.route) {
      this.attemptDeepLink();
    }
  }

  /**
   * Looks up the translation id, which depends on PIN login support.
   */
  private selectLockScreenTitleString_(hasPinLogin: boolean): string {
    if (hasPinLogin) {
      return this.i18n('lockScreenTitleLoginLock');
    }
    return this.i18n('lockScreenTitleLock');
  }

  private getPasswordState_(hasPin: boolean, enableScreenLock: boolean):
      string {
    if (!enableScreenLock) {
      return this.i18n('lockScreenNone');
    }
    if (hasPin) {
      return this.i18n('lockScreenPinOrPassword');
    }
    return this.i18n('lockScreenPasswordOnly');
  }

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

  private getSyncAndGoogleServicesSubtext_(): string {
    if (this.syncStatus && this.syncStatus.hasError &&
        this.syncStatus.statusText) {
      return this.syncStatus.statusText;
    }
    return '';
  }

  private async onPasswordRequested_(): Promise<void> {
    // We get called twice from `settings-lock-screen-subpage` and
    // from `settings-fingerprint-list-subpage`. Once when the current route
    // changed after entering those pages, via the `currentRouteChanged`
    // overrides, and once from `onAuthTokenChanged` listeners that listen to
    // changes in `authToken` value, and potentially request a new token.
    // Prevent double token requests.
    if (this.isAuthenticating_) {
      return;
    }

    this.isAuthenticating_ = true;

    if (!this.isAuthPanelInSessionEnabled_) {
      this.showPasswordPromptDialog_ = true;
      this.isAuthenticating_ = false;
      return;
    }

    const tokenInfo = await InSessionAuth.getRemote().requestToken(
        Reason.kAccessAuthenticationSettings,
        loadTimeData.getString('authPrompt'));

    this.isAuthenticating_ = false;

    if (!tokenInfo.reply) {
      Router.getInstance().navigateToPreviousRoute();
      return;
    }

    this.authTokenReply_ = tokenInfo.reply;
  }

  private getAuthToken_(): string|undefined {
    if (!this.isAuthPanelInSessionEnabled_) {
      return this.authTokenInfo_?.token;
    }
    return this.authTokenReply_?.token;
  }

  /**
   * Invalidate the token to trigger a password re-prompt. Used for PIN auto
   * submit when too many attempts were made when using PrefStore based PIN.
   */
  private async onInvalidateTokenRequested_(): Promise<void> {
    if (!this.isAuthPanelInSessionEnabled_) {
      this.authTokenInfo_ = undefined;
      return;
    }

    if (this.authTokenReply_) {
      const token = this.authTokenReply_.token;
      this.authTokenReply_ = undefined;
      await InSessionAuth.getRemote().invalidateToken(token);
    }
  }

  private onPasswordPromptDialogClose_(): void {
    if (this.isAuthPanelInSessionEnabled_ && !this.authTokenReply_) {
      Router.getInstance().navigateToPreviousRoute();
      return;
    }

    if (!this.isAuthPanelInSessionEnabled_) {
      this.showPasswordPromptDialog_ = false;
      this.isAuthenticating_ = false;
      if (!this.authTokenInfo_) {
        Router.getInstance().navigateToPreviousRoute();
      }
    }
  }

  private onAuthTokenObtained_(
      e: CustomEvent<chrome.quickUnlockPrivate.TokenInfo>): void {
    this.authTokenInfo_ = e.detail;
  }

  /**
   * Should request the password again to get latest token.
   */
  private onAuthTokenInvalid_(): void {
    if (this.isAuthPanelInSessionEnabled_) {
      this.authTokenReply_ = undefined;
      return;
    }
    this.authTokenInfo_ = undefined;
  }

  private onConfigureLockClick_(e: Event): void {
    // Navigating to the lock screen will always open the password prompt
    // dialog, so prevent the end of the tap event to focus what is underneath
    // it, which takes focus from the dialog.
    e.preventDefault();
    Router.getInstance().navigateTo(routes.LOCK_SCREEN);
  }

  private onManageOtherPeople_(): void {
    Router.getInstance().navigateTo(routes.ACCOUNTS);
  }

  private onSmartPrivacy_(): void {
    Router.getInstance().navigateTo(routes.SMART_PRIVACY);
  }

  /**
   * Handler for when the sync state is pushed from the browser.
   */
  private handleSyncStatus_(syncStatus: SyncStatus): void {
    this.syncStatus = syncStatus;

    // When ChromeOSAccountManager is disabled, fall back to using the sync
    // username ("[email protected]") as the profile label.
    if (!this.isAccountManagerEnabled_ && syncStatus &&
        this.syncStatus.signedInState === SignedInState.SYNCING &&
        syncStatus.signedInUsername) {
      this.profileLabel_ = syncStatus.signedInUsername;
    }
  }

  // Users can go to sync setup subpage regardless of sync status.
  private onSyncClick_(): void {
    Router.getInstance().navigateTo(routes.SYNC);
  }

  private onPrivacyHubClick_(): void {
    chrome.metricsPrivate.recordEnumerationValue(
        'ChromeOS.PrivacyHub.Opened',
        PrivacyHubNavigationOrigin.SYSTEM_SETTINGS,
        Object.keys(PrivacyHubNavigationOrigin).length);
    Router.getInstance().navigateTo(routes.PRIVACY_HUB);
  }

  private onAuthTokenChanged_(): void {
    if (this.clearAccountPasswordTimeoutId_) {
      clearTimeout(this.clearAccountPasswordTimeoutId_);
    }
    if (this.authTokenInfo_ === undefined) {
      return;
    }
    // Clear |this.authTokenInfo_| after
    // |this.authTokenInfo_.tokenInfo.lifetimeSeconds|.
    // Subtract time from the expiration time to account for IPC delays.
    // Treat values less than the minimum as 0 for testing.
    const IPC_SECONDS = 2;
    const lifetimeMs = this.authTokenInfo_.lifetimeSeconds > IPC_SECONDS ?
        (this.authTokenInfo_.lifetimeSeconds - IPC_SECONDS) * 1000 :
        0;
    this.clearAccountPasswordTimeoutId_ = setTimeout(() => {
      this.authTokenInfo_ = undefined;
    }, lifetimeMs);
  }

  private onDisableProtectionDialogClosed_(): void {
    this.showDisableProtectionDialog_ = false;
  }

  private onPeripheralProtectionClick_(): void {
    if (!this.isUserConfigurable_) {
      return;
    }

    // Do not flip the actual toggle as this will flip the underlying pref.
    // Instead if the user is attempting to disable the toggle, present the
    // warning dialog.
    if (!this.getPref(this.dataAccessProtectionPrefName_).value) {
      this.showDisableProtectionDialog_ = true;
      return;
    }

    // The underlying settings-toggle-button is disabled, therefore we will have
    // to set the pref value manually to flip the toggle.
    this.setPrefValue(this.dataAccessProtectionPrefName_, false);
  }

  private onDataAccessToggleFocus_(): void {
    if (!this.isUserConfigurable_) {
      return;
    }

    // Don't consume the shift+tab focus event here. Instead redirect it to the
    // previous element.
    if (this.dataAccessShiftTabPressed_) {
      this.dataAccessShiftTabPressed_ = false;
      this.$.verifiedAccessToggle.focus();
      return;
    }

    this.shadowRoot!
        .querySelector<SettingsToggleButtonElement>(
            '.peripheral-data-access-protection')!.focus();
  }

  /**
   * Handles keyboard events in regards to #peripheralDataAccessProtection.
   * The underlying cr-toggle is disabled so we need to handle the keyboard
   * events manually.
   */
  private onDataAccessToggleKeyPress_(event: KeyboardEvent): void {
    // Handle Shift + Tab, we don't want to redirect back to the same toggle.
    if (event.shiftKey && event.key === 'Tab') {
      this.dataAccessShiftTabPressed_ = true;
      return;
    }

    if ((event.key !== 'Enter' && event.key !== ' ') ||
        !this.isUserConfigurable_) {
      return;
    }

    event.stopPropagation();

    if (!this.getPref(this.dataAccessProtectionPrefName_).value) {
      this.showDisableProtectionDialog_ = true;
      return;
    }
    this.setPrefValue(this.dataAccessProtectionPrefName_, false);
  }

  /**
   * This is used to add a keydown listener event for handling keyboard
   * navigation inputs. We have to wait until either
   * #crosSettingDataAccessToggle or #localStateDataAccessToggle is rendered
   * before adding the observer.
   */
  private onDataAccessFlagsSet_(): void {
    if (this.isThunderboltSupported_) {
      this.browserProxy_.getPolicyState()
          .then(policy => {
            this.dataAccessProtectionPrefName_ = policy.prefName;
            this.isUserConfigurable_ = policy.isUserConfigurable;
          })
          .then(() => {
            afterNextRender(this, () => {
              this.shadowRoot!
                  .querySelector<SettingsToggleButtonElement>(
                      '.peripheral-data-access-protection')!.shadowRoot!
                  .querySelector<HTMLElement>('#control')!.addEventListener(
                      'keydown', this.onDataAccessToggleKeyPress_.bind(this));
            });
          });
    }
  }

  private onVerifiedAccessChange_(): void {
    const enabled = this.$.verifiedAccessToggle.checked;
    recordSettingChange(Setting.kVerifiedAccess, {boolValue: enabled});
  }

  /**
   * @return true if the current data access pref is from the local_state.
   */
  private isLocalStateDataAccessPref_(): boolean {
    return this.dataAccessProtectionPrefName_ ===
        'settings.local_state_device_pci_data_access_enabled';
  }

  /**
   * @return true if the current data access pref is from the CrosSetting.
   */
  private isCrosSettingDataAccessPref_(): boolean {
    return this.dataAccessProtectionPrefName_ ===
        'cros.device.peripheral_data_access_enabled';
  }
}

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

customElements.define(
    OsSettingsPrivacyPageElement.is, OsSettingsPrivacyPageElement);