chromium/components/policy/resources/webui/policy_base.ts

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

import './strings.m.js';
import 'chrome://resources/js/action_link.js';
// <if expr="is_ios">
import 'chrome://resources/js/ios/web_ui.js';
// </if>

import './status_box.js';
import './policy_table.js';

import {addWebUiListener, sendWithPromise} from 'chrome://resources/js/cr.js';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {getRequiredElement} from 'chrome://resources/js/util.js';

import type {Policy} from './policy_row.js';
import type {PolicyTableElement, PolicyTableModel} from './policy_table.js';
import type {Status, StatusBoxElement} from './status_box.js';

export interface PolicyNamesResponse {
  [id: string]: {name: string, policyNames: NonNullable<string[]>};
}

export interface PolicyValues {
  [id: string]: {
    name: string,
    policies: {[name: string]: Policy},
    precedenceOrder?: string[],
  };
}

export interface PolicyValuesResponse {
  policyIds: string[];
  policyValues: PolicyValues;
}

// A singleton object that handles communication between browser and WebUI.
export class Page {
  mainSection: Element;
  policyTables: {[id: string]: PolicyTableElement};

  constructor() {
    this.policyTables = {};
  }

  /**
   * Main initialization function. Called by the browser on page load.
   */
  initialize() {
    // The default path is loaded when one path is not supported, so simple
    // redirect to the home path
    if (!loadTimeData.getString('acceptedPaths')
             .split('|')
             .includes(window.location.pathname)) {
      window.history.replaceState({}, '', '/');
    }

    FocusOutlineManager.forDocument(document);

    this.mainSection = getRequiredElement('main-section');

    const policyElement = getRequiredElement('policy-ui');
    // Add or remove header shadow based on scroll position.
    policyElement.addEventListener('scroll', () => {
      document.getElementsByTagName('header')[0]!.classList.toggle(
          'header-shadow', policyElement.scrollTop > 0);
    });

    // Place the initial focus on the search input field.
    const filterElement =
        getRequiredElement('search-field-input') as HTMLInputElement;
    filterElement.focus();

    filterElement.addEventListener('search', () => {
      for (const policyTable in this.policyTables) {
        this.policyTables[policyTable]!.setFilterPattern(
            filterElement.value as string);
      }
    });

    const reloadPoliciesButton =
        getRequiredElement('reload-policies') as HTMLButtonElement;
    reloadPoliciesButton.onclick = () => {
      reloadPoliciesButton!.disabled = true;
      this.createToast(loadTimeData.getString('reloadingPolicies'));
      sendWithPromise('reloadPolicies');
    };

    const moreActionsButton =
        getRequiredElement('more-actions-button') as HTMLButtonElement;
    const moreActionsIcon = getRequiredElement('dropdown-icon') as HTMLElement;
    const moreActionsList =
        getRequiredElement('more-actions-list') as HTMLElement;
    moreActionsButton.onclick = () => {
      moreActionsList!.classList.toggle('more-actions-visibility');
    };

    // Close dropdown if user clicks anywhere on page.
    document.addEventListener('click', function(event) {
      if (moreActionsList && event.target !== moreActionsButton &&
          event.target !== moreActionsIcon) {
        moreActionsList.classList.add('more-actions-visibility');
      }
    });

    const exportButton = getRequiredElement('export-policies');
    const hideExportButton = loadTimeData.valueExists('hideExportButton') &&
        loadTimeData.getBoolean('hideExportButton');
    if (hideExportButton) {
      exportButton.style.display = 'none';
    } else {
      exportButton.onclick = () => {
        sendWithPromise('exportPoliciesJSON');
      };
    }

    // <if expr="not is_chromeos">
    // Hide report button by default, will be displayed once we have policy
    // value.
    const uploadReportButton =
        getRequiredElement('upload-report') as HTMLButtonElement;
    uploadReportButton.style.display = 'none';
    uploadReportButton.onclick = () => {
      uploadReportButton.disabled = true;
      this.createToast(loadTimeData.getString('reportUploading'));
      sendWithPromise('uploadReport').then(() => {
        uploadReportButton.disabled = false;
        this.createToast(loadTimeData.getString('reportUploaded'));
      });
    };
    // </if>

    getRequiredElement('copy-policies').onclick = () => {
      sendWithPromise('copyPoliciesJSON');
      this.createToast(loadTimeData.getString('copyPoliciesDone'));
    };

    getRequiredElement('show-unset').onchange = () => {
      for (const policyTable in this.policyTables) {
        this.policyTables[policyTable]?.filter();
      }
    };

    sendWithPromise('listenPoliciesUpdates');
    addWebUiListener(
        'status-updated', (status: Status) => this.setStatus(status));
    addWebUiListener(
        'policies-updated',
        (names: PolicyNamesResponse, values: PolicyValuesResponse) =>
            this.onPoliciesReceived_(names, values));
    addWebUiListener(
        'download-json', (json: string) => this.downloadJson(json));
  }

  private onPoliciesReceived_(
      policyNames: PolicyNamesResponse,
      policyValuesResponse: PolicyValuesResponse) {
    const policyValues: PolicyValues = policyValuesResponse.policyValues;
    const policyIds: string[] = policyValuesResponse.policyIds;

    const policyGroups: Array<NonNullable<PolicyTableModel>> =
        policyIds.map((id: string) => {
          const knownPolicyNames =
              policyNames[id] ? policyNames[id]!.policyNames : [];
          const value: any = policyValues[id];
          const knownPolicyNamesSet = new Set(knownPolicyNames);
          const receivedPolicyNames =
              value.policies ? Object.keys(value.policies) : [];
          const allPolicyNames = Array.from(
              new Set([...knownPolicyNames, ...receivedPolicyNames]));
          const policies = allPolicyNames.map(
              name => Object.assign(
                  {
                    name,
                    link: [
                      policyNames['chrome']?.policyNames,
                      policyNames['precedence']?.policyNames,
                    ].includes(knownPolicyNames) &&
                            knownPolicyNamesSet.has(name) ?
                        `https://chromeenterprise.google/policies/?policy=${
                            name}` :
                        undefined,
                  },
                  value?.policies[name]));

          return {
            name: value.forSigninScreen ?
                `${value.name} [${loadTimeData.getString('signinProfile')}]` :
                value.name,
            id: value.isExtension ? id : null,
            policies,
            ...(value.precedenceOrder &&
                {precedenceOrder: value.precedenceOrder}),
          };
        });

    policyGroups.forEach(group => this.createOrUpdatePolicyTable(group));

    // <if expr="not is_chromeos">
    this.updateReportButton(
      !!policyValues['chrome']?.policies['CloudReportingEnabled']?.value ||
      !!policyValues['chrome']?.policies['CloudProfileReportingEnabled']?.value,
    );
    // </if>
    this.reloadPoliciesDone();
  }

  /**
   * Creates a toast notification with 2 second timeout at bottom of the page.
   * The notification is also announced to screen readers.
   */
  createToast(content: string): void {
    const toast = document.createElement('div');
    toast.textContent = content;
    toast.classList.add('toast');
    toast.setAttribute('role', 'alert');
    const container = getRequiredElement('toast-container');
    container.appendChild(toast);

    setTimeout(() => {
      container.removeChild(toast);
    }, 2000);
  }

  // Triggers the download of the policies as a JSON file.
  downloadJson(json: string) {
    const jsonObject = JSON.parse(json);
    const timestamp = new Date(Date.now()).toLocaleString(undefined, {
      dateStyle: 'short',
      timeStyle: 'long',
    });

    jsonObject.policyExportTime = timestamp;
    const blob = new Blob(
        [JSON.stringify(jsonObject, null, 3)], {type: 'application/json'});
    const blobUrl = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = blobUrl;
    // Regex matches GMT timezone pattern, such as "GMT+5:30"
    link.download =
        `policies_${timestamp.replace(/ GMT[+-]\d+:\d+/g, '')}.json`;

    document.body.appendChild(link);

    link.dispatchEvent(new MouseEvent(
        'click', {bubbles: true, cancelable: true, view: window}));

    document.body.removeChild(link);
    this.createToast(loadTimeData.getString('exportPoliciesDone'));
  }

  createOrUpdatePolicyTable(dataModel: PolicyTableModel) {
    const id = `${dataModel.name}-${dataModel.id}`;
    if (!this.policyTables[id]) {
      this.policyTables[id] = document.createElement('policy-table');
      this.mainSection!.appendChild(this.policyTables[id]!);
      this.policyTables[id]!.addEventListeners();
    }
    this.policyTables[id]!.updateDataModel(dataModel);
  }

  /**
   * Update the status section of the page to show the current cloud policy
   * status.
   * Status is the dictionary containing the current policy status.
   */
  setStatus(status: {[key: string]: any}) {
    // Remove any existing status boxes.
    const container = getRequiredElement('status-box-container');
    while (container.firstChild) {
      container.removeChild(container.firstChild);
    }
    // Hide the status section.
    const section = getRequiredElement('status-section');
    section!.hidden = true;

    // Add a status box for each scope that has a cloud policy status.
    for (const scope in status) {
      const boxStatus: Status = status[scope];
      if (!boxStatus.policyDescriptionKey) {
        continue;
      }
      const box = document.createElement('status-box') as StatusBoxElement;
      box.initialize(scope, boxStatus);
      container.appendChild(box);
      // Show the status section.
      section!.hidden = false;
    }
  }

  /**
   * Re-enable the reload policies button when the previous request to reload
   * policies values has completed.
   */
  reloadPoliciesDone() {
    const reloadButton =
        getRequiredElement('reload-policies') as HTMLButtonElement;
    if (reloadButton!.disabled) {
      reloadButton!.disabled = false;
      this.createToast(loadTimeData.getString('reloadPoliciesDone'));
    }
  }

  // <if expr="not is_chromeos">
  /**
   * Show report button if it's `enabled` by the policy. Exclude CrOS as there
   * are multiple report on CrOS but the button doesn't support all of them so
   * far.
   */
  updateReportButton(enabled: boolean) {
    getRequiredElement('upload-report').style.display =
        enabled ? 'block' : 'none';
  }
  // </if>

  static getInstance() {
    return instance || (instance = new Page());
  }
}

// Make Page a singleton.
let instance: Page;