| /** |
| * @license |
| * Copyright (C) 2016 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| /* Import to get Gerrit interface */ |
| /* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */ |
| import '../gr-error-dialog/gr-error-dialog'; |
| import '../../shared/gr-alert/gr-alert'; |
| import '../../shared/gr-overlay/gr-overlay'; |
| import {PolymerElement} from '@polymer/polymer/polymer-element'; |
| import {htmlTemplate} from './gr-error-manager_html'; |
| import {getBaseUrl} from '../../../utils/url-util'; |
| import {appContext} from '../../../services/app-context'; |
| import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer'; |
| import {customElement, property} from '@polymer/decorators'; |
| import {GrOverlay} from '../../shared/gr-overlay/gr-overlay'; |
| import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog'; |
| import {GrAlert} from '../../shared/gr-alert/gr-alert'; |
| import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types'; |
| import {AccountId} from '../../../types/common'; |
| import { |
| EventType, |
| NetworkErrorEvent, |
| ServerErrorEvent, |
| ShowAlertEventDetail, |
| ShowErrorEvent, |
| } from '../../../types/events'; |
| import {windowLocationReload} from '../../../utils/dom-util'; |
| import {debounce, DelayedTask} from '../../../utils/async-util'; |
| import {fireIronAnnounce} from '../../../utils/event-util'; |
| |
| const HIDE_ALERT_TIMEOUT_MS = 5000; |
| const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000; |
| const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000; |
| const SIGN_IN_WIDTH_PX = 690; |
| const SIGN_IN_HEIGHT_PX = 500; |
| const TOO_MANY_FILES = 'too many files to find conflicts'; |
| const AUTHENTICATION_REQUIRED = 'Authentication required\n'; |
| |
| // Bigger number has higher priority |
| const ErrorTypePriority = { |
| [ErrorType.AUTH]: 3, |
| [ErrorType.NETWORK]: 2, |
| [ErrorType.GENERIC]: 1, |
| }; |
| |
| interface ErrorMsg { |
| errorText?: string; |
| status?: number; |
| statusText?: string; |
| url?: string; |
| trace?: string | null; |
| tip?: string; |
| } |
| |
| export const __testOnly_ErrorType = ErrorType; |
| |
| export interface GrErrorManager { |
| $: { |
| noInteractionOverlay: GrOverlay; |
| errorDialog: GrErrorDialog; |
| errorOverlay: GrOverlay; |
| }; |
| } |
| |
| @customElement('gr-error-manager') |
| export class GrErrorManager extends PolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| /** |
| * The ID of the account that was logged in when the app was launched. If |
| * not set, then there was no account at launch. |
| */ |
| @property({type: Number}) |
| knownAccountId?: AccountId | null; |
| |
| @property({type: Object}) |
| _alertElement: GrAlert | null = null; |
| |
| @property({type: Number}) |
| _hideAlertHandle: number | null = null; |
| |
| @property({type: Boolean}) |
| _refreshingCredentials = false; |
| |
| /** |
| * The time (in milliseconds) since the most recent credential check. |
| */ |
| @property({type: Number}) |
| _lastCredentialCheck: number = Date.now(); |
| |
| @property({type: String}) |
| loginUrl = '/login'; |
| |
| private readonly reporting = appContext.reportingService; |
| |
| private readonly _authService = appContext.authService; |
| |
| private readonly eventEmitter = appContext.eventEmitter; |
| |
| _authErrorHandlerDeregistrationHook?: Function; |
| |
| private readonly restApiService = appContext.restApiService; |
| |
| private checkLoggedInTask?: DelayedTask; |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| document.addEventListener(EventType.SERVER_ERROR, this.handleServerError); |
| document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError); |
| document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert); |
| document.addEventListener('hide-alert', this.hideAlert); |
| document.addEventListener('show-error', this.handleShowErrorDialog); |
| document.addEventListener('visibilitychange', this.handleVisibilityChange); |
| document.addEventListener('show-auth-required', this.handleAuthRequired); |
| |
| this._authErrorHandlerDeregistrationHook = this.eventEmitter.on( |
| 'auth-error', |
| event => { |
| this._handleAuthError(event.message, event.action); |
| } |
| ); |
| |
| ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability(); |
| } |
| |
| /** @override */ |
| disconnectedCallback() { |
| this._clearHideAlertHandle(); |
| document.removeEventListener( |
| EventType.SERVER_ERROR, |
| this.handleServerError |
| ); |
| document.removeEventListener( |
| EventType.NETWORK_ERROR, |
| this.handleNetworkError |
| ); |
| document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert); |
| document.removeEventListener('hide-alert', this.hideAlert); |
| document.removeEventListener('show-error', this.handleShowErrorDialog); |
| document.removeEventListener( |
| 'visibilitychange', |
| this.handleVisibilityChange |
| ); |
| document.removeEventListener('show-auth-required', this.handleAuthRequired); |
| this.checkLoggedInTask?.cancel(); |
| |
| if (this._authErrorHandlerDeregistrationHook) { |
| this._authErrorHandlerDeregistrationHook(); |
| } |
| super.disconnectedCallback(); |
| } |
| |
| _shouldSuppressError(msg: string) { |
| return msg.includes(TOO_MANY_FILES); |
| } |
| |
| private readonly handleAuthRequired = () => { |
| this._showAuthErrorAlert( |
| 'Log in is required to perform that action.', |
| 'Log in.' |
| ); |
| }; |
| |
| _handleAuthError(msg: string, action: string) { |
| this.$.noInteractionOverlay.open().then(() => { |
| this._showAuthErrorAlert(msg, action); |
| }); |
| } |
| |
| private readonly handleServerError = (e: ServerErrorEvent) => { |
| const {request, response} = e.detail; |
| response.text().then(errorText => { |
| const url = request && (request.anonymizedUrl || request.url); |
| const {status, statusText} = response; |
| if ( |
| response.status === 403 && |
| !this._authService.isAuthed && |
| errorText === AUTHENTICATION_REQUIRED |
| ) { |
| // if not authed previously, this is trying to access auth required APIs |
| // show auth required alert |
| this.handleAuthRequired(); |
| } else if ( |
| response.status === 403 && |
| this._authService.isAuthed && |
| errorText === AUTHENTICATION_REQUIRED |
| ) { |
| // The app was logged at one point and is now getting auth errors. |
| // This indicates the auth token may no longer valid. |
| // Re-check on auth |
| this._authService.clearCache(); |
| this.restApiService.getLoggedIn(); |
| } else if (!this._shouldSuppressError(errorText)) { |
| const trace = |
| response.headers && response.headers.get('X-Gerrit-Trace'); |
| if (response.status === 404) { |
| this._showNotFoundMessageWithTip({ |
| status, |
| statusText, |
| errorText, |
| url, |
| trace, |
| }); |
| } else { |
| this._showErrorDialog( |
| this._constructServerErrorMsg({ |
| status, |
| statusText, |
| errorText, |
| url, |
| trace, |
| }) |
| ); |
| } |
| } |
| console.info(`server error: ${errorText}`); |
| }); |
| }; |
| |
| _showNotFoundMessageWithTip({ |
| status, |
| statusText, |
| errorText, |
| url, |
| trace, |
| }: ErrorMsg) { |
| this.restApiService.getLoggedIn().then(isLoggedIn => { |
| const tip = isLoggedIn |
| ? 'You might have not enough privileges.' |
| : 'You might have not enough privileges. Sign in and try again.'; |
| this._showErrorDialog( |
| this._constructServerErrorMsg({ |
| status, |
| statusText, |
| errorText, |
| url, |
| trace, |
| tip, |
| }), |
| { |
| showSignInButton: !isLoggedIn, |
| } |
| ); |
| }); |
| } |
| |
| _constructServerErrorMsg({ |
| errorText, |
| status, |
| statusText, |
| url, |
| trace, |
| tip, |
| }: ErrorMsg) { |
| let err = ''; |
| if (tip) { |
| err += `${tip}\n\n`; |
| } |
| err += `Error ${status}`; |
| if (statusText) { |
| err += ` (${statusText})`; |
| } |
| if (errorText || url) { |
| err += ': '; |
| } |
| if (errorText) { |
| err += errorText; |
| } |
| if (url) { |
| err += `\nEndpoint: ${url}`; |
| } |
| if (trace) { |
| err += `\nTrace Id: ${trace}`; |
| } |
| return err; |
| } |
| |
| private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => { |
| this._showAlert( |
| e.detail.message, |
| e.detail.action, |
| e.detail.callback, |
| e.detail.dismissOnNavigation, |
| undefined, |
| e.detail.showDismiss |
| ); |
| }; |
| |
| private readonly handleNetworkError = (e: NetworkErrorEvent) => { |
| this._showAlert('Server unavailable'); |
| console.error(e.detail.error.message); |
| }; |
| |
| // TODO(dhruvsr): allow less priority alerts to override high priority alerts |
| // In some use cases we may want generic alerts to show along/over errors |
| _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) { |
| return ErrorTypePriority[incoming] >= ErrorTypePriority[existing]; |
| } |
| |
| _showAlert( |
| text: string, |
| actionText?: string, |
| actionCallback?: () => void, |
| dismissOnNavigation?: boolean, |
| type?: ErrorType, |
| showDismiss?: boolean |
| ) { |
| if (this._alertElement) { |
| // check priority before hiding |
| if (!this._canOverride(type, this._alertElement.type)) return; |
| this.hideAlert(); |
| } |
| |
| this._clearHideAlertHandle(); |
| if (dismissOnNavigation) { |
| // Persist alert until navigation. |
| document.addEventListener('location-change', this.hideAlert); |
| } else { |
| this._hideAlertHandle = window.setTimeout( |
| this.hideAlert, |
| HIDE_ALERT_TIMEOUT_MS |
| ); |
| } |
| const el = this._createToastAlert(showDismiss); |
| el.show(text, actionText, actionCallback); |
| this._alertElement = el; |
| fireIronAnnounce(this, `Alert: ${text}`); |
| this.reporting.reportInteraction('show-alert', {text}); |
| } |
| |
| private readonly hideAlert = () => { |
| if (!this._alertElement) { |
| return; |
| } |
| |
| this._alertElement.hide(); |
| this._alertElement = null; |
| |
| // Remove listener for page navigation, if it exists. |
| document.removeEventListener('location-change', this.hideAlert); |
| }; |
| |
| _clearHideAlertHandle() { |
| if (this._hideAlertHandle !== null) { |
| window.clearTimeout(this._hideAlertHandle); |
| this._hideAlertHandle = null; |
| } |
| } |
| |
| _showAuthErrorAlert(errorText: string, actionText?: string) { |
| // hide any existing alert like `reload` |
| // as auth error should have the highest priority |
| if (this._alertElement) { |
| this._alertElement.hide(); |
| } |
| |
| this._alertElement = this._createToastAlert(); |
| this._alertElement.type = ErrorType.AUTH; |
| this._alertElement.show(errorText, actionText, () => |
| this._createLoginPopup() |
| ); |
| fireIronAnnounce(this, errorText); |
| this.reporting.reportInteraction('show-auth-error', {text: errorText}); |
| this._refreshingCredentials = true; |
| this._requestCheckLoggedIn(); |
| if (!document.hidden) { |
| this.handleVisibilityChange(); |
| } |
| } |
| |
| _createToastAlert(showDismiss?: boolean) { |
| const el = document.createElement('gr-alert'); |
| el.toast = true; |
| el.showDismiss = !!showDismiss; |
| return el; |
| } |
| |
| private readonly handleVisibilityChange = () => { |
| // Ignore when the page is transitioning to hidden (or hidden is undefined). |
| if (document.hidden !== false) return; |
| |
| // If not currently refreshing credentials and the credentials are old, |
| // request them to confirm their validity or (display an auth toast if it |
| // fails). |
| const timeSinceLastCheck = Date.now() - this._lastCredentialCheck; |
| if ( |
| !this._refreshingCredentials && |
| this.knownAccountId !== undefined && |
| timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS |
| ) { |
| this.reporting.reportInteraction('visibility-sign-in-check'); |
| this._lastCredentialCheck = Date.now(); |
| |
| // check auth status in case: |
| // - user signed out |
| // - user switched account |
| this._checkSignedIn(); |
| } |
| }; |
| |
| _requestCheckLoggedIn() { |
| this.checkLoggedInTask = debounce( |
| this.checkLoggedInTask, |
| () => this._checkSignedIn(), |
| CHECK_SIGN_IN_INTERVAL_MS |
| ); |
| } |
| |
| _checkSignedIn() { |
| this._lastCredentialCheck = Date.now(); |
| |
| // force to refetch account info |
| this.restApiService.invalidateAccountsCache(); |
| this._authService.clearCache(); |
| |
| this.restApiService.getLoggedIn().then(isLoggedIn => { |
| if (!this._refreshingCredentials) return; |
| |
| if (!isLoggedIn) { |
| // check later |
| // 1. guest mode |
| // 2. or signed out |
| // in case #2, auth-error is taken care of separately |
| this._requestCheckLoggedIn(); |
| } else { |
| this.restApiService.getAccount().then(account => { |
| if (this._refreshingCredentials) { |
| // If the credentials were refreshed but the account is different, |
| // then reload the page completely. |
| if (account?._account_id !== this.knownAccountId) { |
| this.reporting.reportInteraction('sign-in-window-reload', { |
| oldAccount: !!this.knownAccountId, |
| newAccount: !!account?._account_id, |
| }); |
| this._reloadPage(); |
| return; |
| } |
| |
| this.handleCredentialRefreshed(); |
| } |
| }); |
| } |
| }); |
| } |
| |
| _reloadPage() { |
| windowLocationReload(); |
| } |
| |
| _createLoginPopup() { |
| const left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2; |
| const top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2; |
| const options = [ |
| `width=${SIGN_IN_WIDTH_PX}`, |
| `height=${SIGN_IN_HEIGHT_PX}`, |
| `left=${left}`, |
| `top=${top}`, |
| ]; |
| window.open( |
| getBaseUrl() + '/login/%3FcloseAfterLogin', |
| '_blank', |
| options.join(',') |
| ); |
| window.addEventListener('focus', this.handleWindowFocus); |
| } |
| |
| handleCredentialRefreshed() { |
| window.removeEventListener('focus', this.handleWindowFocus); |
| this._refreshingCredentials = false; |
| this.hideAlert(); |
| this._showAlert('Credentials refreshed.'); |
| this.$.noInteractionOverlay.close(); |
| |
| // Clear the cache for auth |
| this._authService.clearCache(); |
| } |
| |
| private readonly handleWindowFocus = () => { |
| this.checkLoggedInTask?.flush(); |
| }; |
| |
| private readonly handleShowErrorDialog = (e: ShowErrorEvent) => { |
| this._showErrorDialog(e.detail.message); |
| }; |
| |
| _handleDismissErrorDialog() { |
| this.$.errorOverlay.close(); |
| } |
| |
| _showErrorDialog(message: string, options?: {showSignInButton?: boolean}) { |
| this.reporting.reportErrorDialog(message); |
| this.$.errorDialog.text = message; |
| this.$.errorDialog.showSignInButton = |
| !!options && !!options.showSignInButton; |
| this.$.errorOverlay.open(); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-error-manager': GrErrorManager; |
| } |
| } |