From 727db6c4eaa12826580a95d324964adf3367876d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 19 Oct 2020 08:55:46 +0200 Subject: [PATCH] MOBILE-3565 login: Initial implementation of site page --- src/app/classes/site.ts | 15 +- src/app/core/login/login.module.ts | 10 + src/app/core/login/pages/init/init.page.ts | 7 +- src/app/core/login/pages/site/site.html | 106 +- src/app/core/login/pages/site/site.page.ts | 485 ++++++- src/app/core/login/pages/site/site.scss | 130 +- src/app/core/login/services/helper.ts | 1327 ++++++++++++++++++++ src/app/services/events.ts | 10 + src/app/services/sites.ts | 2 +- src/app/services/utils/dom.ts | 5 + src/assets/img/login_logo.png | Bin 0 -> 16563 bytes 11 files changed, 2083 insertions(+), 14 deletions(-) create mode 100644 src/app/core/login/services/helper.ts create mode 100644 src/assets/img/login_logo.png diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index 97cad6e9c..c02a3259f 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -2112,11 +2112,7 @@ export type CoreSitePublicConfigResponse = { mobilecssurl?: string; // Mobile custom CSS theme. // eslint-disable-next-line @typescript-eslint/naming-convention tool_mobile_disabledfeatures?: string; // Disabled features in the app. - identityproviders?: { // Identity providers. - name: string; // The identity provider name. - iconurl: string; // The icon URL for the provider. - url: string; // The URL of the provider. - }[]; + identityproviders?: CoreSiteIdentityProvider[]; // Identity providers. country?: string; // Default site country. agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled. supportname?: string; // Site support contact name (only if age verification is enabled). @@ -2137,6 +2133,15 @@ export type CoreSitePublicConfigResponse = { warnings?: CoreWSExternalWarning[]; }; +/** + * Identity provider. + */ +export type CoreSiteIdentityProvider = { + name: string; // The identity provider name. + iconurl: string; // The icon URL for the provider. + url: string; // The URL of the provider. +}; + /** * Result of WS tool_mobile_get_autologin_key. */ diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index 39ba3ce72..f3e05da4d 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -14,13 +14,17 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@/app/components/components.module'; + import { CoreLoginRoutingModule } from './login-routing.module'; import { CoreLoginInitPage } from './pages/init/init.page'; import { CoreLoginSitePage } from './pages/site/site.page'; +import { CoreLoginHelperProvider } from './services/helper'; @NgModule({ imports: [ @@ -28,10 +32,16 @@ import { CoreLoginSitePage } from './pages/site/site.page'; IonicModule, CoreLoginRoutingModule, TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, ], declarations: [ CoreLoginInitPage, CoreLoginSitePage, ], + providers: [ + CoreLoginHelperProvider, + ], }) export class CoreLoginModule {} diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 38e3cbab3..006ec48c6 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreInit } from '@services/init'; @@ -29,7 +29,7 @@ import { SplashScreen } from '@singletons/core.singletons'; }) export class CoreLoginInitPage implements OnInit { - constructor(protected router: Router) {} + constructor(protected navCtrl: NavController) {} /** * Initialize the component. @@ -90,6 +90,7 @@ export class CoreLoginInitPage implements OnInit { // return this.loginHelper.goToSiteInitialPage(); // } - await this.router.navigate(['/login/site']); + await this.navCtrl.navigateRoot('/login/site'); } + } diff --git a/src/app/core/login/pages/site/site.html b/src/app/core/login/pages/site/site.html index e2beae84f..811b44a34 100644 --- a/src/app/core/login/pages/site/site.html +++ b/src/app/core/login/pages/site/site.html @@ -1,3 +1,105 @@ - - {{ 'core.login.yourenteredsite' | translate }} + + + + + + + {{ 'core.login.connecttomoodle' | translate }} + + + + + + + +
+ +
+ + + +

{{ 'core.login.siteaddress' | translate }}

+ +
+
+ + +

{{ 'core.login.siteaddress' | translate }}

+ +
+ + + + +
+ + + {{ 'core.login.connect' | translate }} + +
+ + + + +

{{ 'core.login.selectsite' | translate }}

+ + + + + + +

{{site.title}}

+

{{site.noProtocolUrl}}

+

{{site.location}}

+
+
+ + +
+

{{ 'core.login.selectsite' | translate }}

+ {{site.title}} +
+
+ + + + + + + + + + +
diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 12e0398ae..8183d1e68 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -12,7 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; + +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreSites, CoreSiteCheckResponse, CoreLoginSiteInfo, CoreSitesDemoSiteData } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/helper'; +import { CoreSite } from '@classes/site'; +import { CoreError } from '@classes/errors/error'; +import CoreConfigConstants from '@app/config.json'; +import { Translate } from '@singletons/core.singletons'; +import { CoreUrl } from '@singletons/url'; +import { CoreUrlUtils } from '@/app/services/utils/url'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -24,11 +39,477 @@ import { Component, OnInit } from '@angular/core'; }) export class CoreLoginSitePage implements OnInit { + @ViewChild('siteFormEl') formElement: ElementRef; + + siteForm: FormGroup; + fixedSites: CoreLoginSiteInfoExtended[]; + filteredSites: CoreLoginSiteInfoExtended[]; + siteSelector = 'sitefinder'; + showKeyboard = false; + filter = ''; + sites: CoreLoginSiteInfoExtended[] = []; + hasSites = false; + loadingSites = false; + searchFunction: (search: string) => void; + showScanQR: boolean; + enteredSiteUrl: CoreLoginSiteInfoExtended; + siteFinderSettings: SiteFinderSettings; + + constructor( + protected route: ActivatedRoute, + protected formBuilder: FormBuilder, + ) { + + let url = ''; + this.siteSelector = CoreConfigConstants.multisitesdisplay; + + const siteFinderSettings: Partial = CoreConfigConstants['sitefindersettings'] || {}; + this.siteFinderSettings = { + displaysitename: true, + displayimage: true, + displayalias: true, + displaycity: true, + displaycountry: true, + displayurl: true, + ...siteFinderSettings, + }; + + // Load fixed sites if they're set. + if (CoreLoginHelper.instance.hasSeveralFixedSites()) { + url = this.initSiteSelector(); + } else if (CoreConfigConstants.enableonboarding && !CoreApp.instance.isIOS() && !CoreApp.instance.isMac()) { + this.initOnboarding(); + } + + this.showScanQR = CoreUtils.instance.canScanQR() && (typeof CoreConfigConstants['displayqronsitescreen'] == 'undefined' || + !!CoreConfigConstants['displayqronsitescreen']); + + this.siteForm = this.formBuilder.group({ + siteUrl: [url, this.moodleUrlValidator()], + }); + + this.searchFunction = CoreUtils.instance.debounce(async (search: string) => { + search = search.trim(); + + if (search.length >= 3) { + // Update the sites list. + this.sites = await CoreSites.instance.findSites(search); + + // Add UI tweaks. + this.sites = this.extendCoreLoginSiteInfo(this.sites); + + this.hasSites = !!this.sites.length; + } else { + // Not reseting the array to allow animation to be displayed. + this.hasSites = false; + } + + this.loadingSites = false; + }, 1000); + } + /** * Initialize the component. */ ngOnInit(): void { - // + this.route.queryParams.subscribe(params => { + this.showKeyboard = !!params['showKeyboard']; + }); + } + + /** + * Initialize the site selector. + * + * @return URL of the first site. + */ + protected initSiteSelector(): string { + // Deprecate listnourl on 3.9.3, remove this block on the following release. + if (this.siteSelector == 'listnourl') { + this.siteSelector = 'list'; + this.siteFinderSettings.displayurl = false; + } + + this.fixedSites = this.extendCoreLoginSiteInfo( CoreLoginHelper.instance.getFixedSites()); + + // Do not show images if none are set. + if (!this.fixedSites.some((site) => !!site.imageurl)) { + this.siteFinderSettings.displayimage = false; + } + + // Autoselect if not defined. + if (this.siteSelector != 'list' && this.siteSelector != 'buttons') { + this.siteSelector = this.fixedSites.length > 3 ? 'list' : 'buttons'; + } + + this.filteredSites = this.fixedSites; + + return this.fixedSites[0].url; + } + + /** + * Initialize and show onboarding if needed. + * + * @return Promise resolved when done. + */ + protected async initOnboarding(): Promise { + const onboardingDone = await CoreConfig.instance.get(CoreLoginHelperProvider.ONBOARDING_DONE, false); + + if (!onboardingDone) { + // Check onboarding. + this.showOnboarding(); + } + } + + /** + * Extend info of Login Site Info to get UI tweaks. + * + * @param sites Sites list. + * @return Sites list with extended info. + */ + protected extendCoreLoginSiteInfo(sites: CoreLoginSiteInfoExtended[]): CoreLoginSiteInfoExtended[] { + return sites.map((site) => { + site.noProtocolUrl = this.siteFinderSettings.displayurl && site.url ? CoreUrl.removeProtocol(site.url) : ''; + + const name = this.siteFinderSettings.displaysitename ? site.name : ''; + const alias = this.siteFinderSettings.displayalias && site.alias ? site.alias : ''; + + // Set title with parenthesis if both name and alias are present. + site.title = name && alias ? name + ' (' + alias + ')' : name + alias; + + const country = this.siteFinderSettings.displaycountry && site.countrycode ? + CoreUtils.instance.getCountryName(site.countrycode) : ''; + const city = this.siteFinderSettings.displaycity && site.city ? + site.city : ''; + + // Separate location with hiphen if both country and city are present. + site.location = city && country ? city + ' - ' + country : city + country; + + return site; + }); + } + + /** + * Validate Url. + * + * @return {ValidatorFn} Validation results. + */ + protected moodleUrlValidator(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value.trim(); + let valid = value.length >= 3 && CoreUrl.isValidMoodleUrl(value); + + if (!valid) { + const demo = !!this.getDemoSiteData(value); + + if (demo) { + valid = true; + } + } + + return valid ? null : { siteUrl: { value: control.value } }; + }; + } + + /** + * Get the demo data for a certain "name" if it is a demo site. + * + * @param name Name of the site to check. + * @return Site data if it's a demo site, undefined otherwise. + */ + getDemoSiteData(name: string): CoreSitesDemoSiteData { + const demoSites = CoreConfigConstants.demo_sites; + if (typeof demoSites != 'undefined' && typeof demoSites[name] != 'undefined') { + return demoSites[name]; + } + } + + /** + * Show a help modal. + */ + showHelp(): void { + // @todo + } + + /** + * Show an onboarding modal. + */ + showOnboarding(): void { + // @todo + } + + /** + * Try to connect to a site. + * + * @param e Event. + * @param url The URL to connect to. + * @param foundSite The site clicked, if any, from the found sites list. + * @return Promise resolved when done. + */ + async connect(e: Event, url: string, foundSite?: CoreLoginSiteInfoExtended): Promise { + e.preventDefault(); + e.stopPropagation(); + + CoreApp.instance.closeKeyboard(); + + if (!url) { + CoreDomUtils.instance.showErrorModal('core.login.siteurlrequired', true); + + return; + } + + if (!CoreApp.instance.isOnline()) { + CoreDomUtils.instance.showErrorModal('core.networkerrormsg', true); + + return; + } + + url = url.trim(); + + if (url.match(/^(https?:\/\/)?campus\.example\.edu/)) { + this.showLoginIssue(null, new CoreError(Translate.instance.instant('core.login.errorexampleurl'))); + + return; + } + + const siteData = CoreSites.instance.getDemoSiteData(url); + + if (siteData) { + // It's a demo site. + await this.loginDemoSite(siteData); + + } else { + // Not a demo site. + const modal = await CoreDomUtils.instance.showModalLoading(); + + let checkResult: CoreSiteCheckResponse; + + try { + checkResult = await CoreSites.instance.checkSite(url); + } catch (error) { + // Attempt guessing the domain if the initial check failed + const domain = CoreUrl.guessMoodleDomain(url); + + if (domain && domain != url) { + try { + checkResult = await CoreSites.instance.checkSite(domain); + } catch (secondError) { + // Try to use the first error. + modal.dismiss(); + + return this.showLoginIssue(url, error || secondError); + } + } else { + modal.dismiss(); + + return this.showLoginIssue(url, error); + } + } + + await this.login(checkResult, foundSite); + + modal.dismiss(); + } + } + + /** + * Authenticate in a demo site. + * + * @param siteData Site data. + * @return Promise resolved when done. + */ + protected async loginDemoSite(siteData: CoreSitesDemoSiteData): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const data = await CoreSites.instance.getUserToken(siteData.url, siteData.username, siteData.password); + + await CoreSites.instance.newSite(data.siteUrl, data.token, data.privateToken); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + + return CoreLoginHelper.instance.goToSiteInitialPage(); + } catch (error) { + CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); + + if (error.loggedout) { + // @todo Send the user to sites page. + } + } finally { + modal.dismiss(); + } + } + + /** + * Process login to a site. + * + * @param response Response obtained from the site check request. + * @param foundSite The site clicked, if any, from the found sites list. + * + * @return Promise resolved after logging in. + */ + protected async login(response: CoreSiteCheckResponse, foundSite?: CoreLoginSiteInfoExtended): Promise { + await CoreUtils.instance.ignoreErrors(CoreSites.instance.checkApplication(response)); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + + if (response.warning) { + CoreDomUtils.instance.showErrorModal(response.warning, true, 4000); + } + + if (CoreLoginHelper.instance.isSSOLoginNeeded(response.code)) { + // SSO. User needs to authenticate in a browser. + CoreLoginHelper.instance.confirmAndOpenBrowserForSSOLogin( + response.siteUrl, response.code, response.service, response.config && response.config.launchurl); + } else { + const pageParams = { siteUrl: response.siteUrl, siteConfig: response.config }; + if (foundSite) { + pageParams['siteName'] = foundSite.name; + pageParams['logoUrl'] = foundSite.imageurl; + } + + // @todo Navigate to credentials. + } + } + + /** + * Show an error that aims people to solve the issue. + * + * @param url The URL the user was trying to connect to. + * @param error Error to display. + */ + protected showLoginIssue(url: string, error: CoreError): void { + let errorMessage = CoreDomUtils.instance.getErrorMessage(error); + + if (errorMessage == Translate.instance.instant('core.cannotconnecttrouble')) { + const found = this.sites.find((site) => site.url == url); + + if (!found) { + errorMessage += ' ' + Translate.instance.instant('core.cannotconnectverify'); + } + } + + let message = '

' + errorMessage + '

'; + if (url) { + const fullUrl = CoreUrlUtils.instance.isAbsoluteURL(url) ? url : 'https://' + url; + message += '

' + url + '

'; + } + + const buttons = [ + { + text: Translate.instance.instant('core.needhelp'), + handler: (): void => { + this.showHelp(); + }, + }, + { + text: Translate.instance.instant('core.tryagain'), + role: 'cancel', + }, + ]; + + // @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0. + CoreDomUtils.instance.showAlertWithOptions({ + header: Translate.instance.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }), + message, + buttons, + }); + } + + /** + * The filter has changed. + * + * @param event Received Event. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterChanged(event: any): void { + const newValue = event.target.value?.trim().toLowerCase(); + if (!newValue || !this.fixedSites) { + this.filteredSites = this.fixedSites; + } else { + this.filteredSites = this.fixedSites.filter((site) => + site.title.toLowerCase().indexOf(newValue) > -1 || site.noProtocolUrl.toLowerCase().indexOf(newValue) > -1 || + site.location.toLowerCase().indexOf(newValue) > -1); + } + } + + /** + * Find a site on the backend. + * + * @param e Event. + * @param search Text to search. + */ + searchSite(e: Event, search: string): void { + this.loadingSites = true; + + search = search.trim(); + + if (this.siteForm.valid && search.length >= 3) { + this.enteredSiteUrl = { + url: search, + name: 'connect', + noProtocolUrl: CoreUrl.removeProtocol(search), + }; + } else { + this.enteredSiteUrl = null; + } + + this.searchFunction(search.trim()); + } + + /** + * Show instructions and scan QR code. + */ + showInstructionsAndScanQR(): void { + // Show some instructions first. + CoreDomUtils.instance.showAlertWithOptions({ + header: Translate.instance.instant('core.login.faqwhereisqrcode'), + message: Translate.instance.instant('core.login.faqwhereisqrcodeanswer', + { $image: CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML }), + buttons: [ + { + text: Translate.instance.instant('core.cancel'), + role: 'cancel', + }, + { + text: Translate.instance.instant('core.next'), + handler: (): void => { + this.scanQR(); + }, + }, + ], + }); + } + + /** + * Scan a QR code and put its text in the URL input. + * + * @return Promise resolved when done. + */ + async scanQR(): Promise { + // Scan for a QR code. + const text = await CoreUtils.instance.scanQR(); + + if (text) { + // @todo + } } } + +/** + * Extended data for UI implementation. + */ +type CoreLoginSiteInfoExtended = CoreLoginSiteInfo & { + noProtocolUrl?: string; // Url wihtout protocol. + location?: string; // City + country. + title?: string; // Name + alias. +}; + +type SiteFinderSettings = { + displayalias: boolean; + displaycity: boolean; + displaycountry: boolean; + displayimage: boolean; + displaysitename: boolean; + displayurl: boolean; +}; diff --git a/src/app/core/login/pages/site/site.scss b/src/app/core/login/pages/site/site.scss index b61725a0d..97390e520 100644 --- a/src/app/core/login/pages/site/site.scss +++ b/src/app/core/login/pages/site/site.scss @@ -1,2 +1,130 @@ -app-root page-core-login-init { +.item-input:last-child { + margin-bottom: 20px; +} + +.searchbar-ios { + background: transparent; + + .searchbar-input { + background-color: white; // @todo $searchbar-ios-toolbar-input-background; + } +} + +.item.item-block { + &.core-login-need-help.item { + text-decoration: underline; + } + &.core-login-site-qrcode { + .item-inner { + border-bottom: 0; + } + } +} + +.core-login-site-connect { + margin-top: 1.4rem; +} + +.item ion-thumbnail { + min-width: 50px; + min-height: 50px; + width: 50px; + height: 50px; + border-radius: 20%; + box-shadow: 0 0 4px #eee; + text-align: center; + overflow: hidden; + + img { + max-height: 50px; + max-width: fit-content; + width: auto; + height: auto; + margin: 0 auto; + margin-left: 50%; + transform: translateX(-50%); + object-fit: cover; + object-position: 50% 50%; + } + ion-icon { + margin: 0 auto; + font-size: 40px; + line-height: 50px; + } +} + +.core-login-site-logo, +.core-login-site-list, +.core-login-site-list-found { + transition-delay: 0s; + visibility: visible; + opacity: 1; + transition: all 0.7s ease-in-out; + max-height: 9999px; + + &.hidden { + opacity: 0; + visibility: hidden; + margin-top: 0; + margin-bottom: 0; + padding: 0; + max-height: 0; + } +} + +.core-login-site-list-found.dimmed { + pointer-events: none; + position: relative; +} + +.core-login-site-list-loading { + position: absolute; + //@todo @include position(0, 0, 0, 0); + width: 100%; + height: 100%; + display: flex; + align-content: center; + align-items: center; + background-color: rgba(255, 255, 255, 0.5); + z-index: 1; + ion-spinner { + flex: 1; + } +} + +.core-login-site-nolist-loading { + text-align: center; +} + +.item.core-login-site-list-title { + ion-label, ion-label h2.item-heading { + margin-top: 0; + } +} +/* @todo +@include media-breakpoint-up(md) { + .scroll-content > * { + max-width: 600px; + margin: 0 auto; + width: 100%; + } + .core-login-site-logo { + margin-top: 20%; + } + + &.hidden { + margin: 0; + } +} +*/ +.core-login-entered-site { + background-color: gray; // @todo $gray-lighter; + ion-thumbnail { + box-shadow: 0 0 4px #ddd; + } +} + + +.core-login-default-icon { + filter: grayscale(100%); } diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts new file mode 100644 index 000000000..c37c2807c --- /dev/null +++ b/src/app/core/login/services/helper.ts @@ -0,0 +1,1327 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { Injectable } from '@angular/core'; +import { Location } from '@angular/common'; +import { Router, Params } from '@angular/router'; +import { NavController } from '@ionic/angular'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreApp, CoreStoreConfig } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents, CoreEventSessionExpiredData, CoreEventsProvider } from '@services/events'; +import { CoreSites, CoreLoginSiteInfo } from '@services/sites'; +import { CoreWS, CoreWSExternalWarning } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlParams, CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import CoreConfigConstants from '@app/config.json'; +import { CoreConstants } from '@core/constants'; +import { CoreSite, CoreSiteConfig, CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/site'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreUrl } from '@singletons/url'; + +/** + * Helper provider that provides some common features regarding authentication. + */ +@Injectable() +export class CoreLoginHelperProvider { + + static readonly OPEN_COURSE = 'open_course'; + static readonly ONBOARDING_DONE = 'onboarding_done'; + static readonly FAQ_URL_IMAGE_HTML = ''; + static readonly FAQ_QRCODE_IMAGE_HTML = ''; + + protected logger: CoreLogger; + protected isSSOConfirmShown = false; + protected isOpenEditAlertShown = false; + protected pageToLoad: {page: string; params: Params; time: number}; // Page to load once main menu is opened. + protected isOpeningReconnect = false; + waitingForBrowser = false; + + constructor( + protected router: Router, + private location: Location, + ) { + this.logger = CoreLogger.getInstance('CoreLoginHelper'); + + CoreEvents.instance.on(CoreEventsProvider.MAIN_MENU_OPEN, () => { + /* If there is any page pending to be opened, do it now. Don't open pages stored more than 5 seconds ago, probably + the function to open the page was called when it shouldn't. */ + if (this.pageToLoad && Date.now() - this.pageToLoad.time < 5000) { + this.loadPageInMainMenu(this.pageToLoad.page, this.pageToLoad.params); + delete this.pageToLoad; + } + }); + } + + /** + * Accept site policy. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if success, rejected if failure. + */ + async acceptSitePolicy(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const result = await site.write('core_user_agree_site_policy', {}); + + if (!result.status) { + // Error. + if (result.warnings && result.warnings.length) { + // Check if there is a warning 'alreadyagreed'. + for (const i in result.warnings) { + const warning = result.warnings[i]; + if (warning.warningcode == 'alreadyagreed') { + // Policy already agreed, treat it as a success. + return; + } + } + + // Another warning, reject. + throw new CoreWSError(result.warnings[0]); + } else { + throw new CoreError('Cannot agree site policy'); + } + } + } + + /** + * Check if a site allows requesting a password reset through the app. + * + * @param siteUrl URL of the site. + * @return Promise resolved with boolean: whether can be done through the app. + */ + async canRequestPasswordReset(siteUrl: string): Promise { + try { + await this.requestPasswordReset(siteUrl); + + return true; + } catch (error) { + return error.available == 1 || (error.errorcode && error.errorcode != 'invalidrecord'); + } + } + + /** + * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out. + */ + checkLogout(): void { + // @todo + } + + /** + * Show a confirm modal if needed and open a browser to perform SSO login. + * + * @param siteurl URL of the site where the SSO login will be performed. + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @return Promise resolved when done or if user cancelled. + */ + async confirmAndOpenBrowserForSSOLogin( + siteUrl: string, + typeOfLogin: number, + service?: string, + launchUrl?: string, + ): Promise { + // Show confirm only if it's needed. Treat "false" (string) as false to prevent typing errors. + const showConfirmation = this.shouldShowSSOConfirm(typeOfLogin); + + if (showConfirmation) { + try { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.login.logininsiterequired')); + } catch (error) { + // User canceled, stop. + return; + } + } + + this.openBrowserForSSOLogin(siteUrl, typeOfLogin, service, launchUrl); + } + + /** + * Helper function to act when the forgotten password is clicked. + * + * @param siteUrl Site URL. + * @param username Username. + * @param siteConfig Site config. + */ + async forgottenPasswordClicked( + siteUrl: string, + username: string, + siteConfig?: CoreSitePublicConfigResponse, + ): Promise { + if (siteConfig && siteConfig.forgottenpasswordurl) { + // URL set, open it. + CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl); + + return; + } + + // Check if password reset can be done through the app. + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const canReset = await this.canRequestPasswordReset(siteUrl); + + if (canReset) { + await this.router.navigate(['/login/forgottenpassword'], { + queryParams: { + siteUrl, + username, + }, + }); + } else { + this.openForgottenPassword(siteUrl); + } + } finally { + modal.dismiss(); + } + } + + /** + * Format profile fields, filtering the ones that shouldn't be shown on signup and classifying them in categories. + * + * @param profileFields Profile fields to format. + * @return Categories with the fields to show in each one. + */ + formatProfileFieldsForSignup(profileFields: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { + if (!profileFields) { + return []; + } + + const categories: Record = {}; + + profileFields.forEach((field) => { + if (!field.signup) { + // Not a signup field, ignore it. + return; + } + + if (!categories[field.categoryid]) { + categories[field.categoryid] = { + id: field.categoryid, + name: field.categoryname, + fields: [], + }; + } + + categories[field.categoryid].fields.push(field); + }); + + return Object.keys(categories).map((index) => categories[Number(index)]); + } + + /** + * Get disabled features from a site public config. + * + * @param config Site public config. + * @return Disabled features. + */ + getDisabledFeatures(config: CoreSitePublicConfigResponse): string { + const disabledFeatures = config && config.tool_mobile_disabledfeatures; + if (!disabledFeatures) { + return ''; + } + + return CoreTextUtils.instance.treatDisabledFeatures(disabledFeatures); + } + + /** + * Builds an object with error messages for some common errors. + * Please notice that this function doesn't support all possible error types. + * + * @param requiredMsg Code of the string for required error. + * @param emailMsg Code of the string for invalid email error. + * @param patternMsg Code of the string for pattern not match error. + * @param urlMsg Code of the string for invalid url error. + * @param minlengthMsg Code of the string for "too short" error. + * @param maxlengthMsg Code of the string for "too long" error. + * @param minMsg Code of the string for min value error. + * @param maxMsg Code of the string for max value error. + * @return Object with the errors. + */ + getErrorMessages( + requiredMsg?: string, + emailMsg?: string, + patternMsg?: string, + urlMsg?: string, + minlengthMsg?: string, + maxlengthMsg?: string, + minMsg?: string, + maxMsg?: string, + ): any { + const errors: any = {}; + + if (requiredMsg) { + errors.required = errors.requiredTrue = Translate.instance.instant(requiredMsg); + } + if (emailMsg) { + errors.email = Translate.instance.instant(emailMsg); + } + if (patternMsg) { + errors.pattern = Translate.instance.instant(patternMsg); + } + if (urlMsg) { + errors.url = Translate.instance.instant(urlMsg); + } + if (minlengthMsg) { + errors.minlength = Translate.instance.instant(minlengthMsg); + } + if (maxlengthMsg) { + errors.maxlength = Translate.instance.instant(maxlengthMsg); + } + if (minMsg) { + errors.min = Translate.instance.instant(minMsg); + } + if (maxMsg) { + errors.max = Translate.instance.instant(maxMsg); + } + + return errors; + } + + /** + * Get logo URL from a site public config. + * + * @param config Site public config. + * @return Logo URL. + */ + getLogoUrl(config: CoreSitePublicConfigResponse): string { + return !CoreConfigConstants.forceLoginLogo && config ? (config.logourl || config.compactlogourl) : null; + } + + /** + * Returns the logout label of a site. + * + * @param site Site. If not defined, use current site. + * @return The string key. + */ + getLogoutLabel(site?: CoreSite): string { + site = site || CoreSites.instance.getCurrentSite(); + const config = site.getStoredConfig(); + + return 'core.mainmenu.' + (config && config.tool_mobile_forcelogout == '1' ? 'logout' : 'changesite'); + } + + /** + * Get the OAuth ID of some URL params (if it has an OAuth ID). + * + * @param params Params. + * @return OAuth ID. + */ + getOAuthIdFromParams(params: CoreUrlParams): number { + return params && typeof params.oauthsso != 'undefined' ? Number(params.oauthsso) : undefined; + } + + /** + * Get the site policy. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the site policy. + */ + async getSitePolicy(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + let sitePolicy: string; + + try { + // Try to get the latest config, maybe the site policy was just added or has changed. + sitePolicy = await site.getConfig('sitepolicy', true); + } catch (error) { + // Cannot get config, try to get the site policy using auth_email_get_signup_settings. + const settings = await CoreWS.instance.callAjax('auth_email_get_signup_settings', {}, + { siteUrl: site.getURL() }); + + sitePolicy = settings.sitepolicy; + } + + if (!sitePolicy) { + throw new CoreError('Cannot retrieve site policy'); + } + + return sitePolicy; + } + + /** + * Get fixed site or sites. + * + * @return Fixed site or list of fixed sites. + */ + getFixedSites(): string | CoreLoginSiteInfo[] { + return CoreConfigConstants.siteurl; + } + + /** + * Get the valid identity providers from a site config. + * + * @param siteConfig Site's public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Valid identity providers. + */ + getValidIdentityProviders(siteConfig: CoreSitePublicConfigResponse, disabledFeatures?: string): CoreSiteIdentityProvider[] { + if (this.isFeatureDisabled('NoDelegate_IdentityProviders', siteConfig, disabledFeatures)) { + // Identity providers are disabled, return an empty list. + return []; + } + + const validProviders: CoreSiteIdentityProvider[] = []; + const httpUrl = CoreTextUtils.instance.concatenatePaths(siteConfig.wwwroot, 'auth/oauth2/'); + const httpsUrl = CoreTextUtils.instance.concatenatePaths(siteConfig.httpswwwroot, 'auth/oauth2/'); + + if (siteConfig.identityproviders && siteConfig.identityproviders.length) { + siteConfig.identityproviders.forEach((provider) => { + const urlParams = CoreUrlUtils.instance.extractUrlParams(provider.url); + + if (provider.url && (provider.url.indexOf(httpsUrl) != -1 || provider.url.indexOf(httpUrl) != -1) && + !this.isFeatureDisabled('NoDelegate_IdentityProvider_' + urlParams.id, siteConfig, disabledFeatures)) { + validProviders.push(provider); + } + }); + } + + return validProviders; + } + + /** + * Go to the page to add a new site. + * If a fixed URL is configured, go to credentials instead. + * + * @param setRoot True to set the new page as root, false to add it to the stack. + * @param showKeyboard Whether to show keyboard in the new page. Only if no fixed URL set. + * @return Promise resolved when done. + */ + async goToAddSite(setRoot?: boolean, showKeyboard?: boolean): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Open a page that doesn't belong to any site. + * + * @param navCtrl Nav Controller. + * @param page Page to open. + * @param params Params of the page. + * @return Promise resolved when done. + */ + goToNoSitePage(navCtrl: NavController, page: string, params?: Params): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Go to the initial page of a site depending on 'userhomepage' setting. + * + * @param navCtrl NavController to use. Defaults to app root NavController. + * @param page Name of the page to load after loading the main page. + * @param params Params to pass to the page. + * @param options Navigation options. + * @param url URL to open once the main menu is loaded. + * @return Promise resolved when done. + */ + goToSiteInitialPage(navCtrl?: NavController, page?: string, params?: Params, options?: any, url?: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Convenient helper to handle authentication in the app using a token received by SSO login. If it's a new account, + * the site is stored and the user is authenticated. If the account already exists, update its token. + * + * @param siteUrl Site's URL. + * @param token User's token. + * @param privateToken User's private token. + * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. + * @return Promise resolved when the user is authenticated with the token. + */ + handleSSOLoginAuthentication(siteUrl: string, token: string, privateToken?: string, oauthId?: number): Promise { + // Always create a new site to prevent overriding data if another user credentials were introduced. + return CoreSites.instance.newSite(siteUrl, token, privateToken, true, oauthId); + } + + /** + * Check if the app is configured to use several fixed URLs. + * + * @return Whether there are several fixed URLs. + */ + hasSeveralFixedSites(): boolean { + return CoreConfigConstants.siteurl && Array.isArray(CoreConfigConstants.siteurl) && + CoreConfigConstants.siteurl.length > 1; + } + + /** + * Given a site public config, check if email signup is disabled. + * + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether email signup is disabled. + */ + isEmailSignupDisabled(config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + return this.isFeatureDisabled('CoreLoginEmailSignup', config, disabledFeatures); + } + + /** + * Given a site public config, check if a certian feature is disabled. + * + * @param feature Feature to check. + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether email signup is disabled. + */ + isFeatureDisabled(feature: string, config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + if (typeof disabledFeatures == 'undefined') { + disabledFeatures = this.getDisabledFeatures(config); + } + + const regEx = new RegExp('(,|^)' + feature + '(,|$)', 'g'); + + return !!disabledFeatures.match(regEx); + } + + /** + * Check if the app is configured to use a fixed URL (only 1). + * + * @return Whether there is 1 fixed URL. + */ + isFixedUrlSet(): boolean { + if (Array.isArray(CoreConfigConstants.siteurl)) { + return CoreConfigConstants.siteurl.length == 1; + } + + return !!CoreConfigConstants.siteurl; + } + + /** + * Given a site public config, check if forgotten password is disabled. + * + * @param config Site public config. + * @param disabledFeatures List of disabled features already treated. If not provided it will be calculated. + * @return Whether it's disabled. + */ + isForgottenPasswordDisabled(config?: CoreSitePublicConfigResponse, disabledFeatures?: string): boolean { + return this.isFeatureDisabled('NoDelegate_ForgottenPassword', config, disabledFeatures); + } + + /** + * Check if current site is logged out, triggering mmCoreEventSessionExpired if it is. + * + * @param pageName Name of the page to go once authenticated if logged out. If not defined, site initial page. + * @param params Params of the page to go once authenticated if logged out. + * @return True if user is logged out, false otherwise. + */ + isSiteLoggedOut(pageName?: string, params?: Params): boolean { + const site = CoreSites.instance.getCurrentSite(); + if (!site) { + return false; + } + + if (site.isLoggedOut()) { + CoreEvents.instance.trigger(CoreEventsProvider.SESSION_EXPIRED, { + pageName, + params, + }, site.getId()); + + return true; + } + + return false; + } + + /** + * Check if a site URL is "allowed". In case the app has fixed sites, only those will be allowed to connect to. + * + * @param siteUrl Site URL to check. + * @return Promise resolved with boolean: whether is one of the fixed sites. + */ + async isSiteUrlAllowed(siteUrl: string): Promise { + if (this.isFixedUrlSet()) { + // Only 1 site allowed. + return CoreUrl.sameDomainAndPath(siteUrl, this.getFixedSites()); + } else if (this.hasSeveralFixedSites()) { + const sites = this.getFixedSites(); + + return sites.some((site) => CoreUrl.sameDomainAndPath(siteUrl, site.url)); + } else if (CoreConfigConstants.multisitesdisplay == 'sitefinder' && CoreConfigConstants.onlyallowlistedsites) { + // Call the sites finder to validate the site. + const result = await CoreSites.instance.findSites(siteUrl.replace(/^https?:\/\/|\.\w{2,3}\/?$/g, '')); + + return result && result.some((site) => CoreUrl.sameDomainAndPath(siteUrl, site.url)); + } else { + // No fixed sites or it uses a non-restrictive sites finder. Allow connecting. + return true; + } + } + + /** + * Check if SSO login should use an embedded browser. + * + * @param code Code to check. + * @return True if embedded browser, false othwerise. + */ + isSSOEmbeddedBrowser(code: number): boolean { + if (CoreApp.instance.isLinux()) { + // In Linux desktop app, always use embedded browser. + return true; + } + + return code == CoreConstants.LOGIN_SSO_INAPP_CODE; + } + + /** + * Check if SSO login is needed based on code returned by the WS. + * + * @param code Code to check. + * @return True if SSO login is needed, false othwerise. + */ + isSSOLoginNeeded(code: number): boolean { + return code == CoreConstants.LOGIN_SSO_CODE || code == CoreConstants.LOGIN_SSO_INAPP_CODE; + } + + /** + * Load a site and load a certain page in that site. + * + * @param page Name of the page to load. + * @param params Params to pass to the page. + * @param siteId Site to load. + * @return Promise resolved when done. + */ + protected loadSiteAndPage(page: string, params: Params, siteId: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Load a certain page in the main menu page. + * + * @param page Name of the page to load. + * @param params Params to pass to the page. + */ + loadPageInMainMenu(page: string, params: Params): void { + if (!CoreApp.instance.isMainMenuOpen()) { + // Main menu not open. Store the page to be loaded later. + this.pageToLoad = { + page: page, + params: params, + time: Date.now(), + }; + + return; + } + + if (page == CoreLoginHelperProvider.OPEN_COURSE) { + // @todo Use the openCourse function. + } else { + CoreEvents.instance.trigger(CoreEventsProvider.LOAD_PAGE_MAIN_MENU, { redirectPage: page, redirectParams: params }); + } + } + + /** + * Open the main menu, loading a certain page. + * + * @param navCtrl NavController. + * @param page Name of the page to load. + * @param params Params to pass to the page. + * @param options Navigation options. + * @param url URL to open once the main menu is loaded. + * @return Promise resolved when done. + */ + protected openMainMenu(navCtrl: NavController, page: string, params: Params, options?: any, url?: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Open a browser to perform OAuth login (Google, Facebook, Microsoft). + * + * @param siteUrl URL of the site where the login will be performed. + * @param provider The identity provider. + * @param launchUrl The URL to open for SSO. If not defined, tool/mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + * @return True if success, false if error. + */ + openBrowserForOAuthLogin( + siteUrl: string, + provider: CoreSiteIdentityProvider, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + ): boolean { + launchUrl = launchUrl || siteUrl + '/admin/tool/mobile/launch.php'; + if (!provider || !provider.url) { + return false; + } + + const params = CoreUrlUtils.instance.extractUrlParams(provider.url); + + if (!params.id) { + return false; + } + + const service = CoreSites.instance.determineService(siteUrl); + const loginUrl = this.prepareForSSOLogin(siteUrl, service, launchUrl, pageName, pageParams, { + oauthsso: params.id, + }); + + if (CoreApp.instance.isLinux()) { + // In Linux desktop app, always use embedded browser. + CoreUtils.instance.openInApp(loginUrl); + } else { + // Always open it in browser because the user might have the session stored in there. + CoreUtils.instance.openInBrowser(loginUrl); + + const nav = window.navigator; // eslint-disable-line @typescript-eslint/no-explicit-any + nav.app?.exitApp(); + } + + return true; + } + + /** + * Open a browser to perform SSO login. + * + * @param siteurl URL of the site where the SSO login will be performed. + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + */ + openBrowserForSSOLogin( + siteUrl: string, + typeOfLogin: number, + service?: string, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + ): void { + const loginUrl = this.prepareForSSOLogin(siteUrl, service, launchUrl, pageName, pageParams); + + if (this.isSSOEmbeddedBrowser(typeOfLogin)) { + CoreUtils.instance.openInApp(loginUrl, { + clearsessioncache: 'yes', // Clear the session cache to allow for multiple logins. + closebuttoncaption: Translate.instance.instant('core.login.cancel'), + }); + } else { + CoreUtils.instance.openInBrowser(loginUrl); + + const nav = window.navigator; // eslint-disable-line @typescript-eslint/no-explicit-any + nav.app?.exitApp(); + } + } + + /** + * Convenient helper to open change password page. + * + * @param siteUrl Site URL to construct change password URL. + * @param error Error message. + * @return Promise resolved when done. + */ + async openChangePassword(siteUrl: string, error: string): Promise { + const alert = await CoreDomUtils.instance.showAlert(Translate.instance.instant('core.notice'), error, undefined, 3000); + + await alert.onDidDismiss(); + + CoreUtils.instance.openInApp(siteUrl + '/login/change_password.php'); + } + + /** + * Open forgotten password in inappbrowser. + * + * @param siteUrl URL of the site. + */ + openForgottenPassword(siteUrl: string): void { + CoreUtils.instance.openInApp(siteUrl + '/login/forgot_password.php'); + } + + /** + * Function to open in app browser to change password or complete user profile. + * + * @param siteId The site ID. + * @param path The relative path of the URL to open. + * @param alertMessage The key of the message to display before opening the in app browser. + * @param invalidateCache Whether to invalidate site's cache (e.g. when the user is forced to change password). + * @return Promise resolved when done. + */ + async openInAppForEdit(siteId: string, path: string, alertMessage?: string, invalidateCache?: boolean): Promise { + if (!siteId || siteId !== CoreSites.instance.getCurrentSiteId()) { + // Site that triggered the event is not current site, nothing to do. + return; + } + + const currentSite = CoreSites.instance.getCurrentSite(); + const siteUrl = currentSite?.getURL(); + + if (!currentSite || !siteUrl) { + return; + } + + if (!this.isOpenEditAlertShown && !this.waitingForBrowser) { + this.isOpenEditAlertShown = true; + + if (invalidateCache) { + currentSite.invalidateWsCache(); + } + + // Open change password. + if (alertMessage) { + alertMessage = Translate.instance.instant(alertMessage) + '
' + + Translate.instance.instant('core.redirectingtosite'); + } + + try { + await currentSite.openInAppWithAutoLogin(siteUrl + path, undefined, alertMessage); + + this.waitingForBrowser = true; + } finally { + this.isOpenEditAlertShown = false; + } + } + } + + /** + * Function that should be called when password change is forced. Reserved for core use. + * + * @param siteId The site ID. + */ + passwordChangeForced(siteId: string): void { + // @todo + } + + /** + * Prepare the app to perform SSO login. + * + * @param siteUrl URL of the site where the SSO login will be performed. + * @param service The service to use. If not defined, external service will be used. + * @param launchUrl The URL to open for SSO. If not defined, local_mobile launch URL will be used. + * @param pageName Name of the page to go once authenticated. If not defined, site initial page. + * @param pageParams Params of the state to go once authenticated. + * @param urlParams Other params to add to the URL. + * @return Login Url. + */ + prepareForSSOLogin( + siteUrl: string, + service?: string, + launchUrl?: string, + pageName?: string, + pageParams?: Params, + urlParams?: CoreUrlParams, + ): string { + + service = service || CoreConfigConstants.wsextservice; + launchUrl = launchUrl || siteUrl + '/local/mobile/launch.php'; + + const passport = Math.random() * 1000; + let loginUrl = launchUrl + '?service=' + service; + + loginUrl += '&passport=' + passport; + loginUrl += '&urlscheme=' + CoreConfigConstants.customurlscheme; + + if (urlParams) { + loginUrl = CoreUrlUtils.instance.addParamsToUrl(loginUrl, urlParams); + } + + // Store the siteurl and passport in CoreConfigProvider for persistence. + // We are "configuring" the app to wait for an SSO. CoreConfigProvider shouldn't be used as a temporary storage. + CoreConfig.instance.set(CoreConstants.LOGIN_LAUNCH_DATA, JSON.stringify( { + siteUrl: siteUrl, + passport: passport, + pageName: pageName || '', + pageParams: pageParams || {}, + ssoUrlParams: urlParams || {}, + })); + + return loginUrl; + } + + /** + * Redirect to a new page, setting it as the root page and loading the right site if needed. + * + * @param page Name of the page to load. Special cases: OPEN_COURSE (to open course page). + * @param params Params to pass to the page. + * @param siteId Site to load. If not defined, current site. + * @return Promise resolved when done. + */ + async redirect(page: string, params?: Params, siteId?: string): Promise { + // @todo + } + + /** + * Request a password reset. + * + * @param siteUrl URL of the site. + * @param username Username to search. + * @param email Email to search. + * @return Promise resolved when done. + */ + requestPasswordReset(siteUrl: string, username?: string, email?: string): Promise { + const params: Record = {}; + + if (username) { + params.username = username; + } + + if (email) { + params.email = email; + } + + return CoreWS.instance.callAjax('core_auth_request_password_reset', params, { siteUrl }); + } + + /** + * Function that should be called when the session expires. Reserved for core use. + * + * @param data Data received by the SESSION_EXPIRED event. + * @return Promise resolved when done. + */ + async sessionExpired(data: CoreEventSessionExpiredData): Promise { + const siteId = data?.siteId; + const currentSite = CoreSites.instance.getCurrentSite(); + const siteUrl = currentSite?.getURL(); + + if (!currentSite || !siteUrl) { + return; + } + + if (siteId && siteId !== currentSite.getId()) { + return; // Site that triggered the event is not current site. + } + + try { + // Check authentication method. + const result = await CoreSites.instance.checkSite(siteUrl); + + if (result.warning) { + CoreDomUtils.instance.showErrorModal(result.warning, true, 4000); + } + + if (this.isSSOLoginNeeded(result.code)) { + // SSO. User needs to authenticate in a browser. Check if we need to display a message. + if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.isSSOConfirmShown && !this.waitingForBrowser) { + this.isSSOConfirmShown = true; + + if (this.shouldShowSSOConfirm(result.code)) { + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.login.' + + (currentSite.isLoggedOut() ? 'loggedoutssodescription' : 'reconnectssodescription'))); + } + + try { + this.waitingForBrowser = true; + + this.openBrowserForSSOLogin(result.siteUrl, result.code, result.service, result.config?.launchurl, + data.pageName, data.params); + } catch (error) { + // User cancelled, logout him. + CoreSites.instance.logout(); + } finally { + this.isSSOConfirmShown = false; + } + } + } else { + if (currentSite.isOAuth()) { + // User authenticated using an OAuth method. Check if it's still valid. + const identityProviders = this.getValidIdentityProviders(result.config); + const providerToUse = identityProviders.find((provider) => { + const params = CoreUrlUtils.instance.extractUrlParams(provider.url); + + return Number(params.id) == currentSite.getOAuthId(); + }); + + if (providerToUse) { + if (!CoreApp.instance.isSSOAuthenticationOngoing() && !this.isSSOConfirmShown && !this.waitingForBrowser) { + // Open browser to perform the OAuth. + this.isSSOConfirmShown = true; + + const confirmMessage = Translate.instance.instant('core.login.' + + (currentSite.isLoggedOut() ? 'loggedoutssodescription' : 'reconnectssodescription')); + + try { + await CoreDomUtils.instance.showConfirm(confirmMessage); + + this.waitingForBrowser = true; + CoreSites.instance.unsetCurrentSite(); // Unset current site to make authentication work fine. + + this.openBrowserForOAuthLogin(siteUrl, providerToUse, result.config.launchurl, data.pageName, + data.params); + } catch (error) { + // User cancelled, logout him. + CoreSites.instance.logout(); + } finally { + this.isSSOConfirmShown = false; + } + } + + return; + } + } + + const info = currentSite.getInfo(); + if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { + // @todo + } + } + } catch (error) { + // Error checking site. + if (currentSite.isLoggedOut()) { + // Site is logged out, show error and logout the user. + CoreDomUtils.instance.showErrorModalDefault(error, 'core.networkerrormsg', true); + CoreSites.instance.logout(); + } + } + } + + /** + * Check if a confirm should be shown to open a SSO authentication. + * + * @param typeOfLogin CoreConstants.LOGIN_SSO_CODE or CoreConstants.LOGIN_SSO_INAPP_CODE. + * @return True if confirm modal should be shown, false otherwise. + */ + shouldShowSSOConfirm(typeOfLogin: number): boolean { + return !this.isSSOEmbeddedBrowser(typeOfLogin) && + (!CoreConfigConstants.skipssoconfirmation || String(CoreConfigConstants.skipssoconfirmation) === 'false'); + } + + /** + * Show a modal warning the user that he should use the Workplace app. + * + * @param message The warning message. + */ + protected showWorkplaceNoticeModal(message: string): void { + const link = CoreApp.instance.getAppStoreUrl({ android: 'com.moodle.workplace', ios: 'id1470929705' }); + + CoreDomUtils.instance.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal warning the user that he should use the current Moodle app. + * + * @param message The warning message. + */ + protected showMoodleAppNoticeModal(message: string): void { + const storesConfig: CoreStoreConfig = CoreConfigConstants.appstores; + storesConfig.desktop = 'https://download.moodle.org/desktop/'; + storesConfig.mobile = 'https://download.moodle.org/mobile/'; + storesConfig.default = 'https://download.moodle.org/mobile/'; + + const link = CoreApp.instance.getAppStoreUrl(storesConfig); + + CoreDomUtils.instance.showDownloadAppNoticeModal(message, link); + } + + /** + * Show a modal to inform the user that a confirmation email was sent, and a button to resend the email on 3.6+ sites. + * + * @param siteUrl Site URL. + * @param email Email of the user. If set displayed in the message. + * @param username Username. If not set the button to resend email will not be shown. + * @param password User password. If not set the button to resend email will not be shown. + */ + protected async showNotConfirmedModal(siteUrl: string, email?: string, username?: string, password?: string): Promise { + const title = Translate.instance.instant('core.login.mustconfirm'); + let message: string; + let canResend = false; + if (email) { + message = Translate.instance.instant('core.login.emailconfirmsent', { $a: email }); + } else { + message = Translate.instance.instant('core.login.emailconfirmsentnoemail'); + } + + // Check whether we need to display the resend button or not. + if (username && password) { + canResend = await this.canResendEmail(siteUrl); + } + + if (!canResend) { + // Just display an informative alert. + await CoreDomUtils.instance.showAlert(title, message); + + return; + } + + const okText = Translate.instance.instant('core.login.resendemail'); + const cancelText = Translate.instance.instant('core.close'); + + try { + // Ask the user if he wants to resend the email. + await CoreDomUtils.instance.showConfirm(message, title, okText, cancelText); + + // Call the WS to resend the confirmation email. + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + const data = { username, password }; + const preSets = { siteUrl }; + + try { + const result = await CoreWS.instance.callAjax( + 'core_auth_resend_confirmation_email', data, preSets); + + if (!result.status) { + throw new CoreWSError(result.warnings[0]); + } + + const message = Translate.instance.instant('core.login.emailconfirmsentsuccess'); + CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), message); + } finally { + modal.dismiss(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Check if confirmation email an be resent. + * + * @param siteUrl Site URL to check. + * @return Promise. + */ + protected async canResendEmail(siteUrl: string): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + // We don't have site info before login, the only way to check if the WS is available is by calling it. + try { + // This call will always fail because we aren't sending parameters. + await CoreWS.instance.callAjax('core_auth_resend_confirmation_email', {}, { siteUrl }); + } catch (error) { + // If the WS responds with an invalid parameter error it means the WS is avaiable. + return error?.errorcode === 'invalidparameter'; + } finally { + modal.dismiss(); + } + } + + /** + * Function called when site policy is not agreed. Reserved for core use. + * + * @param siteId Site ID. If not defined, current site. + */ + sitePolicyNotAgreed(siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (!siteId || siteId != CoreSites.instance.getCurrentSiteId()) { + // Only current site allowed. + return; + } + + if (!CoreSites.instance.wsAvailableInCurrentSite('core_user_agree_site_policy')) { + // WS not available, stop. + return; + } + + // @todo Navigate to site policy page. + } + + /** + * Convenient helper to handle get User Token error. It redirects to change password page if forcepassword is set. + * + * @param siteUrl Site URL to construct change password URL. + * @param error Error object containing errorcode and error message. + * @param username Username. + * @param password User password. + */ + treatUserTokenError(siteUrl: string, error: CoreWSError, username?: string, password?: string): void { + if (error.errorcode == 'forcepasswordchangenotice') { + this.openChangePassword(siteUrl, CoreTextUtils.instance.getErrorMessageFromError(error)); + } else if (error.errorcode == 'usernotconfirmed') { + this.showNotConfirmedModal(siteUrl, undefined, username, password); + } else if (error.errorcode == 'connecttomoodleapp') { + this.showMoodleAppNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + } else if (error.errorcode == 'connecttoworkplaceapp') { + this.showWorkplaceNoticeModal(CoreTextUtils.instance.getErrorMessageFromError(error)); + } else { + CoreDomUtils.instance.showErrorModal(error); + } + } + + /** + * Convenient helper to validate a browser SSO login. + * + * @param url URL received, to be validated. + * @return Promise resolved on success. + */ + async validateBrowserSSOLogin(url: string): Promise { + // Split signature:::token + const params = url.split(':::'); + + const serializedData = await CoreConfig.instance.get(CoreConstants.LOGIN_LAUNCH_DATA); + + const data = CoreTextUtils.instance.parseJSON(serializedData, null); + if (data === null) { + throw new CoreError('No launch data stored.'); + } + + const passport = data.passport; + let launchSiteURL = data.siteUrl; + + // Reset temporary values. + CoreConfig.instance.delete(CoreConstants.LOGIN_LAUNCH_DATA); + + // Validate the signature. + // We need to check both http and https. + let signature = Md5.hashAsciiStr(launchSiteURL + passport); + if (signature != params[0]) { + if (launchSiteURL.indexOf('https://') != -1) { + launchSiteURL = launchSiteURL.replace('https://', 'http://'); + } else { + launchSiteURL = launchSiteURL.replace('http://', 'https://'); + } + signature = Md5.hashAsciiStr(launchSiteURL + passport); + } + + if (signature == params[0]) { + this.logger.debug('Signature validated'); + + return { + siteUrl: launchSiteURL, + token: params[1], + privateToken: params[2], + pageName: data.pageName, + pageParams: data.pageParams, + ssoUrlParams: data.ssoUrlParams, + }; + } else { + this.logger.debug('Invalid signature in the URL request yours: ' + params[0] + ' mine: ' + + signature + ' for passport ' + passport); + + throw new CoreError(Translate.instance.instant('core.unexpectederror')); + } + } + +} + +export class CoreLoginHelper extends makeSingleton(CoreLoginHelperProvider) {} + +/** + * Data related to a SSO authentication. + */ +export interface CoreLoginSSOData { + /** + * The site's URL. + */ + siteUrl: string; + + /** + * User's token. + */ + token?: string; + + /** + * User's private token. + */ + privateToken?: string; + + /** + * Name of the page to go after authenticated. + */ + pageName?: string; + + /** + * Params to page to the page. + */ + pageParams?: Params; + + /** + * Other params added to the login url. + */ + ssoUrlParams?: CoreUrlParams; +}; + +/** + * Result of WS core_user_agree_site_policy. + */ +type AgreeSitePolicyResult = { + status: boolean; // Status: true only if we set the policyagreed to 1 for the user. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS auth_email_get_signup_settings. + */ +export type AuthEmailSignupSettings = { + namefields: string[]; + passwordpolicy?: string; // Password policy. + sitepolicy?: string; // Site policy. + sitepolicyhandler?: string; // Site policy handler. + defaultcity?: string; // Default city. + country?: string; // Default country. + profilefields?: AuthEmailSignupProfileField[]; // Required profile fields. + recaptchapublickey?: string; // Recaptcha public key. + recaptchachallengehash?: string; // Recaptcha challenge hash. + recaptchachallengeimage?: string; // Recaptcha challenge noscript image. + recaptchachallengejs?: string; // Recaptcha challenge js url. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Profile field for signup. + */ +export type AuthEmailSignupProfileField = { + id?: number; // Profile field id. + shortname?: string; // Profile field shortname. + name?: string; // Profield field name. + datatype?: string; // Profield field datatype. + description?: string; // Profield field description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + categoryid?: number; // Profield field category id. + categoryname?: string; // Profield field category name. + sortorder?: number; // Profield field sort order. + required?: number; // Profield field required. + locked?: number; // Profield field locked. + visible?: number; // Profield field visible. + forceunique?: number; // Profield field unique. + signup?: number; // Profield field in signup form. + defaultdata?: string; // Profield field default data. + defaultdataformat: number; // Defaultdata format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + param1?: string; // Profield field settings. + param2?: string; // Profield field settings. + param3?: string; // Profield field settings. + param4?: string; // Profield field settings. + param5?: string; // Profield field settings. +}; + +/** + * Category of profile fields for signup. + */ +export type AuthEmailSignupProfileFieldsCategory = { + id: number; // Category ID. + name: string; // Category name. + fields: AuthEmailSignupProfileField[]; // Field in the category. +}; + +/** + * Result of WS core_auth_request_password_reset. + */ +export type CoreLoginRequestPasswordResetResult = { + status: string; // The returned status of the process + notice: string; // Important information for the user about the process. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_auth_resend_confirmation_email. + */ +type ResendConfirmationEmailResult = { + status: boolean; // True if the confirmation email was sent, false otherwise. + warnings?: CoreWSExternalWarning[]; +}; + +type StoredLoginLaunchData = { + siteUrl: string; + passport: number; + pageName: string; + pageParams: Params; + ssoUrlParams: CoreUrlParams; +}; diff --git a/src/app/services/events.ts b/src/app/services/events.ts index 528aec327..505379a90 100644 --- a/src/app/services/events.ts +++ b/src/app/services/events.ts @@ -13,6 +13,7 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; import { Subject } from 'rxjs'; import { CoreLogger } from '@singletons/logger'; @@ -199,3 +200,12 @@ export class CoreEventsProvider { } export class CoreEvents extends makeSingleton(CoreEventsProvider) {} + +/** + * Data passed to session expired event. + */ +export type CoreEventSessionExpiredData = { + pageName?: string; + params?: Params; + siteId?: string; +}; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index 6efc5d214..004be6ac3 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -276,7 +276,7 @@ export class CoreSitesProvider { * @param name Name of the site to check. * @return Site data if it's a demo site, undefined otherwise. */ - getDemoSiteData(name: string): {[name: string]: CoreSitesDemoSiteData} { + getDemoSiteData(name: string): CoreSitesDemoSiteData { const demoSites = CoreConfigConstants.demo_sites; name = name.toLowerCase(); diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index e72f72056..97b4e3472 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1304,6 +1304,11 @@ export class CoreDomUtilsProvider { needsTranslate?: boolean, autocloseTime?: number, ): Promise { + if (this.isCanceledError(error)) { + // It's a canceled error, don't display an error. + return null; + } + const message = this.getErrorMessage(error, needsTranslate); if (message === null) { diff --git a/src/assets/img/login_logo.png b/src/assets/img/login_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0cbb69d0e497af9fb7d6aec5c53e8cbb32af1219 GIT binary patch literal 16563 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