// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview
* 'settings-people-page' is the settings page containing sign-in settings.
*/
import 'chrome://resources/ash/common/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/ash/common/cr_elements/cr_link_row/cr_link_row.js';
import 'chrome://resources/ash/common/cr_elements/icons.html.js';
import 'chrome://resources/ash/common/cr_elements/policy/cr_policy_indicator.js';
import 'chrome://resources/ash/common/cr_elements/cr_shared_vars.css.js';
import 'chrome://resources/polymer/v3_0/iron-flex-layout/iron-flex-layout-classes.js';
import '../controls/settings_toggle_button.js';
import '../settings_shared.css.js';
import '../os_settings_page/os_settings_animated_pages.js';
import '../os_settings_page/os_settings_subpage.js';
import '../os_settings_page/settings_card.js';
import '../parental_controls_page/parental_controls_page.js';
import '../parental_controls_page/parental_controls_settings_card.js';
import './account_manager_settings_card.js';
import './additional_accounts_settings_card.js';
import {ProfileInfo, ProfileInfoBrowserProxyImpl} from '/shared/settings/people_page/profile_info_browser_proxy.js';
import {SignedInState, SyncBrowserProxy, SyncBrowserProxyImpl, SyncStatus} from '/shared/settings/people_page/sync_browser_proxy.js';
import {convertImageSequenceToPng} from 'chrome://resources/ash/common/cr_picture/png.js';
import {sendWithPromise} from 'chrome://resources/js/cr.js';
import {getImage} from 'chrome://resources/js/icon.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {afterNextRender, flush, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {DeepLinkingMixin} from '../common/deep_linking_mixin.js';
import {isAccountManagerEnabled, isRevampWayfindingEnabled} from '../common/load_time_booleans.js';
import {RouteOriginMixin} from '../common/route_origin_mixin.js';
import {LockStateMixin} from '../lock_state_mixin.js';
import {Section} from '../mojom-webui/routes.mojom-webui.js';
import {Setting} from '../mojom-webui/setting.mojom-webui.js';
import {Route, Router, routes} from '../router.js';
import {Account, AccountManagerBrowserProxyImpl} from './account_manager_browser_proxy.js';
import {getTemplate} from './os_people_page.html.js';
const OsSettingsPeoplePageElementBase =
LockStateMixin(RouteOriginMixin(DeepLinkingMixin(PolymerElement)));
export class OsSettingsPeoplePageElement extends
OsSettingsPeoplePageElementBase {
static get is() {
return 'os-settings-people-page' as const;
}
static get template() {
return getTemplate();
}
static get properties() {
return {
prefs: {
type: Object,
notify: true,
},
section_: {
type: Number,
value: Section.kPeople,
readOnly: true,
},
/**
* The current sync status, supplied by SyncBrowserProxy.
*/
syncStatus: Object,
accounts_: {
type: Array,
value() {
return [];
},
},
deviceAccount_: {
type: Object,
value() {
return null;
},
},
authTokenInfo_: {
type: Object,
observer: 'onAuthTokenChanged_',
},
/**
* The current profile icon URL. Usually a data:image/png URL.
*/
profileIconUrl_: String,
profileName_: String,
profileEmail_: String,
profileLabel_: String,
fingerprintUnlockEnabled_: {
type: Boolean,
value() {
return loadTimeData.getBoolean('fingerprintUnlockEnabled');
},
readOnly: true,
},
isAccountManagerEnabled_: {
type: Boolean,
value() {
return isAccountManagerEnabled();
},
readOnly: true,
},
isRevampWayfindingEnabled_: {
type: Boolean,
value: () => {
return isRevampWayfindingEnabled();
},
readOnly: true,
},
showParentalControls_: {
type: Boolean,
value() {
return loadTimeData.valueExists('showParentalControls') &&
loadTimeData.getBoolean('showParentalControls');
},
},
showPasswordPromptDialog_: {
type: Boolean,
value: false,
},
/**
* Used by DeepLinkingMixin to focus this page's deep links.
*/
supportedSettingIds: {
type: Object,
value: () => new Set<Setting>([
Setting.kSetUpParentalControls,
// Perform Sync page deep links here since it's a shared page.
Setting.kNonSplitSyncEncryptionOptions,
Setting.kImproveSearchSuggestions,
Setting.kMakeSearchesAndBrowsingBetter,
Setting.kGoogleDriveSearchSuggestions,
]),
},
/**
* Whether to show the new UI for OS Sync Settings
* which include sublabel and Apps toggle
* shared between Ash and Lacros.
*/
showSyncSettingsRevamp_: {
type: Boolean,
value: loadTimeData.getBoolean('showSyncSettingsRevamp'),
readOnly: true,
},
};
}
syncStatus: SyncStatus;
private accounts_: Account[];
private deviceAccount_: Account|null;
private authTokenInfo_: chrome.quickUnlockPrivate.TokenInfo|undefined;
private profileIconUrl_: string;
private profileName_: string;
private profileEmail_: string;
private profileLabel_: string;
private fingerprintUnlockEnabled_: boolean;
private isAccountManagerEnabled_: boolean;
private readonly isRevampWayfindingEnabled_: boolean;
private showParentalControls_: boolean;
private section_: Section;
private showPasswordPromptDialog_: boolean;
private showSyncSettingsRevamp_: boolean;
private syncBrowserProxy_: SyncBrowserProxy;
private clearAccountPasswordTimeoutId_: number|undefined;
constructor() {
super();
/** RouteOriginMixin override */
this.route = routes.OS_PEOPLE;
this.syncBrowserProxy_ = SyncBrowserProxyImpl.getInstance();
/**
* The timeout ID to pass to clearTimeout() to cancel auth token
* invalidation.
*/
this.clearAccountPasswordTimeoutId_ = undefined;
}
override connectedCallback(): void {
super.connectedCallback();
if (this.isAccountManagerEnabled_) {
// If we have the Google Account manager, use GAIA name and icon.
this.addWebUiListener(
'accounts-changed', this.updateAccounts_.bind(this));
this.updateAccounts_();
} else {
// Otherwise use the Profile name and icon.
ProfileInfoBrowserProxyImpl.getInstance().getProfileInfo().then(
this.handleProfileInfo_.bind(this));
this.addWebUiListener(
'profile-info-changed', this.handleProfileInfo_.bind(this));
}
this.syncBrowserProxy_.getSyncStatus().then(
this.handleSyncStatus_.bind(this));
this.addWebUiListener(
'sync-status-changed', this.handleSyncStatus_.bind(this));
}
override ready(): void {
super.ready();
this.addFocusConfig(routes.SYNC, '#syncSetupRow');
this.addFocusConfig(
routes.ACCOUNT_MANAGER, '#accountManagerSubpageTrigger');
}
private onPasswordRequested_(): void {
this.showPasswordPromptDialog_ = true;
}
// Invalidate the token to trigger a password re-prompt. Used for PIN auto
// submit when too many attempts were made when using PrefStore based PIN.
private onInvalidateTokenRequested_(): void {
this.authTokenInfo_ = undefined;
}
private onPasswordPromptDialogClose_(): void {
this.showPasswordPromptDialog_ = false;
if (!this.authTokenInfo_) {
Router.getInstance().navigateToPreviousRoute();
}
}
private getSyncAdvancedTitle_(): string {
if (this.showSyncSettingsRevamp_) {
return this.i18n('syncAdvancedDevicePageTitle');
}
return this.i18n('syncAdvancedPageTitle');
}
private afterRenderShowDeepLink_(
settingId: Setting,
getElementCallback: () => (HTMLElement | null)): void {
// Wait for element to load.
afterNextRender(this, () => {
const deepLinkElement = getElementCallback();
if (!deepLinkElement || deepLinkElement.hidden) {
console.warn(`Element with deep link id ${settingId} not focusable.`);
return;
}
this.showDeepLinkElement(deepLinkElement);
});
}
// TODO(b/302374851) The manual deep linking below can be removed once the
// Revamp feature is fully launched.
override beforeDeepLinkAttempt(settingId: Setting): boolean {
switch (settingId) {
// Manually show the deep links for settings nested within elements.
case Setting.kSetUpParentalControls:
this.afterRenderShowDeepLink_(settingId, () => {
const parentalPage =
this.shadowRoot!.querySelector('settings-parental-controls-page');
return parentalPage && parentalPage.getSetupButton();
});
// Stop deep link attempt since we completed it manually.
return false;
// Handle the settings within the old sync page since its a shared
// component.
case Setting.kNonSplitSyncEncryptionOptions:
this.afterRenderShowDeepLink_(settingId, () => {
const syncPage =
this.shadowRoot!.querySelector('os-settings-sync-subpage');
// Expand the encryption collapse.
syncPage!.forceEncryptionExpanded = true;
flush();
return syncPage && syncPage.getEncryptionOptions() &&
syncPage.getEncryptionOptions()!.getEncryptionsRadioButtons();
});
return false;
case Setting.kImproveSearchSuggestions:
this.afterRenderShowDeepLink_(settingId, () => {
const syncPage =
this.shadowRoot!.querySelector('os-settings-sync-subpage');
return syncPage && syncPage.getPersonalizationOptions() &&
syncPage.getPersonalizationOptions()!.getSearchSuggestToggle();
});
return false;
case Setting.kMakeSearchesAndBrowsingBetter:
this.afterRenderShowDeepLink_(settingId, () => {
const syncPage =
this.shadowRoot!.querySelector('os-settings-sync-subpage');
return syncPage && syncPage.getPersonalizationOptions() &&
syncPage.getPersonalizationOptions()!.getUrlCollectionToggle();
});
return false;
case Setting.kGoogleDriveSearchSuggestions:
this.afterRenderShowDeepLink_(settingId, () => {
const syncPage =
this.shadowRoot!.querySelector('os-settings-sync-subpage');
return syncPage && syncPage.getPersonalizationOptions() &&
syncPage.getPersonalizationOptions()!.getDriveSuggestToggle();
});
return false;
default:
// Continue with deep linking attempt.
return true;
}
}
override currentRouteChanged(newRoute: Route, oldRoute?: Route): void {
super.currentRouteChanged(newRoute, oldRoute);
// The old sync page is a shared subpage, so we handle deep links for
// both this page and the sync page. Not ideal.
if (newRoute === routes.SYNC || newRoute === this.route) {
this.attemptDeepLink();
}
}
private onAuthTokenObtained_(
e: CustomEvent<chrome.quickUnlockPrivate.TokenInfo>): void {
this.authTokenInfo_ = e.detail;
}
private getSyncAndGoogleServicesSubtext_(): string {
if (this.syncStatus && this.syncStatus.hasError &&
this.syncStatus.statusText) {
return this.syncStatus.statusText;
}
return '';
}
private handleProfileInfo_(info: ProfileInfo): void {
this.profileName_ = info.name;
// Extract first frame from image by creating a single frame PNG using
// url as input if base64 encoded and potentially animated.
if (info.iconUrl.startsWith('data:image/png;base64')) {
this.profileIconUrl_ = convertImageSequenceToPng([info.iconUrl]);
return;
}
this.profileIconUrl_ = info.iconUrl;
}
/**
* Handler for when the account list is updated.
*/
private async updateAccounts_(): Promise<void> {
const accounts =
await AccountManagerBrowserProxyImpl.getInstance().getAccounts();
this.accounts_ = accounts;
// The user might not have any GAIA accounts (e.g. guest mode or Active
// Directory). In these cases the profile row is hidden, so there's nothing
// to do.
if (accounts.length === 0) {
return;
}
// Device account is always first per account_manager_ui_handler.cc.
// TODO(b/325142618) Investigate why `isDeviceAccount` is not always true.
this.deviceAccount_ = accounts[0];
this.profileName_ = this.deviceAccount_.fullName;
this.profileEmail_ = this.deviceAccount_.email;
this.profileIconUrl_ = this.deviceAccount_.pic;
// Template: "$1 Google accounts" with correct plural of "account".
const labelTemplate = await sendWithPromise(
'getPluralString', 'profileLabel', this.accounts_.length);
// Final output: "X Google accounts"
this.profileLabel_ = loadTimeData.substituteString(
labelTemplate, this.profileEmail_, this.accounts_.length);
}
/**
* Handler for when the sync state is pushed from the browser.
*/
private handleSyncStatus_(syncStatus: SyncStatus): void {
this.syncStatus = syncStatus;
// When ChromeOSAccountManager is disabled, fall back to using the sync
// username ("[email protected]") as the profile label.
if (!this.isAccountManagerEnabled_ && syncStatus &&
syncStatus.signedInState === SignedInState.SYNCING &&
syncStatus.signedInUsername) {
this.profileLabel_ = syncStatus.signedInUsername;
}
}
private onSyncClick_(): void {
// Users can go to sync subpage regardless of sync status.
Router.getInstance().navigateTo(routes.SYNC);
}
private onAccountManagerClick_(): void {
if (this.isAccountManagerEnabled_) {
Router.getInstance().navigateTo(routes.ACCOUNT_MANAGER);
}
}
private getIconImageSet_(iconUrl: string): string {
return getImage(iconUrl);
}
private getProfileName_(): string {
if (this.isAccountManagerEnabled_) {
return loadTimeData.getString('osProfileName');
}
return this.profileName_;
}
private onAuthTokenChanged_(): void {
if (this.clearAccountPasswordTimeoutId_) {
clearTimeout(this.clearAccountPasswordTimeoutId_);
}
if (this.authTokenInfo_ === undefined) {
return;
}
// Clear |this.authTokenInfo_| after
// |this.authTokenInfo_.tokenInfo.lifetimeSeconds|.
// Subtract time from the expiration time to account for IPC delays.
// Treat values less than the minimum as 0 for testing.
const IPC_SECONDS = 2;
const lifetimeMs = this.authTokenInfo_.lifetimeSeconds > IPC_SECONDS ?
(this.authTokenInfo_.lifetimeSeconds - IPC_SECONDS) * 1000 :
0;
this.clearAccountPasswordTimeoutId_ = setTimeout(() => {
this.authTokenInfo_ = undefined;
}, lifetimeMs);
}
}
declare global {
interface HTMLElementTagNameMap {
[OsSettingsPeoplePageElement.is]: OsSettingsPeoplePageElement;
}
}
customElements.define(
OsSettingsPeoplePageElement.is, OsSettingsPeoplePageElement);