| // 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. |
| 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_icons.css.js'; |
| import 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js'; |
| import 'chrome://resources/cr_elements/cr_shared_style.css.js'; |
| import 'chrome://resources/cr_elements/cr_spinner_style.css.js'; |
| import 'chrome://resources/cr_elements/icons.html.js'; |
| import './shared_style.css.js'; |
| |
| import {getInstance as getAnnouncerInstance} from 'chrome://resources/cr_elements/cr_a11y_announcer/cr_a11y_announcer.js'; |
| import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js'; |
| import type {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js'; |
| import type {CrLinkRowElement} from 'chrome://resources/cr_elements/cr_link_row/cr_link_row.js'; |
| import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js'; |
| import {assert, assertNotReached} from 'chrome://resources/js/assert.js'; |
| import {focusWithoutInk} from 'chrome://resources/js/focus_without_ink.js'; |
| import {PluralStringProxyImpl} from 'chrome://resources/js/plural_string_proxy.js'; |
| import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {getTemplate} from './checkup_section.html.js'; |
| import type {FocusConfig} from './focus_config.js'; |
| import type {CredentialsChangedListener, PasswordCheckStatusChangedListener} from './password_manager_proxy.js'; |
| import {PasswordCheckInteraction, PasswordManagerImpl} from './password_manager_proxy.js'; |
| import type {Route} from './router.js'; |
| import {CheckupSubpage, Page, RouteObserverMixin, Router, UrlParam} from './router.js'; |
| |
| const CheckState = chrome.passwordsPrivate.PasswordCheckState; |
| |
| export interface CheckupSectionElement { |
| $: { |
| checkupResult: HTMLElement, |
| checkupStatusLabel: HTMLElement, |
| checkupStatusSubLabel: HTMLElement, |
| refreshButton: CrIconButtonElement, |
| retryButton: CrButtonElement, |
| spinner: HTMLElement, |
| compromisedRow: CrLinkRowElement, |
| reusedRow: CrLinkRowElement, |
| weakRow: CrLinkRowElement, |
| }; |
| } |
| |
| const CheckupSectionElementBase = RouteObserverMixin(I18nMixin(PolymerElement)); |
| |
| export class CheckupSectionElement extends CheckupSectionElementBase { |
| static get is() { |
| return 'checkup-section'; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get properties() { |
| return { |
| focusConfig: { |
| type: Object, |
| observer: 'focusConfigChanged_', |
| }, |
| |
| /** |
| * The number of checked passwords as a formatted string. |
| */ |
| checkedPasswordsText_: String, |
| |
| /** |
| * The number of compromised passwords as a formatted string. |
| */ |
| compromisedPasswordsText_: String, |
| |
| /** |
| * The number of weak passwords as a formatted string. |
| */ |
| reusedPasswordsText_: String, |
| |
| /** |
| * The number of weak passwords as a formatted string. |
| */ |
| weakPasswordsText_: String, |
| |
| /** |
| * Suggested action to take upon compromised passwords discovery. |
| */ |
| compromisedPasswordsSuggestion_: String, |
| |
| /** |
| * The status indicates progress and affects banner, title and icon. |
| */ |
| status_: { |
| type: Object, |
| observer: 'onStatusChanged_', |
| }, |
| |
| compromisedPasswords_: { |
| type: Array, |
| observer: 'onCompromisedPasswordsChanged_', |
| }, |
| |
| reusedPasswords_: { |
| type: Array, |
| observer: 'onReusedPasswordsChanged_', |
| }, |
| |
| weakPasswords_: { |
| type: Array, |
| observer: 'onWeakPasswordsChanged_', |
| }, |
| |
| isCheckRunning_: { |
| type: Boolean, |
| computed: 'computeIsCheckRunning_(status_)', |
| }, |
| |
| isCheckSuccessful_: { |
| type: Boolean, |
| computed: 'computeIsCheckSuccessful_(status_)', |
| }, |
| |
| bannerImage_: { |
| type: Array, |
| value: 'checkup_result_banner_error', |
| computed: 'computeBannerImage_(status_, compromisedPasswords_, ' + |
| 'reusedPasswords_, weakPasswords_)', |
| }, |
| |
| passwordCount_: { |
| type: Number, |
| value: 0, |
| observer: 'updateCheckedPasswordsText_', |
| }, |
| }; |
| } |
| |
| declare focusConfig: FocusConfig; |
| declare private checkedPasswordsText_: string; |
| declare private compromisedPasswordsText_: string; |
| declare private reusedPasswordsText_: string; |
| declare private weakPasswordsText_: string; |
| declare private compromisedPasswordsSuggestion_: string; |
| declare private status_: chrome.passwordsPrivate.PasswordCheckStatus; |
| declare private compromisedPasswords_: |
| chrome.passwordsPrivate.PasswordUiEntry[]; |
| declare private weakPasswords_: chrome.passwordsPrivate.PasswordUiEntry[]; |
| declare private isCheckRunning_: boolean; |
| declare private isCheckSuccessful_: boolean; |
| declare private bannerImage_: string; |
| declare private reusedPasswords_: chrome.passwordsPrivate.PasswordUiEntry[]; |
| private didCheckAutomatically_: boolean = false; |
| declare private passwordCount_: number; |
| |
| private statusChangedListener_: PasswordCheckStatusChangedListener|null = |
| null; |
| private insecureCredentialsChangedListener_: CredentialsChangedListener|null = |
| null; |
| private setSavedPasswordsListener_: CredentialsChangedListener|null = null; |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| |
| this.statusChangedListener_ = status => { |
| this.status_ = status; |
| }; |
| |
| this.insecureCredentialsChangedListener_ = insecureCredentials => { |
| this.compromisedPasswords_ = insecureCredentials.filter(cred => { |
| return !cred.compromisedInfo!.isMuted && |
| cred.compromisedInfo!.compromiseTypes.some(type => { |
| return ( |
| type === chrome.passwordsPrivate.CompromiseType.LEAKED || |
| type === chrome.passwordsPrivate.CompromiseType.PHISHED); |
| }); |
| }); |
| |
| this.reusedPasswords_ = insecureCredentials.filter(cred => { |
| return cred.compromisedInfo!.compromiseTypes.some(type => { |
| return type === chrome.passwordsPrivate.CompromiseType.REUSED; |
| }); |
| }); |
| |
| this.weakPasswords_ = insecureCredentials.filter(cred => { |
| return cred.compromisedInfo!.compromiseTypes.some(type => { |
| return type === chrome.passwordsPrivate.CompromiseType.WEAK; |
| }); |
| }); |
| }; |
| |
| this.setSavedPasswordsListener_ = passwordList => { |
| this.passwordCount_ = |
| passwordList |
| .filter(entry => !entry.federationText && !entry.isPasskey) |
| .length; |
| }; |
| |
| PasswordManagerImpl.getInstance().getPasswordCheckStatus().then( |
| this.statusChangedListener_); |
| PasswordManagerImpl.getInstance().addPasswordCheckStatusListener( |
| this.statusChangedListener_); |
| |
| PasswordManagerImpl.getInstance().getInsecureCredentials().then( |
| this.insecureCredentialsChangedListener_); |
| PasswordManagerImpl.getInstance().addInsecureCredentialsListener( |
| this.insecureCredentialsChangedListener_); |
| |
| PasswordManagerImpl.getInstance().getSavedPasswordList().then( |
| this.setSavedPasswordsListener_); |
| PasswordManagerImpl.getInstance().addSavedPasswordListChangedListener( |
| this.setSavedPasswordsListener_); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| |
| assert(this.statusChangedListener_); |
| PasswordManagerImpl.getInstance().removePasswordCheckStatusListener( |
| this.statusChangedListener_); |
| this.statusChangedListener_ = null; |
| |
| assert(this.insecureCredentialsChangedListener_); |
| PasswordManagerImpl.getInstance().removeInsecureCredentialsListener( |
| this.insecureCredentialsChangedListener_); |
| this.insecureCredentialsChangedListener_ = null; |
| |
| assert(this.setSavedPasswordsListener_); |
| PasswordManagerImpl.getInstance().removeSavedPasswordListChangedListener( |
| this.setSavedPasswordsListener_); |
| this.setSavedPasswordsListener_ = null; |
| } |
| |
| override currentRouteChanged(route: Route): void { |
| const param = route.queryParameters.get(UrlParam.START_CHECK) || ''; |
| if (param === 'true' && !this.didCheckAutomatically_) { |
| this.didCheckAutomatically_ = true; |
| PasswordManagerImpl.getInstance().startBulkPasswordCheck().catch( |
| () => {}); |
| PasswordManagerImpl.getInstance().recordPasswordCheckInteraction( |
| PasswordCheckInteraction.START_CHECK_AUTOMATICALLY); |
| } |
| if (route.page === Page.CHECKUP) { |
| PasswordManagerImpl.getInstance() |
| .dismissSafetyHubPasswordMenuNotification(); |
| } |
| } |
| |
| private async onStatusChanged_( |
| newStatus: chrome.passwordsPrivate.PasswordCheckStatus, |
| oldStatus: chrome.passwordsPrivate.PasswordCheckStatus) { |
| // if state is unchanged - nothing to do. |
| if (oldStatus !== undefined && oldStatus.state === newStatus.state) { |
| return; |
| } |
| |
| await this.updateCheckedPasswordsText_(); |
| |
| if (newStatus.state === CheckState.NO_PASSWORDS) { |
| return; |
| } |
| |
| // Announce password check result and focus retry/refresh button when |
| // password check is finished. |
| if (!!oldStatus && oldStatus.state === CheckState.RUNNING && |
| newStatus.state !== CheckState.RUNNING) { |
| let stateText: string; |
| if (this.compromisedPasswords_.length > 0) { |
| stateText = this.i18n('checkupResultRed'); |
| } else if (this.hasAnyIssues_()) { |
| stateText = this.i18n('checkupResultYellow'); |
| } else { |
| stateText = this.i18n('checkupResultGreen'); |
| } |
| getAnnouncerInstance().announce( |
| [this.checkedPasswordsText_, stateText].join('. ')); |
| focusWithoutInk( |
| this.showRetryButton_() ? this.$.retryButton : this.$.refreshButton); |
| } else if ( |
| !!oldStatus && oldStatus.state !== CheckState.RUNNING && |
| newStatus.state === CheckState.RUNNING) { |
| // Announce password checkup has started. |
| getAnnouncerInstance().announce('Password check started'); |
| } |
| } |
| |
| private async updateCheckedPasswordsText_() { |
| if (!this.status_) { |
| return; |
| } |
| |
| switch (this.status_.state) { |
| case CheckState.IDLE: |
| case CheckState.OFFLINE: |
| case CheckState.SIGNED_OUT: |
| case CheckState.QUOTA_LIMIT: |
| case CheckState.OTHER_ERROR: |
| case CheckState.NO_PASSWORDS: |
| this.checkedPasswordsText_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'checkedPasswords', this.passwordCount_); |
| return; |
| case CheckState.CANCELED: |
| this.checkedPasswordsText_ = this.i18n('checkupCanceled'); |
| return; |
| case CheckState.RUNNING: |
| this.checkedPasswordsText_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'checkingPasswords', this.status_.totalNumberOfPasswords || 0); |
| return; |
| default: |
| assertNotReached( |
| 'Can\'t find a title for state: ' + this.status_.state); |
| } |
| } |
| |
| private async onCompromisedPasswordsChanged_() { |
| this.compromisedPasswordsText_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'compromisedPasswords', this.compromisedPasswords_.length); |
| |
| this.compromisedPasswordsSuggestion_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'compromisedPasswordsTitle', this.compromisedPasswords_.length); |
| } |
| |
| private async onReusedPasswordsChanged_() { |
| this.reusedPasswordsText_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'reusedPasswords', this.reusedPasswords_.length); |
| } |
| |
| private async onWeakPasswordsChanged_() { |
| this.weakPasswordsText_ = |
| await PluralStringProxyImpl.getInstance().getPluralString( |
| 'weakPasswords', this.weakPasswords_.length); |
| } |
| |
| /** |
| * @return true iff a check is running right according to the given |status|. |
| */ |
| private computeIsCheckRunning_(): boolean { |
| return this.status_.state === CheckState.RUNNING; |
| } |
| |
| private computeIsCheckSuccessful_(): boolean { |
| return this.status_.state === CheckState.IDLE; |
| } |
| |
| private didCompromiseCheckFail_(): boolean { |
| return [ |
| CheckState.OFFLINE, |
| CheckState.SIGNED_OUT, |
| CheckState.QUOTA_LIMIT, |
| CheckState.OTHER_ERROR, |
| ].includes(this.status_.state); |
| } |
| |
| private showRetryButton_(): boolean { |
| return !this.computeIsCheckRunning_() && !this.computeIsCheckSuccessful_(); |
| } |
| |
| private showCheckButton_(): boolean { |
| return this.status_.state !== CheckState.NO_PASSWORDS; |
| } |
| |
| /** |
| * Starts/Restarts bulk password check. |
| */ |
| private onPasswordCheckButtonClick_() { |
| PasswordManagerImpl.getInstance().startBulkPasswordCheck().catch(() => {}); |
| PasswordManagerImpl.getInstance().recordPasswordCheckInteraction( |
| PasswordCheckInteraction.START_CHECK_MANUALLY); |
| } |
| |
| private computeBannerImage_(): string { |
| if (!this.status_) { |
| return 'checkup_result_banner_error'; |
| } |
| |
| if (this.computeIsCheckRunning_() || |
| this.status_.state === CheckState.NO_PASSWORDS) { |
| return 'checkup_result_banner_running'; |
| } |
| if (this.computeIsCheckSuccessful_()) { |
| return this.hasAnyIssues_() ? 'checkup_result_banner_compromised' : |
| 'checkup_result_banner_ok'; |
| } |
| return 'checkup_result_banner_error'; |
| } |
| |
| private getIcon_( |
| issues: chrome.passwordsPrivate.PasswordUiEntry[], |
| checkForError: boolean): string { |
| if (checkForError && this.status_ && this.didCompromiseCheckFail_()) { |
| return 'cr:error'; |
| } |
| return !!issues && issues.length ? 'cr:error' : 'cr:check-circle'; |
| } |
| |
| private hasAnyIssues_(): boolean { |
| if (!this.compromisedPasswords_ || !this.reusedPasswords_ || |
| !this.weakPasswords_) { |
| return false; |
| } |
| return !!this.compromisedPasswords_.length || |
| !!this.reusedPasswords_.length || !!this.weakPasswords_.length; |
| } |
| |
| private hasIssues_(issues: chrome.passwordsPrivate.PasswordUiEntry[]): |
| boolean { |
| return !!issues.length; |
| } |
| |
| private getCompromisedSectionLabel_(): string { |
| if (this.status_ && this.didCompromiseCheckFail_()) { |
| // In case of an error, don't show "No compromised passwords" title since |
| // this might be a lie. |
| return !this.compromisedPasswords_ || !this.compromisedPasswords_.length ? |
| this.i18n('compromisedRowWithError') : |
| this.compromisedPasswordsText_; |
| } |
| return this.compromisedPasswordsText_; |
| } |
| |
| private getCompromisedSectionSublabel_(): string { |
| if (!this.status_ || !this.compromisedPasswords_) { |
| return ''; |
| } |
| const brandingName = this.i18n('localPasswordManager'); |
| switch (this.status_.state) { |
| case CheckState.IDLE: |
| case CheckState.NO_PASSWORDS: |
| case CheckState.RUNNING: |
| case CheckState.CANCELED: |
| return this.compromisedPasswords_.length ? |
| this.compromisedPasswordsSuggestion_ : |
| this.i18n('compromisedPasswordsEmpty'); |
| case CheckState.OFFLINE: |
| return this.i18n('checkupErrorOffline', brandingName); |
| case CheckState.SIGNED_OUT: |
| return this.i18n('checkupErrorSignedOut', brandingName); |
| case CheckState.QUOTA_LIMIT: |
| return this.i18n('checkupErrorQuota', brandingName); |
| case CheckState.OTHER_ERROR: |
| return this.i18n('checkupErrorGeneric', brandingName); |
| default: |
| assertNotReached( |
| 'Can\'t find a title for state: ' + this.status_.state); |
| } |
| } |
| |
| private getReusedSectionSublabel_(): string { |
| return this.reusedPasswords_.length ? this.i18n('reusedPasswordsTitle') : |
| this.i18n('reusedPasswordsEmpty'); |
| } |
| |
| private getWeakSectionSublabel_(): string { |
| return this.weakPasswords_.length ? this.i18n('weakPasswordsTitle') : |
| this.i18n('weakPasswordsEmpty'); |
| } |
| |
| private onCompromisedClick_() { |
| if (!this.compromisedPasswords_.length) { |
| return; |
| } |
| |
| Router.getInstance().navigateTo( |
| Page.CHECKUP_DETAILS, CheckupSubpage.COMPROMISED); |
| } |
| |
| private onReusedClick_() { |
| if (!this.reusedPasswords_.length) { |
| return; |
| } |
| |
| Router.getInstance().navigateTo( |
| Page.CHECKUP_DETAILS, CheckupSubpage.REUSED); |
| } |
| |
| private onWeakClick_() { |
| if (!this.weakPasswords_.length) { |
| return; |
| } |
| |
| Router.getInstance().navigateTo(Page.CHECKUP_DETAILS, CheckupSubpage.WEAK); |
| } |
| |
| private showCheckupSublabel_(): boolean { |
| return this.computeIsCheckRunning_(); |
| } |
| |
| private getCheckupSublabelValue_(): string { |
| assert(this.status_); |
| if (!this.computeIsCheckRunning_()) { |
| return this.status_.state === CheckState.NO_PASSWORDS ? |
| this.i18n( |
| 'checkupErrorNoPasswords', this.i18n('localPasswordManager')) : |
| this.status_.elapsedTimeSinceLastCheck || ''; |
| } |
| return this.i18n( |
| 'checkupProgress', this.status_.alreadyProcessed || 0, |
| this.status_.totalNumberOfPasswords || 0); |
| } |
| |
| private showCheckupResult_(): boolean { |
| assert(this.status_); |
| if (this.computeIsCheckRunning_()) { |
| return false; |
| } |
| return this.status_.state !== CheckState.NO_PASSWORDS; |
| } |
| |
| private focusConfigChanged_(_newConfig: FocusConfig, oldConfig: FocusConfig) { |
| // focusConfig is set only once on the parent, so this observer should |
| // only fire once. |
| assert(!oldConfig); |
| |
| this.focusConfig.set(Page.CHECKUP_DETAILS, () => { |
| const previousRoute = Router.getInstance().previousRoute; |
| |
| switch (previousRoute?.details as unknown as CheckupSubpage) { |
| case CheckupSubpage.COMPROMISED: |
| focusWithoutInk(this.$.compromisedRow); |
| break; |
| case CheckupSubpage.REUSED: |
| focusWithoutInk(this.$.reusedRow); |
| break; |
| case CheckupSubpage.WEAK: |
| focusWithoutInk(this.$.weakRow); |
| break; |
| default: |
| break; |
| } |
| }); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'checkup-section': CheckupSectionElement; |
| } |
| } |
| |
| customElements.define(CheckupSectionElement.is, CheckupSectionElement); |