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 000000000..0cbb69d0e Binary files /dev/null and b/src/assets/img/login_logo.png differ