// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview A settings subpage that allows the user to see and manage the
passkeys on their computer.
*/
import '../settings_shared.css.js';
import 'chrome://resources/cr_elements/icons.html.js';
import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/polymer/v3_0/iron-icon/iron-icon.js';
import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import '../site_favicon.js';
import '../simple_confirmation_dialog.js';
// <if expr="is_macosx">
import './passkey_edit_dialog.js';
// </if>
import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import {AnchorAlignment} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import type {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.js';
import type {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../i18n_setup.js';
// <if expr="is_macosx">
import type {PasskeyEditDialogElement, SavedPasskeyEditedEvent} from './passkey_edit_dialog.js';
// </if>
import type {Passkey, PasskeysBrowserProxy} from './passkeys_browser_proxy.js';
import {PasskeysBrowserProxyImpl} from './passkeys_browser_proxy.js';
import {getTemplate} from './passkeys_subpage.html.js';
export interface SettingsPasskeysSubpageElement {
$: {
deleteErrorDialog: CrLazyRenderElement<CrDialogElement>,
menu: CrActionMenuElement,
// <if expr="is_macosx">
editPasskeyDialog: PasskeyEditDialogElement,
// </if>
};
}
export class SettingsPasskeysSubpageElement extends PolymerElement {
static get is() {
return 'settings-passkeys-subpage';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
/** Substring to filter the passkeys by. */
filter: {
type: String,
value: '',
},
// <if expr="is_macosx">
showEditDialog_: Boolean,
username_: String,
relyingPartyId_: String,
// </if>
};
}
// <if expr="is_macosx">
private showEditDialog_: boolean;
private username_: string;
private relyingPartyId_: string;
// </if>
private filter: string;
private passkeys_: Passkey[];
private showDeleteConfirmationDialog_: boolean;
// Set if the current platform doesn't support passkey management.
// (E.g. Windows prior to 2022H2.)
private noManagement_: boolean;
// Contains the credentialId of the passkey that the action menu was opened
// for.
private credentialIdForActionMenu_: string|null;
private browserProxy_: PasskeysBrowserProxy =
PasskeysBrowserProxyImpl.getInstance();
override ready() {
super.ready();
this.browserProxy_.enumerate().then(this.onEnumerateComplete_.bind(this));
}
/**
* Used to filter the displayed passkeys when search text is entered.
*/
private filterFunction_(): ((passkey: Passkey) => boolean) {
return passkey => [passkey.relyingPartyId, passkey.userName].some(
str => str.toLowerCase().includes(
this.filter.trim().toLowerCase()));
}
/**
* Called when the browser has a new list of passkeys.
*/
private onEnumerateComplete_(passkeys: Passkey[]|null) {
if (passkeys === null) {
this.noManagement_ = true;
this.passkeys_ = [];
return;
}
// `passkeys` will have been sorted already because it's easier to do a
// Public Suffix List-based sort in C++.
this.passkeys_ = passkeys;
}
private getIconUrl_(passkey: Passkey): string {
// `passkey.relyingPartyId` comes from the OS and hopefully can be trusted,
// but don't let bad data form an unexpected URL. Thus drop any passkeys
// with characters in the RP ID that are meaningful in a host per
// https://datatracker.ietf.org/doc/html/rfc1738#section-3.1
if (['@', ':', '/'].every(c => passkey.relyingPartyId.indexOf(c) === -1)) {
return 'https://' + passkey.relyingPartyId + '/';
}
return '';
}
/**
* Called when the user clicks on the three-dots icon for a passkey.
*/
private onDotsClick_(e: Event) {
this.credentialIdForActionMenu_ =
(e.target as HTMLElement).dataset['credentialId']!;
this.$.menu.showAt(e.target as HTMLElement, {
anchorAlignmentY: AnchorAlignment.AFTER_END,
});
// <if expr="is_macosx">
const existingEntry = this.passkeys_.find(entry => {
return entry.credentialId === this.credentialIdForActionMenu_;
})!;
this.username_ = existingEntry.userName;
this.relyingPartyId_ = existingEntry.relyingPartyId;
// </if>
}
/**
* Called when the user clicks to delete a passkey.
*/
private onDeleteClick_() {
assert(this.credentialIdForActionMenu_);
this.$.menu.close();
this.showDeleteConfirmationDialog_ = true;
}
/**
* Called when a delete confirmation dialog is closed (whether successful or
* not).
*/
private onConfirmDialogClose_() {
const dialog =
this.shadowRoot!.querySelector('settings-simple-confirmation-dialog');
assert(dialog);
const confirmed = dialog.wasConfirmed();
this.showDeleteConfirmationDialog_ = false;
if (confirmed) {
assert(this.credentialIdForActionMenu_);
this.browserProxy_.delete(this.credentialIdForActionMenu_)
.then(this.onDeleteComplete_.bind(
this, this.credentialIdForActionMenu_));
}
this.credentialIdForActionMenu_ = null;
}
/**
* Called when a delete operation has completed.
*/
private onDeleteComplete_(
deletedCredentialId: string, newPasskeys: Passkey[]|null) {
if (newPasskeys !== null &&
newPasskeys.findIndex(
(cred) => cred.credentialId === deletedCredentialId) !== -1) {
// The passkey is still present thus the deletion failed.
this.$.deleteErrorDialog.get().showModal();
}
this.onEnumerateComplete_(newPasskeys);
}
/**
* Called when the user clicks the "ok" button on the error dialog.
*/
private onErrorDialogOkClick_() {
this.$.deleteErrorDialog.get().close();
}
/**
* Returns the a11y label for the "More actions" button next to a passkey.
*/
private getMoreActionsLabel_(passkey: Passkey): string {
return loadTimeData.getStringF(
'managePasskeysMoreActionsLabel', passkey.userName,
passkey.relyingPartyId);
}
// <if expr="is_macosx">
private onEditClick_() {
this.shadowRoot!.querySelector('cr-action-menu')!.close();
this.showEditDialog_ = true;
}
private onEditDialogClose_() {
this.showEditDialog_ = false;
}
/**
* Called when an edit operation has completed.
*/
private onEditComplete_(newPasskeys: Passkey[]|null) {
this.onEnumerateComplete_(newPasskeys);
}
/**
* Called when the user clicks save in the passkey edit dialog.
*/
private onSavedPasskeyEdited_(event: SavedPasskeyEditedEvent) {
assert(this.credentialIdForActionMenu_);
this.browserProxy_.edit(this.credentialIdForActionMenu_, event.detail)
.then(this.onEditComplete_.bind(this));
}
// </if>
}
declare global {
interface HTMLElementTagNameMap {
'settings-passkeys-subpage': SettingsPasskeysSubpageElement;
}
}
customElements.define(
SettingsPasskeysSubpageElement.is, SettingsPasskeysSubpageElement);