blob: 432e89dd7d9ee4c17a7e62f1133d22174056701f [file] [log] [blame]
// 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
* 'site-details-permission' handles showing the state of one permission, such
* as Geolocation, for a given origin.
*/
import 'chrome://resources/cr_elements/md_select.css.js';
import 'chrome://resources/cr_elements/cr_icon/cr_icon.js';
import '../settings_shared.css.js';
import '../settings_vars.css.js';
import '../i18n_setup.js';
import './site_details_permission_device_entry.js';
import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {ListPropertyUpdateMixin} from 'chrome://resources/cr_elements/list_property_update_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {sanitizeInnerHtml} from 'chrome://resources/js/parse_html_subset.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {ChooserType, ContentSetting, ContentSettingsTypes, SiteSettingSource} from './constants.js';
import {getTemplate} from './site_details_permission.html.js';
import type {ChooserException, RawChooserException, RawSiteException} from './site_settings_browser_proxy.js';
import {SiteSettingsMixin} from './site_settings_mixin.js';
export interface SiteDetailsPermissionElement {
$: {
details: HTMLElement,
permission: HTMLSelectElement,
permissionItem: HTMLElement,
permissionSecondary: HTMLElement,
};
}
const SiteDetailsPermissionElementBase = ListPropertyUpdateMixin(
SiteSettingsMixin(WebUiListenerMixin(I18nMixin(PolymerElement))));
export class SiteDetailsPermissionElement extends
SiteDetailsPermissionElementBase {
static get is() {
return 'site-details-permission';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
/**
* If this is a sound content setting, then this controls whether it
* should use "Automatic" instead of "Allow" as the default setting
* allow label.
*/
useAutomaticLabel: {type: Boolean, value: false},
/**
* Controls whether the content setting should use "Block if site is
* unfamiliar" as the default setting label.
*/
useBlockIfUnfamiliarLabelForDefault: {type: Boolean, value: false},
/**
* The site that this widget is showing details for, or null if this
* widget should be hidden.
*/
site: Object,
/**
* The default setting for this permission category.
*/
defaultSetting_: String,
label: String,
icon: String,
/**
* Expose ContentSetting enum to HTML bindings.
*/
contentSettingEnum_: {
type: Object,
value: ContentSetting,
},
/**
* Array of chooser exceptions to display in the widget.
*/
chooserExceptions_: {
type: Array,
value() {
return [];
},
},
/**
* The chooser type that this element is displaying data for.
* See site_settings/constants.js for possible values.
*/
chooserType: {
type: String,
value: ChooserType.NONE,
},
/**
* If the permission for this category permission is blocked on the system
* level, this will be populated with the key that can be used to look up
* the warning to be shown in the UI.
*/
systemPermissionWarningKey_: {
type: String,
value: null,
observer: 'attachSystemPermissionSettingsLinkClick_',
},
};
}
static get observers() {
return [
'siteChanged_(site)',
'updateChooserExceptions_(site, chooserType)',
];
}
declare useAutomaticLabel: boolean;
declare useBlockIfUnfamiliarLabelForDefault: boolean;
declare site: RawSiteException;
declare private chooserExceptions_: ChooserException[];
declare chooserType: ChooserType;
declare private systemPermissionWarningKey_: string;
declare private defaultSetting_: ContentSetting;
declare label: string;
declare icon: string;
override connectedCallback() {
super.connectedCallback();
this.addWebUiListener(
'contentSettingCategoryChanged',
(category: ContentSettingsTypes) =>
this.onDefaultSettingChanged_(category));
this.addWebUiListener(
'contentSettingChooserPermissionChanged',
(category: ContentSettingsTypes, chooserType: ChooserType) => {
if (category === this.category && chooserType === this.chooserType) {
this.updateChooserExceptions_();
}
});
this.addWebUiListener(
'osGlobalPermissionChanged', (messages: ContentSettingsTypes[]) => {
this.setSystemPermissionWarningKey_(messages);
});
}
/**
* Update the chooser exception list for display.
*/
private updateChooserExceptions_() {
if (!this.site || this.chooserType === ChooserType.NONE) {
return;
}
// TODO(crbug.com/40887747): Use a backend handler to get chooser
// exceptions with a given origin so avoid complex logic in
// processChooserExceptions_.
this.browserProxy.getChooserExceptionList(this.chooserType)
.then(exceptionList => this.processChooserExceptions_(exceptionList));
}
private updateOsPermissionWarning_() {
this.browserProxy.getSystemDeniedPermissions().then(
(messages: ContentSettingsTypes[]) => {
this.setSystemPermissionWarningKey_(messages);
});
}
/**
* Process the chooser exception list returned from the native layer by
* keeping the exception that is relevant to |this.site| and filtering out
* sites of exception that doesn't match |this.site|.
*/
private processChooserExceptions_(exceptionList: RawChooserException[]) {
// TODO(crbug.com/40887747): Move this processing logic to the backend and
// remove this function.
const siteFilter = (site: RawSiteException) => {
// Site's origin from backend will have forward slash ending,
// hence converting it to URL and using URL.origin for
// comparison to avoid mismatch due to the slash ending.
const url = this.toUrl(site.origin);
const targetUrl = this.toUrl(this.site.origin);
if (!url || !targetUrl) {
return false;
}
return site.incognito === this.site.incognito &&
url.origin === targetUrl.origin;
};
const exceptions =
exceptionList
.filter(exception => {
// Filters out exceptions that don't have any site matching
// |this.site|.
return exception.sites.some(site => siteFilter(site));
})
.map(exception => {
// Filters out any site of |exception.sites| that doesn't match
// |this.site|.
const sites = exception.sites.filter(site => siteFilter(site))
.map(site => this.expandSiteException(site));
return Object.assign(exception, {sites});
});
this.updateList(
'chooserExceptions_', x => x.displayName, exceptions,
/*identityBasedUpdate=*/ true);
}
/**
* Updates the drop-down value after |site| has changed. If |site| is null,
* this element will hide.
* @param site The site to display.
*/
private siteChanged_(site: RawSiteException|null) {
if (!site) {
return;
}
this.updateOsPermissionWarning_();
if (site.source === SiteSettingSource.DEFAULT) {
this.defaultSetting_ = site.setting;
this.$.permission.value = ContentSetting.DEFAULT;
} else {
// The default setting is unknown, so consult the C++ backend for it.
this.updateDefaultPermission_();
this.$.permission.value = site.setting;
}
if (this.isNonDefaultAsk_(site.setting, site.source)) {
assert(
this.$.permission.value === ContentSetting.ASK,
'\'Ask\' should only show up when it\'s currently selected.');
}
}
/**
* Updates the default permission setting for this permission category.
*/
private updateDefaultPermission_() {
this.browserProxy.getDefaultValueForContentType(this.category)
.then((defaultValue) => {
this.defaultSetting_ = defaultValue.setting;
});
}
/**
* Handles the category permission changing for this origin.
* @param category The permission category that has changed default
* permission.
*/
private onDefaultSettingChanged_(category: ContentSettingsTypes) {
if (category === this.category) {
this.updateDefaultPermission_();
}
}
/**
* Handles the category permission changing for this origin.
*/
private onPermissionSelectionChange_() {
this.browserProxy.setOriginPermissions(
this.site.origin, this.category,
this.$.permission.value as ContentSetting);
}
/**
* @param category The permission type.
* @return if we should use the custom labels for the sound type.
*/
private useCustomSoundLabels_(category: ContentSettingsTypes): boolean {
return category === ContentSettingsTypes.SOUND;
}
/**
* Updates the string used for this permission category's default setting.
* @param defaultSetting Value of the default setting for this permission
* category.
* @param category The permission type.
* @param useAutomaticLabel Whether to use the automatic label if the default
* setting value is allow.
*/
private defaultSettingString_(
defaultSetting: ContentSetting, category: ContentSettingsTypes,
useAutomaticLabel: boolean, useBlockIfUnfamiliarLabel: boolean): string {
if (defaultSetting === undefined || category === undefined ||
useAutomaticLabel === undefined) {
return '';
}
if (useBlockIfUnfamiliarLabel) {
return this.i18n('siteSettingsActionBlockOnUnfamiliarSitesDefaultMenu');
}
if (defaultSetting === ContentSetting.ASK) {
return this.i18n('siteSettingsActionAskDefault');
} else if (defaultSetting === ContentSetting.ALLOW) {
if (this.useCustomSoundLabels_(category) && useAutomaticLabel) {
return this.i18n('siteSettingsActionAutomaticDefault');
}
return this.i18n('siteSettingsActionAllowDefault');
} else if (defaultSetting === ContentSetting.BLOCK) {
if (this.useCustomSoundLabels_(category)) {
return this.i18n('siteSettingsActionMuteDefault');
}
return this.i18n('siteSettingsActionBlockDefault');
}
assertNotReached(
`No string for ${this.category}'s default of ${defaultSetting}`);
}
/**
* Updates the string used for this permission category's block setting.
* @param category The permission type.
* @param blockString 'Block' label.
* @param muteString 'Mute' label.
*/
private blockSettingString_(
category: ContentSettingsTypes, blockString: string,
muteString: string): string {
if (this.useCustomSoundLabels_(category)) {
return muteString;
}
return blockString;
}
/**
* @return true if |this| should be hidden.
*/
private shouldHideCategory_() {
return !this.site;
}
/**
* Returns true if there's a string to display that provides more information
* about this permission's setting. Currently, this only gets called when
* |this.site| is updated.
* @param source The source of the permission.
* @param category The permission type.
* @param setting The permission setting.
* @return Whether the permission will have a source string to display.
*/
private hasPermissionInfoString_(
source: SiteSettingSource, category: ContentSettingsTypes,
setting: ContentSetting): boolean {
// This method assumes that an empty string will be returned for categories
// that have no permission info string.
return String(this.permissionInfoString_(
source, category, setting,
// Set all permission info string arguments as null. This is OK
// because there is no need to know what the information string
// will be, just whether there is one or not.
null, null, null, null, null, null,
// <if expr="is_win and _google_chrome">
null,
// </if>
null, null, null, null, null, null)) !== '';
}
/**
* Checks if there's a additional information to display, and returns the
* class name to apply to permissions if so.
* @param source The source of the permission.
* @param category The permission type.
* @param setting The permission setting.
* @return CSS class applied when there is an additional description string.
*/
private permissionInfoStringClass_(
source: SiteSettingSource, category: ContentSettingsTypes,
setting: ContentSetting): string {
return (this.hasPermissionInfoString_(source, category, setting) ||
this.hasSystemPermissionWarning_()) ?
'two-line' :
'';
}
/**
* @param source The source of the permission.
* @return Whether this permission can be controlled by the user.
*/
private isPermissionUserControlled_(source: SiteSettingSource): boolean {
return !(source === SiteSettingSource.ALLOWLIST ||
source === SiteSettingSource.POLICY ||
source === SiteSettingSource.EXTENSION ||
source === SiteSettingSource.KILL_SWITCH ||
source === SiteSettingSource.INSECURE_ORIGIN) &&
!this.hasSystemPermissionWarning_();
}
/**
* @param category The permission type.
* @return Whether if the 'allow' option should be shown.
*/
private showAllowedSetting_(category: ContentSettingsTypes) {
return !(
category === ContentSettingsTypes.SERIAL_PORTS ||
category === ContentSettingsTypes.USB_DEVICES ||
category === ContentSettingsTypes.BLUETOOTH_SCANNING ||
category === ContentSettingsTypes.FILE_SYSTEM_WRITE ||
category === ContentSettingsTypes.HID_DEVICES ||
category === ContentSettingsTypes.BLUETOOTH_DEVICES
// <if expr="is_chromeos">
|| category === ContentSettingsTypes.SMART_CARD_READERS
// </if>
);
}
/**
* @param category The permission type.
* @param setting The setting of the permission.
* @param source The source of the permission.
* @return Whether the 'ask' option should be shown.
*/
private showAskSetting_(
category: ContentSettingsTypes, setting: ContentSetting,
source: SiteSettingSource): boolean {
// For chooser-based permissions 'ask' takes the place of 'allow'.
if (category === ContentSettingsTypes.SERIAL_PORTS ||
category === ContentSettingsTypes.USB_DEVICES ||
category === ContentSettingsTypes.HID_DEVICES ||
category === ContentSettingsTypes.BLUETOOTH_DEVICES) {
return true;
}
// For Bluetooth scanning permission, File System write permission
// and Smart card readers permission 'ask' takes the place of 'allow'.
if (
category === ContentSettingsTypes.BLUETOOTH_SCANNING ||
category === ContentSettingsTypes.FILE_SYSTEM_WRITE
// <if expr="is_chromeos">
|| category === ContentSettingsTypes.SMART_CARD_READERS
// </if>
) {
return true;
}
return this.isNonDefaultAsk_(setting, source);
}
/**
* @param messages The message with the blocked permission types.
* @return The key to lookup the warning. Null if the warning is not to be
* shown.
*/
private setSystemPermissionWarningKey_(messages: ContentSettingsTypes[]) {
this.set(
'systemPermissionWarningKey_', ((category: ContentSettingsTypes) => {
// We return null as warningKey in case the category is not one of
// the listed, as the warning in case of an OS level block is
// supported only for camera, microphone and location permissions.
if (!messages.includes(category)) {
return null;
}
switch (category) {
case ContentSettingsTypes.CAMERA:
return 'siteSettingsCameraBlockedByOs';
case ContentSettingsTypes.MIC:
return 'siteSettingsMicrophoneBlockedByOs';
case ContentSettingsTypes.GEOLOCATION:
return 'siteSettingsLocationBlockedByOs';
default:
return null;
}
})(this.category));
}
/** Attempts to open the system permission settings. */
private onSystemPermissionSettingsLinkClick_(event: MouseEvent) {
// Prevents navigation to href='#'.
event.preventDefault();
if (this.category !== null) {
this.browserProxy.openSystemPermissionSettings(this.category);
}
}
/**
* @param category The permission type.
* @return The text of the warning. Null if the warning is not to be shown.
*/
private getSystemPermissionWarning_(): TrustedHTML {
if (this.systemPermissionWarningKey_ !== null) {
return this.i18nAdvanced(
this.systemPermissionWarningKey_, {tags: ['a'], attrs: ['id']});
}
return sanitizeInnerHtml('');
}
/** Attaches the click action to the anchor element. */
private attachSystemPermissionSettingsLinkClick_() {
const element: HTMLElement|null|undefined =
this.shadowRoot?.querySelector('#openSystemSettingsLink');
if (element !== null && element !== undefined) {
element.addEventListener('click', (me: MouseEvent) => {
this.onSystemPermissionSettingsLinkClick_(me);
});
// Set the correct aria label describing the link target.
const settingsPageName: string|null = (() => {
switch (this.category) {
case ContentSettingsTypes.CAMERA:
return 'Camera';
case ContentSettingsTypes.MIC:
return 'Microphone';
case ContentSettingsTypes.GEOLOCATION:
return 'Location';
default:
return null;
}
})();
if (settingsPageName) {
element.setAttribute(
'aria-label', `System Settings: ${settingsPageName}`);
}
}
}
/**
* @return The text of the warning. Null if the permission is not blocked by
* the OS.
*/
private hasSystemPermissionWarning_(): boolean {
return (this.systemPermissionWarningKey_ !== null);
}
/**
* @param category The permission type.
* @return The text of the warning. Null if the permission is not to be
* displayed.
*/
private showSystemPermissionWarning_(
source: SiteSettingSource, category: ContentSettingsTypes,
setting: ContentSetting): boolean {
if (this.hasPermissionInfoString_(source, category, setting)) {
return false;
}
return this.hasSystemPermissionWarning_();
}
/**
* Returns true if the permission is set to a non-default 'ask'. Currently,
* this only gets called when |this.site| is updated.
* @param setting The setting of the permission.
* @param source The source of the permission.
*/
private isNonDefaultAsk_(setting: ContentSetting, source: SiteSettingSource) {
if (setting !== ContentSetting.ASK ||
source === SiteSettingSource.DEFAULT) {
return false;
}
assert(
source === SiteSettingSource.EXTENSION ||
source === SiteSettingSource.POLICY ||
source === SiteSettingSource.PREFERENCE,
'Only extensions, enterprise policy or preferences can change ' +
'the setting to ASK.');
return true;
}
/**
* Updates the information string for the current permission.
* Currently, this only gets called when |this.site| is updated.
* @param source The source of the permission.
* @param category The permission type.
* @param setting The permission setting.
* @param allowlistString The string to show if the permission is
* allowlisted.
* @param adsBlocklistString The string to show if the site is
* blocklisted for showing bad ads.
* @param adsBlockString The string to show if ads are blocked, but
* the site is not blocklisted.
* @return The permission information string to display in the HTML.
*/
private permissionInfoString_(
source: SiteSettingSource, category: ContentSettingsTypes,
setting: ContentSetting,
allowlistString: string|null, adsBlocklistString: string|null,
adsBlockString: string|null, embargoString: string|null,
insecureOriginString: string|null, killSwitchString: string|null,
// <if expr="is_win and _google_chrome">
protectedContentIdentifierAllowedString: string|null,
// </if>
extensionAllowString: string|null, extensionBlockString: string|null,
extensionAskString: string|null, policyAllowString: string|null,
policyBlockString: string|null,
policyAskString: string|null): (TrustedHTML|null) {
if (source === undefined || category === undefined ||
setting === undefined) {
return window.trustedTypes!.emptyHTML;
}
const extensionStrings: {[key: string]: string|null} = {};
extensionStrings[ContentSetting.ALLOW] = extensionAllowString;
extensionStrings[ContentSetting.BLOCK] = extensionBlockString;
extensionStrings[ContentSetting.ASK] = extensionAskString;
const policyStrings: {[key: string]: string|null} = {};
policyStrings[ContentSetting.ALLOW] = policyAllowString;
policyStrings[ContentSetting.BLOCK] = policyBlockString;
policyStrings[ContentSetting.ASK] = policyAskString;
function htmlOrNull(str: string|null): TrustedHTML|null {
return str === null ? null : sanitizeInnerHtml(str);
}
if (source === SiteSettingSource.ALLOWLIST) {
return htmlOrNull(allowlistString);
} else if (source === SiteSettingSource.ADS_FILTER_BLACKLIST) {
assert(
ContentSettingsTypes.ADS === category,
'The ads filter blocklist only applies to Ads.');
return htmlOrNull(adsBlocklistString);
} else if (
category === ContentSettingsTypes.ADS &&
setting === ContentSetting.BLOCK) {
return htmlOrNull(adsBlockString);
} else if (source === SiteSettingSource.EMBARGO) {
assert(
ContentSetting.BLOCK === setting,
'Embargo is only used to block permissions.');
return htmlOrNull(embargoString);
} else if (source === SiteSettingSource.EXTENSION) {
return htmlOrNull(extensionStrings[setting]);
} else if (source === SiteSettingSource.INSECURE_ORIGIN) {
assert(
ContentSetting.BLOCK === setting,
'Permissions can only be blocked due to insecure origins.');
return htmlOrNull(insecureOriginString);
} else if (source === SiteSettingSource.KILL_SWITCH) {
assert(
ContentSetting.BLOCK === setting,
'The permissions kill switch can only be used to block permissions.');
return htmlOrNull(killSwitchString);
} else if (source === SiteSettingSource.POLICY) {
return htmlOrNull(policyStrings[setting]);
// <if expr="is_win and _google_chrome">
} else if (
category === ContentSettingsTypes.PROTECTED_CONTENT &&
setting === ContentSetting.ALLOW) {
return htmlOrNull(protectedContentIdentifierAllowedString);
// </if>
} else if (
source === SiteSettingSource.DEFAULT ||
source === SiteSettingSource.PREFERENCE) {
return window.trustedTypes!.emptyHTML;
}
assertNotReached(`No string for ${category} setting source '${source}'`);
}
}
declare global {
interface HTMLElementTagNameMap {
'site-details-permission': SiteDetailsPermissionElement;
}
}
customElements.define(
SiteDetailsPermissionElement.is, SiteDetailsPermissionElement);