blob: 0aa644c9417e1e2eddce53a9e4a3768165135ad5 [file] [log] [blame]
// 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 'password-view' is the subpage containing details about the
* password such as the URL, the username, the password and the note.
*/
import 'chrome://resources/cr_elements/cr_button/cr_button.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import '../i18n_setup.js';
// <if expr="is_chromeos">
import '../controls/password_prompt_dialog.js';
// </if>
import '../settings_shared.css.js';
import './password_edit_dialog.js';
import './password_remove_dialog.js';
import './passwords_shared.css.js';
import {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {I18nMixin, I18nMixinInterface} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {assert} from 'chrome://resources/js/assert_ts.js';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {loadTimeData} from '../i18n_setup.js';
import {routes} from '../route.js';
import {Route, RouteObserverMixin, RouteObserverMixinInterface, Router} from '../router.js';
import {SavedPasswordEditedEvent} from './password_edit_dialog.js';
import {PasswordListItemElement} from './password_list_item.js';
import {PasswordManagerImpl} from './password_manager_proxy.js';
import {PasswordRemovalMixin, PasswordRemovalMixinInterface} from './password_removal_mixin.js';
import {PasswordRemoveDialogPasswordsRemovedEvent} from './password_remove_dialog.js';
import {PasswordRequestorMixin, PasswordRequestorMixinInterface} from './password_requestor_mixin.js';
import {getTemplate} from './password_view.html.js';
declare global {
interface HTMLElementEventMap {
[PASSWORD_VIEW_PAGE_REQUESTED_EVENT_NAME]: PasswordViewPageRequestedEvent;
}
}
export type PasswordViewPageRequestedEvent =
CustomEvent<PasswordListItemElement>;
export const PASSWORD_VIEW_PAGE_REQUESTED_EVENT_NAME =
'password-view-page-requested';
export interface PasswordViewElement {
$: {
toast: CrToastElement,
};
}
const PasswordViewElementBase =
PasswordRemovalMixin(PasswordRequestorMixin(
RouteObserverMixin(I18nMixin(PolymerElement)))) as {
new (): PolymerElement & I18nMixinInterface &
RouteObserverMixinInterface & PasswordRequestorMixinInterface &
PasswordRemovalMixinInterface,
};
export enum PasswordRemovalUrlParams {
REMOVED_FROM_STORES = 'removedFromStores',
}
export enum PasswordViewPageUrlParams {
ID = 'id',
}
export const PASSWORD_MANAGER_AUTH_TIMEOUT_PARAM = 'authTimeout';
export function recordPasswordViewInteraction(
interaction: PasswordViewPageInteractions) {
chrome.metricsPrivate.recordEnumerationValue(
'PasswordManager.PasswordViewPage.UserActions', interaction,
PasswordViewPageInteractions.COUNT);
}
/**
* Should be kept in sync with
* |password_manager::metrics_util::PasswordViewPageInteractions|.
* These values are persisted to logs. Entries should not be renumbered and
* numeric values should never be reused.
*/
export enum PasswordViewPageInteractions {
CREDENTIAL_ROW_CLICKED = 0,
CREDENTIAL_FOUND = 1,
CREDENTIAL_NOT_FOUND = 2,
USERNAME_COPY_BUTTON_CLICKED = 3,
PASSWORD_COPY_BUTTON_CLICKED = 4,
PASSWORD_SHOW_BUTTON_CLICKED = 5,
PASSWORD_EDIT_BUTTON_CLICKED = 6,
PASSWORD_DELETE_BUTTON_CLICKED = 7,
CREDENTIAL_EDITED = 8,
TIMED_OUT_IN_EDIT_DIALOG = 9,
TIMED_OUT_IN_VIEW_PAGE = 10,
CREDENTIAL_REQUESTED_BY_URL = 11,
// Must be last.
COUNT = 12,
}
export class PasswordViewElement extends PasswordViewElementBase {
// TODO(crbug/1345899): Reroute to password list when the credential is
// deleted or update the page when credential is updated from other sources
// (e.g: sync).
static get is() {
return 'password-view';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
toastText_: {
type: String,
value: '',
},
credential: {
type: Object,
value: null,
notify: true,
},
isPasswordNotesEnabled_: {
type: Boolean,
value() {
return loadTimeData.getBoolean('enablePasswordNotes');
},
},
isPasswordVisible_: {
type: Boolean,
value: false,
},
showEditDialog_: {
type: Boolean,
value: false,
},
};
}
private toastText_: string;
credential: chrome.passwordsPrivate.PasswordUiEntry|null;
private isPasswordNotesEnabled_: boolean;
private isPasswordVisible_: boolean;
private showEditDialog_: boolean;
private visibilityChangedListener_: () => void;
private passwordManagerAuthTimeoutListener_: () => void;
override connectedCallback() {
super.connectedCallback();
this.passwordManagerAuthTimeoutListener_ = () => {
if (Router.getInstance().getCurrentRoute() !== routes.PASSWORD_VIEW) {
return;
}
recordPasswordViewInteraction(
this.showEditDialog_ ?
PasswordViewPageInteractions.TIMED_OUT_IN_EDIT_DIALOG :
PasswordViewPageInteractions.TIMED_OUT_IN_VIEW_PAGE);
const params = new URLSearchParams();
params.set(PASSWORD_MANAGER_AUTH_TIMEOUT_PARAM, 'true');
Router.getInstance().navigateTo(routes.PASSWORDS, params);
};
PasswordManagerImpl.getInstance().addPasswordManagerAuthTimeoutListener(
this.passwordManagerAuthTimeoutListener_);
FocusOutlineManager.forDocument(document);
}
override disconnectedCallback() {
super.disconnectedCallback();
PasswordManagerImpl.getInstance().removePasswordManagerAuthTimeoutListener(
this.passwordManagerAuthTimeoutListener_);
}
override currentRouteChanged(route: Route): void {
if (route !== routes.PASSWORD_VIEW) {
this.hideToast_();
this.credential = null;
this.isPasswordVisible_ = false;
this.showEditDialog_ = false;
return;
}
if (!this.credential) {
this.requestCredential_();
}
}
override ready() {
super.ready();
if (document.visibilityState !== 'visible') {
this.visibilityChangedListener_ = () => {
if (document.visibilityState === 'visible' &&
Router.getInstance().getCurrentRoute() === routes.PASSWORD_VIEW &&
!this.credential) {
this.requestCredential_();
document.removeEventListener(
'visibilitychange', this.visibilityChangedListener_);
}
};
document.addEventListener(
'visibilitychange', this.visibilityChangedListener_);
}
}
override onPasswordRemoveDialogPasswordsRemoved(
event: PasswordRemoveDialogPasswordsRemovedEvent) {
super.onPasswordRemoveDialogPasswordsRemoved(event);
this.rerouteAndShowRemovalNotification_(event.detail.removedFromStores);
}
private getId_() {
const idInput = Router.getInstance().getQueryParameters().get(
PasswordViewPageUrlParams.ID);
if (!idInput || Number.isNaN(Number(idInput))) {
return null;
}
return Number(idInput);
}
// This method is responsible for requesting the credential details (password,
// note). If the user does not authenticate, the page will be redirected to
// the passwords main page.
// The method also is disabled when the tab is not visible to the user (e.g: a
// background tab) so that the native authentication dialog will not be shown.
private requestCredential_() {
const credentialId = this.getId_();
if (credentialId === null || document.visibilityState !== 'visible') {
return;
}
// wrap the id in a PasswordListItemElement:
const eventDetail = {entry: {id: credentialId}} as unknown as
PasswordListItemElement;
this.dispatchEvent(
new CustomEvent(PASSWORD_VIEW_PAGE_REQUESTED_EVENT_NAME, {
bubbles: true,
composed: true,
detail: eventDetail,
}));
recordPasswordViewInteraction(
PasswordViewPageInteractions.CREDENTIAL_REQUESTED_BY_URL);
}
/** Gets the title text for the show/hide icon. */
private getPasswordButtonTitle_(): string {
assert(!this.isFederated_());
return this.i18n(this.isPasswordVisible_ ? 'hidePassword' : 'showPassword');
}
/** Get the right icon to display when hiding/showing a password. */
private getIconClass_(): string {
assert(!this.isFederated_());
return this.isPasswordVisible_ ? 'icon-visibility-off' : 'icon-visibility';
}
private getNoteClass_(): string {
return this.credential!.note ? '' : 'empty-note';
}
private getNoteValue_(): string {
return this.credential!.note || this.i18n('passwordNoNoteAdded');
}
/**
* Show the password or a placeholder with 10 characters when password is not
* set. If the credential is a federated credential, it shows the federation
* text.
*/
private getPasswordOrFederationText_(): string {
return this.credential?.password || this.credential?.federationText ||
' '.repeat(10);
}
/**
* Gets the password input's type. Should be 'text' when input content is
* visible otherwise 'password'. If the entry is a federated credential,
* the content (federation text) is always visible.
*/
private getPasswordInputType_(): string {
return this.isFederated_() || this.isPasswordVisible_ ? 'text' : 'password';
}
private isFederated_(): boolean {
return !!this.credential && !!this.credential.federationText;
}
private isNoteEnabled_(): boolean {
return !this.isFederated_() && this.isPasswordNotesEnabled_;
}
/** Handler to copy the password from the password field. */
private onCopyPasswordButtonClick_() {
assert(!this.isFederated_());
recordPasswordViewInteraction(
PasswordViewPageInteractions.PASSWORD_COPY_BUTTON_CLICKED);
this.requestPlaintextPassword(
this.credential!.id, chrome.passwordsPrivate.PlaintextReason.COPY)
.then(() => {
this.toastText_ = this.i18n('passwordCopiedToClipboard');
this.showToast_();
})
.catch(() => {});
}
/** Handler to copy the username from the username field. */
private onCopyUsernameButtonClick_() {
navigator.clipboard.writeText(this.credential!.username).then(() => {
this.toastText_ = this.i18n('passwordUsernameCopiedToClipboard');
this.showToast_();
});
recordPasswordViewInteraction(
PasswordViewPageInteractions.USERNAME_COPY_BUTTON_CLICKED);
}
/** Handler for the remove button. */
private onDeleteButtonClick_() {
assert(this.credential);
recordPasswordViewInteraction(
PasswordViewPageInteractions.PASSWORD_DELETE_BUTTON_CLICKED);
if (!this.removePassword(this.credential)) {
return;
}
this.rerouteAndShowRemovalNotification_(this.credential.storedIn);
}
/** Handler to open edit dialog for the password. */
private onEditButtonClick_() {
assert(!this.isFederated_());
recordPasswordViewInteraction(
PasswordViewPageInteractions.PASSWORD_EDIT_BUTTON_CLICKED);
this.showEditDialog_ = true;
}
private onEditDialogClosed_() {
this.showEditDialog_ = false;
}
private onSavedPasswordEdited_(event: SavedPasswordEditedEvent) {
// The dialog is recently closed. Use the new IDs to update the URL.
const newParams = Router.getInstance().getQueryParameters();
this.credential = event.detail;
newParams.set(PasswordViewPageUrlParams.ID, String(event.detail.id));
Router.getInstance().updateRouteParams(newParams);
}
/** Handler for tapping the show/hide button. */
private onShowPasswordButtonClick_() {
assert(!this.isFederated_());
if (this.isPasswordVisible_) {
this.isPasswordVisible_ = false;
return;
}
recordPasswordViewInteraction(
PasswordViewPageInteractions.PASSWORD_SHOW_BUTTON_CLICKED);
this.isPasswordVisible_ = true;
}
/** Reroutes to PASSWORDS page and shows the removal notification */
private rerouteAndShowRemovalNotification_(
removedFromStores: chrome.passwordsPrivate.PasswordStoreSet): void {
// TODO(https://crbug.com/1298027): find a way to reroute to
// DEVICE_PASSWORDS if view is opened from there.
const params = new URLSearchParams();
params.set(
PasswordRemovalUrlParams.REMOVED_FROM_STORES,
removedFromStores.toString());
Router.getInstance().navigateTo(routes.PASSWORDS, params);
}
private hideToast_() {
this.$.toast.hide();
}
private showToast_() {
this.$.toast.show();
}
}
declare global {
interface HTMLElementTagNameMap {
'password-view': PasswordViewElement;
}
}
customElements.define(PasswordViewElement.is, PasswordViewElement);