commit
ed3d8e333b
|
@ -1,4 +1,5 @@
|
|||
module.exports = {
|
||||
framework: '@storybook/angular',
|
||||
addons: ['@storybook/addon-controls'],
|
||||
stories: ['../src/**/*.stories.ts'],
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import '!style-loader!css-loader!sass-loader!../src/theme/theme.design-system.scss';
|
||||
import '!style-loader!css-loader!sass-loader!./styles.scss';
|
||||
|
||||
export const parameters = {
|
||||
layout: 'centered',
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
.core-error-info {
|
||||
max-width: 300px;
|
||||
}
|
|
@ -13,7 +13,10 @@ module.exports = {
|
|||
'^.+\\.(ts|html)$': 'ts-jest',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!@ionic-native|@ionic)'],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/src/' }),
|
||||
moduleNameMapper: {
|
||||
...pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/src/' }),
|
||||
'^!raw-loader!.*': 'jest-raw-loader',
|
||||
},
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsConfig: './tsconfig.test.json',
|
||||
|
|
|
@ -5677,6 +5677,22 @@
|
|||
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz",
|
||||
"integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg=="
|
||||
},
|
||||
"@storybook/addon-controls": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-6.1.21.tgz",
|
||||
"integrity": "sha512-IJgZWD2E9eLKj8DJLA9lT63N4jPfVneFJ05gnPco01ZJCEiDAo7babP5Ns2UTJDUaQEtX0m04UoIkidcteWKsA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@storybook/addons": "6.1.21",
|
||||
"@storybook/api": "6.1.21",
|
||||
"@storybook/client-api": "6.1.21",
|
||||
"@storybook/components": "6.1.21",
|
||||
"@storybook/node-logger": "6.1.21",
|
||||
"@storybook/theming": "6.1.21",
|
||||
"core-js": "^3.0.1",
|
||||
"ts-dedent": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@storybook/addons": {
|
||||
"version": "6.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.21.tgz",
|
||||
|
@ -21473,6 +21489,12 @@
|
|||
"ts-jest": "26.x"
|
||||
}
|
||||
},
|
||||
"jest-raw-loader": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-raw-loader/-/jest-raw-loader-1.0.1.tgz",
|
||||
"integrity": "sha512-g9oaAjeC4/rIJk1Wd3RxVbOfMizowM7LSjEJqa4R9qDX0OjQNABXOhH+GaznUp+DjTGVPi2vPPbQXyX87DOnYg==",
|
||||
"dev": true
|
||||
},
|
||||
"jest-regex-util": {
|
||||
"version": "26.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
|
||||
|
|
|
@ -141,6 +141,7 @@
|
|||
"@angular/language-service": "~10.0.14",
|
||||
"@ionic/angular-toolkit": "^2.3.3",
|
||||
"@ionic/cli": "^6.19.0",
|
||||
"@storybook/addon-controls": "~6.1",
|
||||
"@storybook/angular": "~6.1",
|
||||
"@types/faker": "^5.1.3",
|
||||
"@types/node": "^12.12.64",
|
||||
|
@ -172,6 +173,7 @@
|
|||
"gulp-slash": "^1.1.3",
|
||||
"jest": "^26.5.2",
|
||||
"jest-preset-angular": "^8.3.1",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jsonc-parser": "^2.3.1",
|
||||
"minimatch": "^5.1.0",
|
||||
"native-run": "^1.4.0",
|
||||
|
|
|
@ -1471,8 +1471,6 @@
|
|||
"core.calculating": "local_moodlemobileapp",
|
||||
"core.cancel": "moodle",
|
||||
"core.cannotconnect": "local_moodlemobileapp",
|
||||
"core.cannotconnecttrouble": "local_moodlemobileapp",
|
||||
"core.cannotconnectverify": "local_moodlemobileapp",
|
||||
"core.cannotdownloadfiles": "local_moodlemobileapp",
|
||||
"core.cannotinstallapk": "local_moodlemobileapp",
|
||||
"core.cannotlogoutpageblocks": "local_moodlemobileapp",
|
||||
|
@ -1519,7 +1517,9 @@
|
|||
"core.confirmleaveunknownchanges": "local_moodlemobileapp",
|
||||
"core.confirmloss": "local_moodlemobileapp",
|
||||
"core.confirmopeninbrowser": "local_moodlemobileapp",
|
||||
"core.connectionlost": "local_moodlemobileapp",
|
||||
"core.considereddigitalminor": "moodle",
|
||||
"core.contactsupport": "local_moodlemobileapp",
|
||||
"core.content": "moodle",
|
||||
"core.contenteditingsynced": "local_moodlemobileapp",
|
||||
"core.contentlinks.chooseaccount": "local_moodlemobileapp",
|
||||
|
@ -1696,7 +1696,10 @@
|
|||
"core.endonesteptour": "tool_usertours",
|
||||
"core.error": "moodle",
|
||||
"core.errorchangecompletion": "local_moodlemobileapp",
|
||||
"core.errorcode": "local_moodlemobileapp",
|
||||
"core.errordeletefile": "local_moodlemobileapp",
|
||||
"core.errordetailshide": "local_moodlemobileapp",
|
||||
"core.errordetailsshow": "local_moodlemobileapp",
|
||||
"core.errordownloading": "local_moodlemobileapp",
|
||||
"core.errordownloadingsomefiles": "local_moodlemobileapp",
|
||||
"core.errorfileexistssamename": "local_moodlemobileapp",
|
||||
|
@ -1908,16 +1911,17 @@
|
|||
"core.login.changepasswordbutton": "local_moodlemobileapp",
|
||||
"core.login.changepasswordhelp": "local_moodlemobileapp",
|
||||
"core.login.changepasswordreconnectinstructions": "local_moodlemobileapp",
|
||||
"core.login.changepasswordsupportsubject": "local_moodlemobileapp",
|
||||
"core.login.confirmdeletesite": "local_moodlemobileapp",
|
||||
"core.login.connect": "local_moodlemobileapp",
|
||||
"core.login.connecttomoodle": "local_moodlemobileapp",
|
||||
"core.login.connecttomoodleapp": "local_moodlemobileapp",
|
||||
"core.login.connecttoworkplaceapp": "local_moodlemobileapp",
|
||||
"core.login.contactyouradministrator": "local_moodlemobileapp",
|
||||
"core.login.contactyouradministratorissue": "local_moodlemobileapp",
|
||||
"core.login.createaccount": "moodle",
|
||||
"core.login.createuserandpass": "moodle",
|
||||
"core.login.credentialsdescription": "local_moodlemobileapp",
|
||||
"core.login.credentialshelp": "local_moodlemobileapp",
|
||||
"core.login.credentialssupportsubject": "local_moodlemobileapp",
|
||||
"core.login.emailconfirmsent": "moodle",
|
||||
"core.login.emailconfirmsentnoemail": "local_moodlemobileapp",
|
||||
"core.login.emailconfirmsentsuccess": "moodle",
|
||||
|
@ -1927,8 +1931,11 @@
|
|||
"core.login.errorexampleurl": "local_moodlemobileapp",
|
||||
"core.login.errorqrnoscheme": "local_moodlemobileapp",
|
||||
"core.login.errorupdatesite": "local_moodlemobileapp",
|
||||
"core.login.faqcannotconnectanswer": "local_moodlemobileapp",
|
||||
"core.login.faqcannotconnectquestion": "local_moodlemobileapp",
|
||||
"core.login.exceededloginattempts": "local_moodlemobileapp",
|
||||
"core.login.exceededloginattemptsfallback": "local_moodlemobileapp",
|
||||
"core.login.exceededloginattemptssupportsubject": "local_moodlemobileapp",
|
||||
"core.login.exceededpasswordresetattempts": "local_moodlemobileapp",
|
||||
"core.login.exceededpasswordresetattemptssupportsubject": "local_moodlemobileapp",
|
||||
"core.login.faqcannotfindmysiteanswer": "local_moodlemobileapp",
|
||||
"core.login.faqcannotfindmysitequestion": "local_moodlemobileapp",
|
||||
"core.login.faqsetupsiteanswer": "local_moodlemobileapp",
|
||||
|
@ -1945,7 +1952,6 @@
|
|||
"core.login.forcepasswordchangenotice": "moodle",
|
||||
"core.login.forgotten": "moodle",
|
||||
"core.login.help": "moodle",
|
||||
"core.login.helpmelogin": "local_moodlemobileapp",
|
||||
"core.login.instructions": "auth",
|
||||
"core.login.invalidaccount": "local_moodlemobileapp",
|
||||
"core.login.invaliddate": "calendar/errorinvaliddate",
|
||||
|
@ -1994,7 +2000,9 @@
|
|||
"core.login.recaptchaexpired": "local_moodlemobileapp",
|
||||
"core.login.recaptchaincorrect": "local_moodlemobileapp",
|
||||
"core.login.reconnect": "local_moodlemobileapp",
|
||||
"core.login.reconnecthelp": "local_moodlemobileapp",
|
||||
"core.login.reconnectssodescription": "local_moodlemobileapp",
|
||||
"core.login.reconnectsupportsubject": "local_moodlemobileapp",
|
||||
"core.login.reconnecttosite": "local_moodlemobileapp",
|
||||
"core.login.removeaccount": "local_moodlemobileapp",
|
||||
"core.login.resendemail": "moodle",
|
||||
|
@ -2266,6 +2274,9 @@
|
|||
"core.sitehome.sitehome": "moodle",
|
||||
"core.sitehome.sitenews": "moodle",
|
||||
"core.sitemaintenance": "admin",
|
||||
"core.sitenotfound": "local_moodlemobileapp",
|
||||
"core.sitenotfoundhelp": "local_moodlemobileapp",
|
||||
"core.siteunavailablehelp": "local_moodlemobileapp",
|
||||
"core.size": "moodle",
|
||||
"core.sizeb": "moodle",
|
||||
"core.sizegb": "moodle",
|
||||
|
@ -2338,8 +2349,10 @@
|
|||
"core.user.address": "moodle",
|
||||
"core.user.city": "moodle",
|
||||
"core.user.completeprofile": "local_moodlemobileapp",
|
||||
"core.user.completeprofilehelp": "local_moodlemobileapp",
|
||||
"core.user.completeprofilenotice": "local_moodlemobileapp",
|
||||
"core.user.completeprofilereconnectinstructions": "local_moodlemobileapp",
|
||||
"core.user.completeprofilesupportsubject": "local_moodlemobileapp",
|
||||
"core.user.completeyourprofile": "local_moodlemobileapp",
|
||||
"core.user.contact": "local_moodlemobileapp",
|
||||
"core.user.country": "moodle",
|
||||
|
@ -2363,6 +2376,7 @@
|
|||
"core.user.roles": "moodle",
|
||||
"core.user.sendemail": "local_moodlemobileapp",
|
||||
"core.user.student": "moodle/defaultcoursestudent",
|
||||
"core.user.support": "local_moodlemobileapp",
|
||||
"core.user.teacher": "moodle/noneditingteacher",
|
||||
"core.user.useraccount": "moodle",
|
||||
"core.user.userwithid": "local_moodlemobileapp",
|
||||
|
@ -2385,7 +2399,6 @@
|
|||
"core.weeks": "moodle",
|
||||
"core.whatisyourage": "moodle",
|
||||
"core.wheredoyoulive": "moodle",
|
||||
"core.whoissiteadmin": "local_moodlemobileapp",
|
||||
"core.whyisthishappening": "local_moodlemobileapp",
|
||||
"core.whyisthisrequired": "moodle",
|
||||
"core.wsfunctionnotavailable": "local_moodlemobileapp",
|
||||
|
|
|
@ -31,7 +31,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa
|
|||
implements CanLeave, OnDestroy {
|
||||
|
||||
canLeaveSafely = false;
|
||||
remainingTimeout?: ReturnType<typeof setTimeout>;
|
||||
remainingTimeout?: number;
|
||||
|
||||
@ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent;
|
||||
|
||||
|
@ -68,7 +68,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa
|
|||
clearTimeout(this.remainingTimeout);
|
||||
}
|
||||
// When user finish an activity, he have 10 seconds to leave safely (without show alert).
|
||||
this.remainingTimeout = setTimeout(() => {
|
||||
this.remainingTimeout = window.setTimeout(() => {
|
||||
this.canLeaveSafely = false;
|
||||
}, 10000);
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreSite } from '@classes/site';
|
||||
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
|
||||
import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course';
|
||||
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
||||
|
@ -313,7 +314,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
|||
|
||||
if (!CoreNetwork.isOnline()) {
|
||||
// Cannot sync in offline.
|
||||
throw new CoreError(Translate.instant('core.cannotconnect'));
|
||||
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||
}
|
||||
|
||||
const offlineAttempt = offlineAttempts.pop()!;
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
"errordownloadscorm": "Error downloading SCORM: \"{{name}}\".",
|
||||
"errorgetscorm": "Error getting SCORM data.",
|
||||
"errorinvalidversion": "Sorry, the application only supports SCORM 1.2.",
|
||||
"errornotdownloadable": "The download of SCORM packages is disabled. Please contact your site administrator.",
|
||||
"errornotdownloadable": "Your school or learning provider has disabled the download of SCORM packages.",
|
||||
"errornovalidsco": "This SCORM package doesn't have a visible SCO to load.",
|
||||
"errorpackagefile": "Sorry, the application only supports ZIP packages.",
|
||||
"errorsyncscorm": "An error occurred while synchronising. Please try again.",
|
||||
|
@ -49,4 +49,4 @@
|
|||
"toc": "TOC",
|
||||
"warningofflinedatadeleted": "Some offline data from attempt {{number}} has been discarded because it couldn't be counted as a new attempt.",
|
||||
"warningsynconlineincomplete": "Some attempts couldn't be synchronised with the site because the last online attempt is not yet finished. Please finish the online attempt first."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,16 +19,14 @@ import { BackButtonEvent, ScrollDetail } from '@ionic/core';
|
|||
import { CoreLang } from '@services/lang';
|
||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { NgZone, SplashScreen, Translate } from '@singletons';
|
||||
import { NgZone, SplashScreen } from '@singletons';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreSubscriptions } from '@singletons/subscriptions';
|
||||
import { CoreWindow } from '@singletons/window';
|
||||
import { CoreCustomURLSchemes } from '@services/urlschemes';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreUrlUtils } from '@services/utils/url';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
|
@ -46,8 +44,6 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
|
||||
@ViewChild(IonRouterOutlet) outlet?: IonRouterOutlet;
|
||||
|
||||
protected lastInAppUrl?: string;
|
||||
|
||||
/**
|
||||
* Component being initialized.
|
||||
*/
|
||||
|
@ -91,60 +87,6 @@ export class AppComponent implements OnInit, AfterViewInit {
|
|||
content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scrollElement));
|
||||
});
|
||||
|
||||
// Check URLs loaded in any InAppBrowser.
|
||||
CoreEvents.on(CoreEvents.IAB_LOAD_START, (event) => {
|
||||
// URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this.
|
||||
const protocol = CoreUrlUtils.getUrlProtocol(event.url);
|
||||
const url = event.url.replace(/^https?:\/\//, '');
|
||||
const urlScheme = CoreUrlUtils.getUrlProtocol(url);
|
||||
const isExternalApp = urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile';
|
||||
|
||||
if (CoreCustomURLSchemes.isCustomURL(url)) {
|
||||
// Close the browser if it's a valid SSO URL.
|
||||
CoreCustomURLSchemes.handleCustomURL(url).catch((error) => {
|
||||
CoreCustomURLSchemes.treatHandleCustomURLError(error);
|
||||
});
|
||||
CoreUtils.closeInAppBrowser();
|
||||
|
||||
} else if (isExternalApp && url.includes('://token=')) {
|
||||
// It's an SSO token for another app. Close the IAB and show an error.
|
||||
CoreUtils.closeInAppBrowser();
|
||||
CoreDomUtils.showErrorModal(Translate.instant('core.login.contactyouradministratorissue', {
|
||||
$a: '<br><br>' + Translate.instant('core.errorurlschemeinvalidscheme', {
|
||||
$a: urlScheme,
|
||||
}),
|
||||
}));
|
||||
|
||||
} else if (CoreApp.isAndroid()) {
|
||||
// Check if the URL has a custom URL scheme. In Android they need to be opened manually.
|
||||
if (isExternalApp) {
|
||||
// Open in browser should launch the right app if found and do nothing if not found.
|
||||
CoreUtils.openInBrowser(url, { showBrowserWarning: false });
|
||||
|
||||
// At this point the InAppBrowser is showing a "Webpage not available" error message.
|
||||
// Try to navigate to last loaded URL so this error message isn't found.
|
||||
if (this.lastInAppUrl) {
|
||||
CoreUtils.openInApp(this.lastInAppUrl);
|
||||
} else {
|
||||
// No last URL loaded, close the InAppBrowser.
|
||||
CoreUtils.closeInAppBrowser();
|
||||
}
|
||||
} else {
|
||||
this.lastInAppUrl = protocol ? `${protocol}://${url}` : url;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check InAppBrowser closed.
|
||||
CoreEvents.on(CoreEvents.IAB_EXIT, () => {
|
||||
this.lastInAppUrl = '';
|
||||
|
||||
if (CoreLoginHelper.isWaitingForBrowser()) {
|
||||
CoreLoginHelper.stopWaitingForBrowser();
|
||||
CoreLoginHelper.checkLogout();
|
||||
}
|
||||
});
|
||||
|
||||
CorePlatform.resume.subscribe(() => {
|
||||
// Wait a second before setting it to false since in iOS there could be some frozen WS calls.
|
||||
setTimeout(() => {
|
||||
|
|
|
@ -12,18 +12,18 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror';
|
||||
|
||||
/**
|
||||
* Generic error returned by an Ajax call.
|
||||
*/
|
||||
export class CoreAjaxError extends CoreError {
|
||||
export class CoreAjaxError extends CoreSiteError {
|
||||
|
||||
available = 1; // @deprecated since app 4.0. AJAX endpoint should always be available in supported Moodle versions.
|
||||
status?: number;
|
||||
|
||||
constructor(message: string, available?: number, status?: number) {
|
||||
super(message);
|
||||
constructor(messageOrOptions: string | CoreSiteErrorOptions, available?: number, status?: number) {
|
||||
super(typeof messageOrOptions === 'string' ? { message: messageOrOptions } : messageOrOptions);
|
||||
|
||||
this.status = status;
|
||||
}
|
||||
|
|
|
@ -12,15 +12,14 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
|
||||
/**
|
||||
* Error returned by WS.
|
||||
*/
|
||||
export class CoreAjaxWSError extends CoreError {
|
||||
export class CoreAjaxWSError extends CoreSiteError {
|
||||
|
||||
exception?: string; // Name of the Moodle exception.
|
||||
errorcode?: string;
|
||||
warningcode?: string;
|
||||
link?: string; // Link to the site.
|
||||
moreinfourl?: string; // Link to a page with more info.
|
||||
|
@ -30,10 +29,12 @@ export class CoreAjaxWSError extends CoreError {
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(error: any, available?: number) {
|
||||
super(error.message || error.error);
|
||||
super({
|
||||
message: error.message || error.error,
|
||||
errorcode: error.errorcode,
|
||||
});
|
||||
|
||||
this.exception = error.exception;
|
||||
this.errorcode = error.errorcode;
|
||||
this.warningcode = error.warningcode;
|
||||
this.link = error.link;
|
||||
this.moreinfourl = error.moreinfourl;
|
||||
|
|
|
@ -23,6 +23,7 @@ import { CoreAjaxWSError } from './ajaxwserror';
|
|||
import { CoreCaptureError } from './captureerror';
|
||||
import { CoreNetworkError } from './network-error';
|
||||
import { CoreSiteError } from './siteerror';
|
||||
import { CoreLoginError } from './loginerror';
|
||||
import { CoreErrorWithOptions } from './errorwithtitle';
|
||||
import { CoreHttpError } from './httperror';
|
||||
|
||||
|
@ -35,6 +36,7 @@ export const CORE_ERRORS_CLASSES: Type<unknown>[] = [
|
|||
CoreNetworkError,
|
||||
CoreSilentError,
|
||||
CoreSiteError,
|
||||
CoreLoginError,
|
||||
CoreWSError,
|
||||
CoreErrorWithOptions,
|
||||
CoreHttpError,
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
// (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 { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror';
|
||||
|
||||
/**
|
||||
* Error returned when performing operations during login.
|
||||
*/
|
||||
export class CoreLoginError extends CoreSiteError {
|
||||
|
||||
title?: string;
|
||||
critical?: boolean;
|
||||
loggedOut?: boolean;
|
||||
|
||||
constructor(options: CoreLoginErrorOptions) {
|
||||
super(options);
|
||||
|
||||
this.title = options.title;
|
||||
this.critical = options.critical;
|
||||
this.loggedOut = options.loggedOut;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type CoreLoginErrorOptions = CoreSiteErrorOptions & {
|
||||
title?: string; // Error title.
|
||||
critical?: boolean; // Whether the error is important enough to abort the operation.
|
||||
loggedOut?: boolean; // Whether site has been marked as logged out.
|
||||
};
|
|
@ -13,29 +13,33 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
|
||||
/**
|
||||
* Error returned when performing operations regarding a site (check if it exists, authenticate user, etc.).
|
||||
* Error returned when performing operations regarding a site.
|
||||
*/
|
||||
export class CoreSiteError extends CoreError {
|
||||
|
||||
errorcode?: string;
|
||||
critical?: boolean;
|
||||
loggedOut?: boolean;
|
||||
errorDetails?: string;
|
||||
supportConfig?: CoreUserSupportConfig;
|
||||
|
||||
constructor(protected error: SiteError) {
|
||||
super(error.message);
|
||||
constructor(options: CoreSiteErrorOptions) {
|
||||
super(options.message);
|
||||
|
||||
this.errorcode = error.errorcode;
|
||||
this.critical = error.critical;
|
||||
this.loggedOut = error.loggedOut;
|
||||
this.errorcode = options.errorcode;
|
||||
this.errorDetails = options.errorDetails;
|
||||
this.supportConfig = options.supportConfig;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type SiteError = {
|
||||
export type CoreSiteErrorOptions = {
|
||||
message: string;
|
||||
errorcode?: string;
|
||||
critical?: boolean; // Whether the error is important enough to abort the operation.
|
||||
loggedOut?: boolean; // Whether site has been marked as logged out.
|
||||
errorcode?: string; // Technical error code useful for technical assistance.
|
||||
errorDetails?: string; // Technical error details useful for technical assistance.
|
||||
|
||||
// Configuration to use to contact site support. If this attribute is present, it means
|
||||
// that the error warrants contacting support.
|
||||
supportConfig?: CoreUserSupportConfig;
|
||||
};
|
||||
|
|
|
@ -60,6 +60,8 @@ import {
|
|||
import { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs';
|
||||
import { finalize, map, mergeMap } from 'rxjs/operators';
|
||||
import { firstValueFrom } from '../utils/rxjs';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
|
||||
|
||||
/**
|
||||
* QR Code type enumeration.
|
||||
|
@ -808,9 +810,7 @@ export class CoreSite {
|
|||
): Promise<T> {
|
||||
if (preSets.forceOffline) {
|
||||
// Don't call the WS, just fail.
|
||||
throw new CoreError(
|
||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
);
|
||||
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||
}
|
||||
|
||||
try {
|
||||
|
@ -1130,7 +1130,12 @@ export class CoreSite {
|
|||
);
|
||||
|
||||
if (!data || !data.responses) {
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw new CoreSiteError({
|
||||
supportConfig: new CoreUserAuthenticatedSupportConfig(this),
|
||||
message: Translate.instant('core.siteunavailablehelp', { site: this.siteUrl }),
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method: 'tool_mobile_call_external_functions' }),
|
||||
});
|
||||
}
|
||||
|
||||
requests.forEach((request, i) => {
|
||||
|
@ -1670,6 +1675,10 @@ export class CoreSite {
|
|||
*/
|
||||
async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise<CoreSitePublicConfigResponse> {
|
||||
if (!this.db) {
|
||||
if (options.readingStrategy === CoreSitesReadingStrategy.ONLY_CACHE) {
|
||||
throw new CoreError('Cache not available to read public config');
|
||||
}
|
||||
|
||||
return this.requestPublicConfig();
|
||||
}
|
||||
|
||||
|
@ -1714,9 +1723,7 @@ export class CoreSite {
|
|||
.catch(async () => {
|
||||
if (cachePreSets.forceOffline) {
|
||||
// Don't call the WS, just fail.
|
||||
throw new CoreError(
|
||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
);
|
||||
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||
}
|
||||
|
||||
// Call the WS.
|
||||
|
@ -2742,10 +2749,21 @@ export type CoreSiteConfigResponse = {
|
|||
warnings?: CoreWSExternalWarning[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Possible values for 'supportavailability' config.
|
||||
*/
|
||||
export const enum CoreSiteConfigSupportAvailability {
|
||||
Disabled = 0,
|
||||
Authenticated = 1,
|
||||
Anyone = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Site config indexed by name.
|
||||
*/
|
||||
export type CoreSiteConfig = {[name: string]: string};
|
||||
export type CoreSiteConfig = Record<string, string> & {
|
||||
supportavailability?: string; // String representation of CoreSiteConfigSupportAvailability.
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of WS tool_mobile_get_public_config.
|
||||
|
@ -2777,6 +2795,8 @@ export type CoreSitePublicConfigResponse = {
|
|||
agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled.
|
||||
supportname?: string; // Site support contact name (only if age verification is enabled).
|
||||
supportemail?: string; // Site support contact email (only if age verification is enabled).
|
||||
supportavailability?: CoreSiteConfigSupportAvailability;
|
||||
supportpage?: string; // Site support contact url.
|
||||
autolang?: number; // Whether to detect default language from browser setting.
|
||||
lang?: string; // Default language for the site.
|
||||
langmenu?: number; // Whether the language menu should be displayed.
|
||||
|
|
|
@ -63,7 +63,7 @@ function prepareStubs(config: Partial<CoreDatabaseConfiguration> = {}): [User[],
|
|||
});
|
||||
const table = new CoreDatabaseTableProxy<User>(config, database, 'users');
|
||||
|
||||
mockSingleton(CoreConfig, { isReady: () => Promise.resolve() });
|
||||
mockSingleton(CoreConfig, { ready: () => Promise.resolve() });
|
||||
|
||||
return [records, database, table];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<!--
|
||||
The markup for this component is rendered dynamically using the static render() method
|
||||
instead of using Angular's engine. The reason for using this approach is that this
|
||||
allows injecting this component into HTML directly, rather than requiring Angular
|
||||
to control its lifecycle.
|
||||
-->
|
|
@ -0,0 +1,88 @@
|
|||
.core-error-info {
|
||||
background: var(--gray-200);
|
||||
border-radius: var(--small-radius);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--gray-900);
|
||||
|
||||
p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.core-error-info--content {
|
||||
padding: var(--spacing-2) var(--spacing-2) 0 var(--spacing-2);
|
||||
|
||||
.core-error-info--code {
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.core-error-info--details {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.core-error-info--checkbox {
|
||||
display: none;
|
||||
|
||||
& + .core-error-info--content {
|
||||
max-height: calc(var(--font-size-sm) + 2 * var(--spacing-2));
|
||||
overflow: hidden;
|
||||
transition: max-height 600ms ease-in-out;
|
||||
|
||||
& + .core-error-info--toggle {
|
||||
display: flex;
|
||||
padding: var(--spacing-2);
|
||||
min-height: var(--a11y-min-target-size);
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
.core-error-info--hide-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&:checked + .core-error-info--content {
|
||||
max-height: 150px;
|
||||
|
||||
& + .core-error-info--toggle .core-error-info--hide-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& + .core-error-info--toggle .core-error-info--show-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
&.has-error-code .core-error-info--checkbox {
|
||||
|
||||
& + .core-error-info--content {
|
||||
max-height: calc(var(--font-size-normal) + 2 * var(--spacing-2));
|
||||
}
|
||||
|
||||
&:checked + .core-error-info--content {
|
||||
max-height: 170px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
// (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 { Component, ElementRef, Input, OnChanges, OnInit } from '@angular/core';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreForms } from '@singletons/form';
|
||||
import ChevronUpSVG from '!raw-loader!ionicons/dist/svg/chevron-up.svg';
|
||||
import ChevronDownSVG from '!raw-loader!ionicons/dist/svg/chevron-down.svg';
|
||||
|
||||
/**
|
||||
* Component to show error details.
|
||||
*
|
||||
* Given that this component has to be injected dynamically in some situations (for example, error alerts),
|
||||
* it can be rendered using the static render() method to get the raw HTML.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'core-error-info',
|
||||
templateUrl: 'core-error-info.html',
|
||||
styleUrls: ['error-info.scss'],
|
||||
})
|
||||
export class CoreErrorInfoComponent implements OnInit, OnChanges {
|
||||
|
||||
/**
|
||||
* Render an instance of the component into an HTML string.
|
||||
*
|
||||
* @param errorDetails Error details.
|
||||
* @param errorCode Error code.
|
||||
* @returns Component HTML.
|
||||
*/
|
||||
static render(errorDetails: string, errorCode?: string): string {
|
||||
const toggleId = CoreForms.uniqueId('error-info-toggle');
|
||||
const errorCodeLabel = Translate.instant('core.errorcode');
|
||||
const hideDetailsLabel = Translate.instant('core.errordetailshide');
|
||||
const showDetailsLabel = Translate.instant('core.errordetailsshow');
|
||||
|
||||
return `
|
||||
<div class="core-error-info ${errorCode ? 'has-error-code' : ''}">
|
||||
<input id="${toggleId}" type="checkbox" class="core-error-info--checkbox" />
|
||||
<div class="core-error-info--content">
|
||||
${errorCode ? `<p class="core-error-info--code"><strong>${errorCodeLabel}: ${errorCode}</strong></p>` : ''}
|
||||
<p class="core-error-info--details">${errorDetails}</p>
|
||||
</div>
|
||||
<label for="${toggleId}" class="core-error-info--toggle" aria-hidden="true">
|
||||
<span class="core-error-info--hide-content">
|
||||
${hideDetailsLabel}
|
||||
${ChevronUpSVG}
|
||||
</span>
|
||||
<span class="core-error-info--show-content">
|
||||
${showDetailsLabel}
|
||||
${ChevronDownSVG}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@Input() errorDetails!: string;
|
||||
@Input() errorCode?: string;
|
||||
|
||||
constructor(private element: ElementRef) {}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnChanges(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render component html in the element created by Angular.
|
||||
*/
|
||||
private render(): void {
|
||||
this.element.nativeElement.innerHTML = CoreErrorInfoComponent.render(this.errorDetails, this.errorCode);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
// (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 { Meta, moduleMetadata, Story } from '@storybook/angular';
|
||||
|
||||
import { story } from '@/storybook/utils/helpers';
|
||||
import { StorybookModule } from '@/storybook/storybook.module';
|
||||
|
||||
import { CoreErrorInfoComponent } from '@components/error-info/error-info';
|
||||
|
||||
interface Args {
|
||||
errorCode: string;
|
||||
errorDetails: string;
|
||||
}
|
||||
|
||||
export default <Meta<Args>> {
|
||||
title: 'Core/Error Info',
|
||||
component: CoreErrorInfoComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [CoreErrorInfoComponent],
|
||||
imports: [StorybookModule],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
errorCode: '',
|
||||
errorDetails:
|
||||
'AJAX endpoint not found. ' +
|
||||
'This can happen if the Moodle site is too old or it blocks access to this endpoint. ' +
|
||||
'The Moodle app only supports Moodle systems 3.5 onwards.',
|
||||
},
|
||||
};
|
||||
|
||||
const Template: Story<Args> = (args) => ({
|
||||
component: CoreErrorInfoComponent,
|
||||
props: args,
|
||||
});
|
||||
|
||||
export const Primary = story<Args>(Template);
|
|
@ -26,11 +26,11 @@ describe('CoreIconComponent', () => {
|
|||
expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0);
|
||||
|
||||
const icon = fixture.nativeElement.querySelector('ion-icon');
|
||||
const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || '';
|
||||
const name = icon?.getAttribute('name') || icon?.getAttribute('ng-reflect-name') || '';
|
||||
|
||||
expect(icon).not.toBeNull();
|
||||
expect(name).toEqual('fa-thumbs-up');
|
||||
expect(icon.getAttribute('role')).toEqual('presentation');
|
||||
expect(icon?.getAttribute('role')).toEqual('presentation');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -33,7 +33,7 @@ describe('CoreIframeComponent', () => {
|
|||
|
||||
const iframe = nativeElement.querySelector('iframe');
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe.src).toEqual('https://moodle.org/');
|
||||
expect(iframe?.src).toEqual('https://moodle.org/');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -27,7 +27,7 @@ describe('CoreUserAvatarComponent', () => {
|
|||
|
||||
const image = nativeElement.querySelector('img');
|
||||
expect(image).not.toBeNull();
|
||||
expect(image.src).toEqual(document.location.href + 'assets/img/user-avatar.png');
|
||||
expect(image?.src).toEqual(document.location.href + 'assets/img/user-avatar.png');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ import { CoreSite } from '@classes/site';
|
|||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreConstants } from '../constants';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { Translate } from '@singletons';
|
||||
|
||||
/**
|
||||
* Directive to handle external content.
|
||||
|
@ -217,7 +218,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O
|
|||
if (!site.canDownloadFiles() && isSiteFile) {
|
||||
this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken.
|
||||
|
||||
throw new CoreError('Site doesn\'t allow downloading files.');
|
||||
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
|
||||
}
|
||||
|
||||
const finalUrl = await this.getUrlToUse(targetAttr, url, site);
|
||||
|
|
|
@ -29,7 +29,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy {
|
|||
|
||||
element: HTMLElement;
|
||||
pressGesture?: Gesture;
|
||||
timeout?: NodeJS.Timeout;
|
||||
timeout?: number;
|
||||
|
||||
@Output() longPress = new EventEmitter();
|
||||
|
||||
|
@ -48,7 +48,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy {
|
|||
disableScroll: true,
|
||||
gestureName: 'longpress',
|
||||
onStart: (event) => {
|
||||
this.timeout = setTimeout(() => {
|
||||
this.timeout = window.setTimeout(() => {
|
||||
this.longPress.emit(event);
|
||||
|
||||
delete this.timeout;
|
||||
|
|
|
@ -33,7 +33,15 @@ describe('CoreFormatTextDirective', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockSingleton(CoreSites, { getSite: () => Promise.reject() });
|
||||
mockSingleton(CoreConfig, { get: (_, defaultValue) => defaultValue });
|
||||
mockSingleton(CoreConfig, {
|
||||
get(name, defaultValue) {
|
||||
if (defaultValue === undefined) {
|
||||
throw Error(`Default value not provided for '${name}'`);
|
||||
}
|
||||
|
||||
return Promise.resolve(defaultValue);
|
||||
},
|
||||
});
|
||||
mockSingleton(CoreFilter, { formatText: text => Promise.resolve(text) });
|
||||
mockSingleton(CoreFilterHelper, { getFiltersAndFormatText: text => Promise.resolve({ text, filters: [] }) });
|
||||
|
||||
|
@ -59,12 +67,12 @@ describe('CoreFormatTextDirective', () => {
|
|||
// Assert
|
||||
const text = fixture.nativeElement.querySelector('core-format-text');
|
||||
expect(text).not.toBeNull();
|
||||
expect(text.innerHTML).toEqual(sentence);
|
||||
expect(text?.innerHTML).toEqual(sentence);
|
||||
});
|
||||
|
||||
it('should format text', async () => {
|
||||
// Arrange
|
||||
mockSingleton(CoreFilter, { formatText: () => 'Formatted text' });
|
||||
mockSingleton(CoreFilter, { formatText: () => Promise.resolve('Formatted text') });
|
||||
|
||||
// Act
|
||||
const { nativeElement } = await renderTemplate(
|
||||
|
@ -75,7 +83,7 @@ describe('CoreFormatTextDirective', () => {
|
|||
// Assert
|
||||
const text = nativeElement.querySelector('core-format-text');
|
||||
expect(text).not.toBeNull();
|
||||
expect(text.textContent).toEqual('Formatted text');
|
||||
expect(text?.textContent).toEqual('Formatted text');
|
||||
|
||||
expect(CoreFilter.formatText).toHaveBeenCalledTimes(1);
|
||||
expect(CoreFilter.formatText).toHaveBeenCalledWith(
|
||||
|
@ -107,7 +115,7 @@ describe('CoreFormatTextDirective', () => {
|
|||
// Assert
|
||||
const text = nativeElement.querySelector('core-format-text');
|
||||
expect(text).not.toBeNull();
|
||||
expect(text.textContent).toEqual('Formatted text');
|
||||
expect(text?.textContent).toEqual('Formatted text');
|
||||
|
||||
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledTimes(1);
|
||||
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledWith(
|
||||
|
@ -131,7 +139,7 @@ describe('CoreFormatTextDirective', () => {
|
|||
mockSingleton(CoreFilepool, { getSrcByUrl: () => Promise.resolve('file://local-path') });
|
||||
mockSingleton(CoreSites, {
|
||||
getSite: () => Promise.resolve(site),
|
||||
getCurrentSite: () => Promise.resolve(site),
|
||||
getCurrentSite: () => site,
|
||||
});
|
||||
|
||||
// Act
|
||||
|
@ -145,7 +153,7 @@ describe('CoreFormatTextDirective', () => {
|
|||
// Assert
|
||||
const image = nativeElement.querySelector('img');
|
||||
expect(image).not.toBeNull();
|
||||
expect(image.src).toEqual('file://local-path/');
|
||||
expect(image?.src).toEqual('file://local-path/');
|
||||
|
||||
expect(CoreSites.getSite).toHaveBeenCalledWith(site.getId());
|
||||
expect(CoreFilepool.getSrcByUrl).toHaveBeenCalledTimes(1);
|
||||
|
@ -163,7 +171,7 @@ describe('CoreFormatTextDirective', () => {
|
|||
);
|
||||
const anchor = nativeElement.querySelector('a');
|
||||
|
||||
anchor.click();
|
||||
anchor?.click();
|
||||
|
||||
// Assert
|
||||
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
||||
|
|
|
@ -31,7 +31,7 @@ describe('CoreLinkDirective', () => {
|
|||
|
||||
const anchor = fixture.nativeElement.querySelector('a');
|
||||
expect(anchor).not.toBeNull();
|
||||
expect(anchor.href).toEqual('https://moodle.org/');
|
||||
expect(anchor?.href).toEqual('https://moodle.org/');
|
||||
});
|
||||
|
||||
it('should capture clicks', async () => {
|
||||
|
@ -46,7 +46,7 @@ describe('CoreLinkDirective', () => {
|
|||
|
||||
const anchor = nativeElement.querySelector('a');
|
||||
|
||||
anchor.click();
|
||||
anchor?.click();
|
||||
|
||||
// Assert
|
||||
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
||||
|
|
|
@ -27,7 +27,7 @@ import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
|||
})
|
||||
export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy {
|
||||
|
||||
protected element: HTMLIonButtonElement;
|
||||
protected element: HTMLIonButtonElement | HTMLElement;
|
||||
protected mutationObserver: MutationObserver;
|
||||
|
||||
constructor(element: ElementRef<HTMLIonButtonElement>) {
|
||||
|
@ -52,7 +52,11 @@ export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestr
|
|||
* @inheritdoc
|
||||
*/
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.element.componentOnReady();
|
||||
if ('componentOnReady' in this.element) {
|
||||
// This may be necessary if this is somehow called but Ionic's directives arent. This happens, for example,
|
||||
// in some tests such as the credentials page.
|
||||
await this.element.componentOnReady();
|
||||
}
|
||||
|
||||
this.mutationObserver.observe(this.element, { attributes: true, attributeFilter: ['aria-label'] });
|
||||
}
|
||||
|
|
|
@ -128,7 +128,7 @@ export class CoreCommentsViewerPage implements OnInit, OnDestroy {
|
|||
this.componentName = CoreNavigator.getRequiredRouteParam<string>('componentName');
|
||||
this.itemId = CoreNavigator.getRequiredRouteNumberParam('itemId');
|
||||
this.area = CoreNavigator.getRouteParam('area') || '';
|
||||
this.title = CoreNavigator.getRouteNumberParam('title') ||
|
||||
this.title = CoreNavigator.getRouteParam('title') ||
|
||||
Translate.instant('core.comments.comments');
|
||||
this.courseId = CoreNavigator.getRouteNumberParam('courseId');
|
||||
} catch (error) {
|
||||
|
|
|
@ -193,10 +193,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
|||
|
||||
// Completion deleted, add a warning if the completion status doesn't match.
|
||||
if (onlineComp.state != entry.completed) {
|
||||
result.warnings.push(Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
||||
name: courseName || courseId,
|
||||
error: Translate.instant('core.course.warningmanualcompletionmodified'),
|
||||
}));
|
||||
result.warnings.push({
|
||||
warningcode: 'apperror',
|
||||
message: Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
||||
name: courseName || courseId,
|
||||
error: Translate.instant('core.course.warningmanualcompletionmodified'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
|
@ -220,10 +223,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
|||
await CoreCourseOffline.deleteManualCompletion(entry.cmid, siteId);
|
||||
|
||||
// Completion deleted, add a warning.
|
||||
result.warnings.push(Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
||||
name: courseName || courseId,
|
||||
error: CoreTextUtils.getErrorMessageFromError(error),
|
||||
}));
|
||||
result.warnings.push({
|
||||
warningcode: 'apperror',
|
||||
message: Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
||||
name: courseName || courseId,
|
||||
error: CoreTextUtils.getErrorMessageFromError(error),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
@ -18,9 +18,11 @@ import { CoreLoginSiteOnboardingComponent } from './site-onboarding/site-onboard
|
|||
import { CoreLoginSiteHelpComponent } from './site-help/site-help';
|
||||
import { CoreLoginSitesComponent } from './sites/sites';
|
||||
import { CoreLoginMethodsComponent } from './login-methods/login-methods';
|
||||
import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CoreLoginExceededAttemptsComponent,
|
||||
CoreLoginSiteOnboardingComponent,
|
||||
CoreLoginSiteHelpComponent,
|
||||
CoreLoginSitesComponent,
|
||||
|
@ -30,6 +32,7 @@ import { CoreLoginMethodsComponent } from './login-methods/login-methods';
|
|||
CoreSharedModule,
|
||||
],
|
||||
exports: [
|
||||
CoreLoginExceededAttemptsComponent,
|
||||
CoreLoginSiteOnboardingComponent,
|
||||
CoreLoginSiteHelpComponent,
|
||||
CoreLoginSitesComponent,
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<ion-card class="core-danger-card">
|
||||
<ion-item>
|
||||
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>
|
||||
<ion-label>
|
||||
<p>
|
||||
<ng-content></ng-content>
|
||||
</p>
|
||||
<ion-button *ngIf="canContactSupport" fill="outline" color="medium" (click)="contactSupport()">
|
||||
{{ 'core.contactsupport' | translate }}
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
|
@ -0,0 +1,9 @@
|
|||
:host {
|
||||
|
||||
ion-button {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
// (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 { Component, Input, OnInit } from '@angular/core';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
|
||||
@Component({
|
||||
selector: 'core-login-exceeded-attempts',
|
||||
templateUrl: 'exceeded-attempts.html',
|
||||
styleUrls: ['./exceeded-attempts.scss'],
|
||||
})
|
||||
export class CoreLoginExceededAttemptsComponent implements OnInit {
|
||||
|
||||
@Input() supportConfig!: CoreUserSupportConfig;
|
||||
@Input() supportSubject?: string;
|
||||
|
||||
canContactSupport = false;
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.canContactSupport = this.supportConfig.canContactSupport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact site support.
|
||||
*/
|
||||
async contactSupport(): Promise<void> {
|
||||
await CoreUserSupport.contact({
|
||||
supportConfig: this.supportConfig,
|
||||
subject: this.supportSubject,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -12,34 +12,24 @@
|
|||
</ion-header>
|
||||
<ion-content>
|
||||
<ion-list>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2><strong>{{ 'core.login.faqcannotfindmysitequestion' | translate }}</strong></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p>{{ 'core.login.faqcannotfindmysiteanswer' | translate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2><strong>{{ 'core.login.faqwhatisurlquestion' | translate }}</strong></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap core-login-faqwhatisurlanswer">
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p [innerHTML]="'core.login.faqwhatisurlanswer' | translate: {$image: urlImageHtml}"></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<h2><strong>{{ 'core.login.faqcannotconnectquestion' | translate }}</strong></h2>
|
||||
<h2><strong>{{ 'core.login.faqcannotfindmysitequestion' | translate }}</strong></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p>{{ 'core.login.faqcannotconnectanswer' | translate }} {{ 'core.whoissiteadmin' | translate }}</p>
|
||||
<p>{{ 'core.login.faqcannotfindmysiteanswer' | translate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
|
@ -62,7 +52,7 @@
|
|||
</ion-item>
|
||||
<ion-item class="ion-text-wrap">
|
||||
<ion-label>
|
||||
<p>{{ 'core.login.faqtestappanswer' | translate }}</p>
|
||||
<p [innerHTML]="'core.login.faqtestappanswer' | translate"></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item class="ion-text-wrap" *ngIf="canScanQR">
|
||||
|
|
|
@ -6,20 +6,21 @@
|
|||
"cancel": "Cancel",
|
||||
"changepassword": "Change password",
|
||||
"changepasswordbutton": "Change password",
|
||||
"changepasswordhelp": "If you have problems changing your password, please contact your site administrator. \"Site Administrators\" are the people who manages the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.",
|
||||
"changepasswordhelp": "If you have problems changing your password, try again later or contact your school or learning provider.",
|
||||
"changepasswordreconnectinstructions": "If you didn't change your password correctly, you'll be asked to do it again.",
|
||||
"changepasswordsupportsubject": "Need help changing my password",
|
||||
"confirmdeletesite": "Are you sure you want to remove the account on {{sitename}}?",
|
||||
"connect": "Connect!",
|
||||
"connecttomoodle": "Connect to Moodle",
|
||||
"connecttomoodleapp": "You are trying to connect to a regular Moodle site. Please download the official Moodle app to access this site.",
|
||||
"connecttoworkplaceapp": "You are trying to connect to a Moodle Workplace site. Please download the Moodle Workplace app to access this site.",
|
||||
"contactyouradministrator": "Contact your site administrator for further help.",
|
||||
"contactyouradministratorissue": "Please ask your site administrator to check the following issue: {{$a}}",
|
||||
"createaccount": "Create my new account",
|
||||
"createuserandpass": "Choose your username and password",
|
||||
"credentialsdescription": "Please provide your username and password to log in.",
|
||||
"emailconfirmsent": "<p>An email should have been sent to your address at <b>{{$a}}</b></p>\n <p>It contains easy instructions to complete your registration.</p>\n <p>If you continue to have difficulty, contact the site administrator.</p>",
|
||||
"emailconfirmsentnoemail": "<p>An email should have been sent to your address.</p><p>It contains easy instructions to complete your registration.</p><p>If you continue to have difficulty, contact the site administrator.</p>",
|
||||
"credentialshelp": "If you have problems logging in, try again later or contact your school or learning provider.",
|
||||
"credentialssupportsubject": "Need help logging in",
|
||||
"emailconfirmsent": "<p>An email should have been sent to your address at <b>{{$a}}</b></p><p>It contains easy instructions to complete your registration.</p>",
|
||||
"emailconfirmsentnoemail": "<p>An email should have been sent to your address.</p><p>It contains easy instructions to complete your registration.</p>",
|
||||
"emailconfirmsentsuccess": "Confirmation email sent successfully",
|
||||
"emailnotmatch": "Emails do not match",
|
||||
"erroraccesscontrolalloworigin": "The cross-origin call you're trying to perform has been rejected. Please check https://docs.moodle.org/dev/Moodle_Mobile_development_using_Chrome_or_Chromium",
|
||||
|
@ -27,30 +28,32 @@
|
|||
"errorexampleurl": "The URL https://campus.example.edu is only an example URL, it's not a real site. <strong>Please use the URL of your school or organization's site.</strong>",
|
||||
"errorqrnoscheme": "This URL isn't a valid login URL.",
|
||||
"errorupdatesite": "An error occurred while updating the site's token.",
|
||||
"faqcannotconnectanswer": "Please, contact your site administrator.",
|
||||
"faqcannotconnectquestion": "I typed my site address correctly but I still can't connect.",
|
||||
"faqcannotfindmysiteanswer": "Have you typed the name correctly? It's also possible that your site is not included in our public sites directory. If you still can't find it, please enter your site address instead.",
|
||||
"faqcannotfindmysitequestion": "I can't find my site.",
|
||||
"exceededloginattempts": "Need help logging in? Try recovering your password or contact your site support.",
|
||||
"exceededloginattemptsfallback": "Need help logging in? Try recovering your password.",
|
||||
"exceededloginattemptssupportsubject": "I can't log in",
|
||||
"exceededpasswordresetattempts": "It seems you are having trouble accessing your account. You can contact your school or learning provider or try again later.",
|
||||
"exceededpasswordresetattemptssupportsubject": "I can't reset my password",
|
||||
"faqcannotfindmysiteanswer": "If you still can't find it, please contact your school or learning provider for help.",
|
||||
"faqcannotfindmysitequestion": "I can't find my site by address.",
|
||||
"faqsetupsiteanswer": "Visit {{$link}} to check out the different options you have to create your own Moodle site.",
|
||||
"faqsetupsitelinktitle": "Get started.",
|
||||
"faqsetupsitequestion": "I want to set up my own Moodle site.",
|
||||
"faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site\" field and click the \"Connect to your site\" button.",
|
||||
"faqtestappquestion": "I just want to test the app, what can I do?",
|
||||
"faqwhatisurlanswer": "<p>Every organisation has their own unique address or URL for their Moodle site. To find the address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"</li><li>Now you can log in to your site using your username and password.</li></ol>",
|
||||
"faqwhatisurlquestion": "What is my site address? How can I find my site URL?",
|
||||
"faqtestappanswer": "To test the app in a Moodle demo site, type \"teacher\" or \"student\" in the <strong>Your site</strong> field and tap <strong>Connect to your site</strong>.",
|
||||
"faqtestappquestion": "Can I test the app on a demo site?",
|
||||
"faqwhatisurlanswer": "<p>If you can't find your site by name, try searching by address instead.</p><p>To find your site address:</p><ol><li>Open a web browser and go to your Moodle site login page.</li><li>At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".<br>{{$image}}</li><li>Copy the address (don't copy the /login nor what comes after), paste it into <strong>Your site</strong> in the app, then tap <strong>Connect to your site</strong></li><li>Now you can log in to your site with your username and password.</li></ol>",
|
||||
"faqwhatisurlquestion": "How can I find my site?",
|
||||
"faqwhereisqrcode": "Where can I find the QR code?",
|
||||
"faqwhereisqrcodeanswer": "<p>If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}",
|
||||
"faqwhereisqrcodeanswer": "<p>If your school or learning provider has enabled it, you will find a QR code on the web site at the bottom of your user profile page.</p>{{$image}}",
|
||||
"findyoursite": "Find your site",
|
||||
"firsttime": "Is this your first time here?",
|
||||
"forcepasswordchangenotice": "You must change your password to proceed.",
|
||||
"forgotten": "Forgotten your username or password?",
|
||||
"help": "Help",
|
||||
"helpmelogin": "<p>There are many thousands of Moodle sites around the world. This app can only connect to Moodle sites that have specifically enabled Mobile app access.</p><p>If you can't connect to your Moodle site then you need to contact your site administrator and ask them to read <a href=\"http://docs.moodle.org/en/Mobile_app\" target=\"_blank\">http://docs.moodle.org/en/Mobile_app</a></p><p>To test the app in a Moodle demo site type <i>teacher</i> or <i>student</i> in the <i>Site address</i> field and click the <b>Connect button</b>.</p>",
|
||||
"instructions": "Instructions",
|
||||
"invalidaccount": "Please check your login details or ask your site administrator to check the site configuration.",
|
||||
"invalidaccount": "Please check your login details and try again.",
|
||||
"invaliddate": "Invalid date",
|
||||
"invalidemail": "Invalid email address",
|
||||
"invalidmoodleversion": "<p>Invalid Moodle site version. The Moodle app only supports Moodle systems {{$a}} onwards.</p>\n<p>You can contact your site administrators and ask them to update their Moodle system.</p>\n<p>\"Site Administrators\" are the people who manages the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.</p>",
|
||||
"invalidmoodleversion": "Invalid Moodle site version. The Moodle app only supports Moodle systems {{$a}} onwards.",
|
||||
"invalidsite": "The site URL is invalid.",
|
||||
"invalidtime": "Invalid time",
|
||||
"invalidurl": "Invalid URL specified",
|
||||
|
@ -64,7 +67,7 @@
|
|||
"missingemail": "Missing email address",
|
||||
"missingfirstname": "Missing given name",
|
||||
"missinglastname": "Missing surname",
|
||||
"mobileservicesnotenabled": "Mobile access is not enabled on your site. Please contact your site administrator if you think it should be enabled.",
|
||||
"mobileservicesnotenabled": "Mobile services are not enabled on the site.",
|
||||
"mustconfirm": "You need to confirm your account",
|
||||
"newaccount": "New account",
|
||||
"notloggedin": "You need to be logged in.",
|
||||
|
@ -94,7 +97,9 @@
|
|||
"recaptchaexpired": "Verification expired. Answer the security question again.",
|
||||
"recaptchaincorrect": "The security question answer is incorrect.",
|
||||
"reconnect": "Reconnect",
|
||||
"reconnecthelp": "If you have problems reconnecting, try again later or contact your school or learning provider.",
|
||||
"reconnectssodescription": "Your authentication token is invalid or has expired. You have to reconnect to the site. You need to log in to the site in a browser window.",
|
||||
"reconnectsupportsubject": "Need help reconnecting",
|
||||
"reconnecttosite": "Reconnect to the site",
|
||||
"removeaccount": "Remove account",
|
||||
"resendemail": "Resend email",
|
||||
|
@ -122,7 +127,7 @@
|
|||
"usernamerequired": "Username required",
|
||||
"usernotaddederror": "User not added - error",
|
||||
"visitchangepassword": "Do you want to visit the site to change the password?",
|
||||
"webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.",
|
||||
"webservicesnotenabled": "Web services are not enabled on the site.",
|
||||
"youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
|
||||
"yourenteredsite": "Connect to your site"
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { CoreSharedModule } from '@/core/shared.module';
|
|||
import { CoreLoginHasSitesGuard } from './guards/has-sites';
|
||||
import { CoreLoginComponentsModule } from './components/components.module';
|
||||
import { CoreLoginHelper } from './services/login-helper';
|
||||
import { CoreLoginForgottenPasswordPage } from '@features/login/pages/forgotten-password/forgotten-password';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
|
@ -42,8 +43,7 @@ const routes: Routes = [
|
|||
},
|
||||
{
|
||||
path: 'forgottenpassword',
|
||||
loadChildren: () => import('./pages/forgotten-password/forgotten-password.module')
|
||||
.then( m => m.CoreLoginForgottenPasswordPageModule),
|
||||
component: CoreLoginForgottenPasswordPage,
|
||||
},
|
||||
{
|
||||
path: 'changepassword',
|
||||
|
@ -70,5 +70,8 @@ const routes: Routes = [
|
|||
CoreLoginComponentsModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [
|
||||
CoreLoginForgottenPasswordPage,
|
||||
],
|
||||
})
|
||||
export class CoreLoginLazyModule {}
|
||||
|
|
|
@ -43,7 +43,7 @@ const appRoutes: Routes = [
|
|||
{
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
useValue: async () => {
|
||||
CoreCronDelegate.register(CoreLoginCronHandler.instance);
|
||||
|
||||
CoreEvents.on(CoreEvents.SESSION_EXPIRED, (data) => {
|
||||
|
@ -57,6 +57,8 @@ const appRoutes: Routes = [
|
|||
CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, (data) => {
|
||||
CoreLoginHelper.sitePolicyNotAgreed(data.siteId);
|
||||
});
|
||||
|
||||
await CoreLoginHelper.initialize();
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -15,12 +15,12 @@
|
|||
import { Component, OnDestroy } from '@angular/core';
|
||||
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
|
||||
/**
|
||||
* Page that shows instructions to change the password.
|
||||
|
@ -43,12 +43,12 @@ export class CoreLoginChangePasswordPage implements OnDestroy {
|
|||
}
|
||||
|
||||
/**
|
||||
* Show a help modal.
|
||||
* Show help modal.
|
||||
*/
|
||||
showHelp(): void {
|
||||
CoreDomUtils.showAlert(
|
||||
Translate.instant('core.help'),
|
||||
CoreUserSupport.showHelp(
|
||||
Translate.instant('core.login.changepasswordhelp'),
|
||||
Translate.instant('core.login.changepasswordsupportsubject'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@
|
|||
<ion-button fill="clear" (click)="openSettings()" [attr.aria-label]="'core.settings.appsettings' | translate">
|
||||
<ion-icon slot="icon-only" name="fas-cog" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="showHelp()" [attr.aria-label]="'core.help' | translate">
|
||||
<ion-icon slot="icon-only" name="far-question-circle" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
@ -30,6 +33,16 @@
|
|||
<p class="core-siteurl">{{siteUrl}}</p>
|
||||
</div>
|
||||
|
||||
<core-login-exceeded-attempts *ngIf="supportConfig && loginAttempts >= 3" [supportConfig]="supportConfig"
|
||||
[supportSubject]="'core.login.exceededloginattemptssupportsubject' | translate">
|
||||
<span *ngIf="canContactSupport">
|
||||
{{ 'core.login.exceededloginattempts' | translate }}
|
||||
</span>
|
||||
<span *ngIf="!canContactSupport">
|
||||
{{ 'core.login.exceededloginattemptsfallback' | translate }}
|
||||
</span>
|
||||
</core-login-exceeded-attempts>
|
||||
|
||||
<form [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
|
||||
<ion-item *ngIf="siteChecked && !isBrowserSSO">
|
||||
<ion-label class="sr-only">{{ 'core.login.username' | translate }}</ion-label>
|
||||
|
|
|
@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
|
|||
import { RouterModule, Routes } from '@angular/router';
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreLoginComponentsModule } from '@features/login/components/components.module';
|
||||
import { CoreLoginCredentialsPage } from './credentials';
|
||||
|
||||
const routes: Routes = [
|
||||
|
@ -29,6 +30,7 @@ const routes: Routes = [
|
|||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
CoreLoginComponentsModule,
|
||||
],
|
||||
declarations: [
|
||||
CoreLoginCredentialsPage,
|
||||
|
|
|
@ -28,6 +28,9 @@ import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes
|
|||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreForms } from '@singletons/form';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
|
||||
|
||||
/**
|
||||
* Page to enter the user credentials.
|
||||
|
@ -54,6 +57,9 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
|||
isFixedUrlSet = false;
|
||||
showForgottenPassword = true;
|
||||
showScanQR = false;
|
||||
loginAttempts = 0;
|
||||
supportConfig?: CoreUserSupportConfig;
|
||||
canContactSupport?: boolean;
|
||||
|
||||
protected siteConfig?: CoreSitePublicConfigResponse;
|
||||
protected eventThrown = false;
|
||||
|
@ -72,11 +78,12 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
|||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
this.siteUrl = CoreNavigator.getRequiredRouteParam<string>('siteUrl');
|
||||
|
||||
this.siteName = CoreNavigator.getRouteParam('siteName');
|
||||
this.logoUrl = !CoreConstants.CONFIG.forceLoginLogo && CoreNavigator.getRouteParam('logoUrl') || undefined;
|
||||
this.siteConfig = CoreNavigator.getRouteParam('siteConfig');
|
||||
this.siteConfig = CoreNavigator.getRouteParam<CoreSitePublicConfigResponse>('siteConfig');
|
||||
this.urlToOpen = CoreNavigator.getRouteParam('urlToOpen');
|
||||
this.supportConfig = this.siteConfig && new CoreUserGuestSupportConfig(this.siteConfig);
|
||||
this.canContactSupport = this.supportConfig?.canContactSupport();
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModal(error);
|
||||
|
||||
|
@ -124,6 +131,16 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help modal.
|
||||
*/
|
||||
showHelp(): void {
|
||||
CoreUserSupport.showHelp(
|
||||
Translate.instant('core.login.credentialshelp'),
|
||||
Translate.instant('core.login.credentialssupportsubject'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get site config and check if it requires SSO login.
|
||||
* This should be used only if a fixed URL is set, otherwise this check is already performed in CoreLoginSitePage.
|
||||
|
@ -226,7 +243,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
|||
// Site wasn't checked (it failed) or a previous check determined it was SSO. Let's check again.
|
||||
await this.checkSite(siteUrl);
|
||||
|
||||
if (!this.isBrowserSSO) {
|
||||
if (!this.isBrowserSSO && this.siteChecked) {
|
||||
// Site doesn't use browser SSO, throw app's login again.
|
||||
return this.login();
|
||||
}
|
||||
|
@ -274,6 +291,8 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
|||
} else if (error.errorcode == 'forcepasswordchangenotice') {
|
||||
// Reset password field.
|
||||
this.credForm.controls.password.reset();
|
||||
} else if (error.errorcode === 'invalidlogin') {
|
||||
this.loginAttempts++;
|
||||
}
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
|
|
|
@ -11,6 +11,10 @@
|
|||
</ion-header>
|
||||
<ion-content>
|
||||
<div class="list-item-limited-width">
|
||||
<core-login-exceeded-attempts *ngIf="canContactSupport && wasPasswordResetRequestedRecently" [supportConfig]="supportConfig"
|
||||
[supportSubject]="'core.login.exceededpasswordresetattemptssupportsubject' | translate">
|
||||
{{ 'core.login.exceededpasswordresetattempts' | translate }}
|
||||
</core-login-exceeded-attempts>
|
||||
|
||||
<ion-list>
|
||||
<ion-item class="ion-text-wrap">
|
||||
|
|
|
@ -22,6 +22,9 @@ import { CoreWSExternalWarning } from '@services/ws';
|
|||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreForms } from '@singletons/form';
|
||||
import { CorePlatform } from '@services/platform';
|
||||
import { CoreSitePublicConfigResponse } from '@classes/site';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
|
||||
|
||||
/**
|
||||
* Page to recover a forgotten password.
|
||||
|
@ -37,16 +40,16 @@ export class CoreLoginForgottenPasswordPage implements OnInit {
|
|||
myForm!: FormGroup;
|
||||
siteUrl!: string;
|
||||
autoFocus!: boolean;
|
||||
supportConfig?: CoreUserSupportConfig;
|
||||
canContactSupport?: boolean;
|
||||
wasPasswordResetRequestedRecently = false;
|
||||
|
||||
constructor(
|
||||
protected formBuilder: FormBuilder,
|
||||
) {
|
||||
}
|
||||
constructor(protected formBuilder: FormBuilder) {}
|
||||
|
||||
/**
|
||||
* Initialize the component.
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
async ngOnInit(): Promise<void> {
|
||||
const siteUrl = CoreNavigator.getRouteParam<string>('siteUrl');
|
||||
if (!siteUrl) {
|
||||
CoreDomUtils.showErrorModal('Site URL not supplied.');
|
||||
|
@ -55,12 +58,18 @@ export class CoreLoginForgottenPasswordPage implements OnInit {
|
|||
return;
|
||||
}
|
||||
|
||||
const siteConfig = CoreNavigator.getRouteParam<CoreSitePublicConfigResponse>('siteConfig');
|
||||
|
||||
this.siteUrl = siteUrl;
|
||||
this.autoFocus = CorePlatform.is('tablet');
|
||||
this.myForm = this.formBuilder.group({
|
||||
field: ['username', Validators.required],
|
||||
value: [CoreNavigator.getRouteParam<string>('username') || '', Validators.required],
|
||||
});
|
||||
|
||||
this.supportConfig = siteConfig && new CoreUserGuestSupportConfig(siteConfig);
|
||||
this.canContactSupport = this.supportConfig?.canContactSupport();
|
||||
this.wasPasswordResetRequestedRecently = await CoreLoginHelper.wasPasswordResetRequestedRecently(siteUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -101,8 +110,9 @@ export class CoreLoginForgottenPasswordPage implements OnInit {
|
|||
// Success.
|
||||
CoreForms.triggerFormSubmittedEvent(this.formElement, true);
|
||||
|
||||
CoreDomUtils.showAlert(Translate.instant('core.success'), response.notice);
|
||||
CoreNavigator.back();
|
||||
await CoreDomUtils.showAlert(Translate.instant('core.success'), response.notice);
|
||||
await CoreNavigator.back();
|
||||
await CoreLoginHelper.passwordResetRequested(this.siteUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
CoreDomUtils.showErrorModal(error);
|
||||
|
|
|
@ -7,6 +7,12 @@
|
|||
<ion-title>
|
||||
<h1>{{ 'core.login.reconnect' | translate }}</h1>
|
||||
</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button fill="clear" (click)="showHelp()" [attr.aria-label]="'core.help' | translate">
|
||||
<ion-icon slot="icon-only" name="far-question-circle" aria-hidden="true"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content class="ion-padding" (keydown)="keyDown($event)" (keyup)="keyUp($event)">
|
||||
|
@ -36,6 +42,16 @@
|
|||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-card>
|
||||
|
||||
<core-login-exceeded-attempts *ngIf="supportConfig && reconnectAttempts >= 3" [supportConfig]="supportConfig"
|
||||
[supportSubject]="'core.login.exceededloginattemptssupportsubject' | translate">
|
||||
<span *ngIf="canContactSupport">
|
||||
{{ 'core.login.exceededloginattempts' | translate }}
|
||||
</span>
|
||||
<span *ngIf="!canContactSupport">
|
||||
{{ 'core.login.exceededloginattemptsfallback' | translate }}
|
||||
</span>
|
||||
</core-login-exceeded-attempts>
|
||||
</div>
|
||||
<form *ngIf="!isOAuth" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #reconnectForm>
|
||||
<ion-item class="ion-text-wrap core-username item-interactive">
|
||||
|
|
|
@ -26,6 +26,10 @@ import { CoreEvents } from '@singletons/events';
|
|||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreNavigator, CoreRedirectPayload } from '@services/navigator';
|
||||
import { CoreForms } from '@singletons/form';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
|
||||
import { Translate } from '@singletons';
|
||||
|
||||
/**
|
||||
* Page to enter the user password to reconnect to a site.
|
||||
|
@ -55,6 +59,9 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
|||
siteId!: string;
|
||||
showScanQR = false;
|
||||
showLoading = true;
|
||||
reconnectAttempts = 0;
|
||||
supportConfig?: CoreUserSupportConfig;
|
||||
canContactSupport?: boolean;
|
||||
|
||||
protected siteConfig?: CoreSitePublicConfigResponse;
|
||||
protected viewLeft = false;
|
||||
|
@ -101,6 +108,8 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
|||
this.userAvatar = site.infos.userpictureurl;
|
||||
this.siteUrl = site.infos.siteurl;
|
||||
this.siteName = site.getSiteName();
|
||||
this.supportConfig = new CoreUserAuthenticatedSupportConfig(site);
|
||||
this.canContactSupport = this.supportConfig.canContactSupport();
|
||||
|
||||
// If login was OAuth we should only reach this page if the OAuth method ID has changed.
|
||||
this.isOAuth = site.isOAuth();
|
||||
|
@ -133,6 +142,16 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help modal.
|
||||
*/
|
||||
showHelp(): void {
|
||||
CoreUserSupport.showHelp(
|
||||
Translate.instant('core.login.reconnecthelp'),
|
||||
Translate.instant('core.login.reconnectsupportsubject'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get some data (like identity providers) from the site config.
|
||||
*/
|
||||
|
@ -243,6 +262,8 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
|||
} else if (error.errorcode == 'forcepasswordchangenotice') {
|
||||
// Reset password field.
|
||||
this.credForm.controls.password.reset();
|
||||
} else if (error.errorcode == 'invalidlogin') {
|
||||
this.reconnectAttempts++;
|
||||
}
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
|
|
|
@ -27,7 +27,6 @@ import {
|
|||
CoreLoginSiteFinderSettings,
|
||||
CoreLoginSiteSelectorListMethod,
|
||||
} from '@features/login/services/login-helper';
|
||||
import { CoreSite } from '@classes/site';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreConstants } from '@/core/constants';
|
||||
import { Translate } from '@singletons';
|
||||
|
@ -40,6 +39,13 @@ import { CoreCustomURLSchemes, CoreCustomURLSchemesHandleError } from '@services
|
|||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreForms } from '@singletons/form';
|
||||
import { AlertButton } from '@ionic/core';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
import { CoreErrorInfoComponent } from '@components/error-info/error-info';
|
||||
import { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
|
||||
import { CoreLoginError } from '@classes/errors/loginerror';
|
||||
import { CoreSite } from '@classes/site';
|
||||
|
||||
/**
|
||||
* Site (url) chooser when adding a new site.
|
||||
|
@ -380,43 +386,73 @@ export class CoreLoginSitePage implements OnInit {
|
|||
* @param url The URL the user was trying to connect to.
|
||||
* @param error Error to display.
|
||||
*/
|
||||
protected showLoginIssue(url: string | null, error: CoreError): void {
|
||||
protected async showLoginIssue(url: string | null, error: CoreError): Promise<void> {
|
||||
let errorMessage = CoreDomUtils.getErrorMessage(error);
|
||||
let siteExists = false;
|
||||
let supportConfig: CoreUserSupportConfig | undefined = undefined;
|
||||
let errorTitle: string | undefined;
|
||||
let errorDetails: string | undefined;
|
||||
let errorCode: string | undefined;
|
||||
|
||||
if (errorMessage == Translate.instant('core.cannotconnecttrouble')) {
|
||||
const found = this.sites.find((site) => site.url == url);
|
||||
|
||||
if (!found) {
|
||||
errorMessage += ' ' + Translate.instant('core.cannotconnectverify');
|
||||
}
|
||||
if (error instanceof CoreSiteError) {
|
||||
supportConfig = error.supportConfig;
|
||||
errorDetails = error.errorDetails;
|
||||
errorCode = error.errorcode;
|
||||
siteExists = supportConfig instanceof CoreUserGuestSupportConfig;
|
||||
}
|
||||
|
||||
let message = '<p>' + errorMessage + '</p>';
|
||||
if (url) {
|
||||
const fullUrl = CoreUrlUtils.isAbsoluteURL(url) ? url : 'https://' + url;
|
||||
message += '<p padding><a href="' + fullUrl + '" core-link>' + url + '</a></p>';
|
||||
if (error instanceof CoreLoginError) {
|
||||
errorTitle = error.title;
|
||||
}
|
||||
|
||||
const buttons: AlertButton[] = [
|
||||
{
|
||||
text: Translate.instant('core.needhelp'),
|
||||
cssClass: 'core-login-need-help',
|
||||
handler: (): void => {
|
||||
this.showHelp();
|
||||
},
|
||||
},
|
||||
if (errorDetails) {
|
||||
errorMessage = `<p>${errorMessage}</p><div class="core-error-info-container"></div>`;
|
||||
}
|
||||
|
||||
const alertSupportConfig = supportConfig;
|
||||
const buttons = [
|
||||
{
|
||||
text: Translate.instant('core.tryagain'),
|
||||
role: 'cancel',
|
||||
},
|
||||
];
|
||||
alertSupportConfig?.canContactSupport()
|
||||
? {
|
||||
text: Translate.instant('core.contactsupport'),
|
||||
handler: () => CoreUserSupport.contact({
|
||||
supportConfig: alertSupportConfig,
|
||||
subject: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
message: `Error: ${errorCode}\n\n${errorDetails}`,
|
||||
}),
|
||||
}
|
||||
: (
|
||||
!siteExists
|
||||
? {
|
||||
text: Translate.instant('core.needhelp'),
|
||||
cssClass: 'core-login-need-help',
|
||||
handler: () => this.showHelp(),
|
||||
}
|
||||
: null
|
||||
),
|
||||
].filter(button => !!button);
|
||||
|
||||
// @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0.
|
||||
CoreDomUtils.showAlertWithOptions({
|
||||
header: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
message,
|
||||
buttons,
|
||||
const alertElement = await CoreDomUtils.showAlertWithOptions({
|
||||
header: errorTitle ?? (
|
||||
siteExists
|
||||
? Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION })
|
||||
: Translate.instant('core.sitenotfound')
|
||||
),
|
||||
message: errorMessage ?? Translate.instant('core.sitenotfoundhelp'),
|
||||
buttons: buttons as AlertButton[],
|
||||
});
|
||||
|
||||
if (errorDetails) {
|
||||
const containerElement = alertElement.querySelector('.core-error-info-container');
|
||||
|
||||
if (containerElement) {
|
||||
containerElement.innerHTML = CoreErrorInfoComponent.render(errorDetails, errorCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -39,6 +39,8 @@ import { CorePushNotifications } from '@features/pushnotifications/services/push
|
|||
import { CoreText } from '@singletons/text';
|
||||
import { CorePromisedValue } from '@classes/promised-value';
|
||||
|
||||
const PASSWORD_RESETS_CONFIG_KEY = 'password-resets';
|
||||
|
||||
/**
|
||||
* Helper provider that provides some common features regarding authentication.
|
||||
*/
|
||||
|
@ -63,6 +65,13 @@ export class CoreLoginHelperProvider {
|
|||
this.logger = CoreLogger.getInstance('CoreLoginHelper');
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize service.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
this.cleanUpPasswordResets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept site policy.
|
||||
*
|
||||
|
@ -183,6 +192,7 @@ export class CoreLoginHelperProvider {
|
|||
await CoreNavigator.navigate('/login/forgottenpassword', {
|
||||
params: {
|
||||
siteUrl,
|
||||
siteConfig,
|
||||
username,
|
||||
},
|
||||
});
|
||||
|
@ -1457,6 +1467,65 @@ export class CoreLoginHelperProvider {
|
|||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a password reset has been requested for a given site.
|
||||
*
|
||||
* @param siteUrl Site url.
|
||||
*/
|
||||
async passwordResetRequested(siteUrl: string): Promise<void> {
|
||||
const passwordResets = await this.getPasswordResets();
|
||||
|
||||
passwordResets[siteUrl] = Date.now();
|
||||
|
||||
await CoreConfig.set(PASSWORD_RESETS_CONFIG_KEY, JSON.stringify(passwordResets));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find out if a password reset has been requested recently for a given site.
|
||||
*
|
||||
* @param siteUrl Site url.
|
||||
* @return Whether a password reset has been requested recently.
|
||||
*/
|
||||
async wasPasswordResetRequestedRecently(siteUrl: string): Promise<boolean> {
|
||||
const passwordResets = await this.getPasswordResets();
|
||||
|
||||
return siteUrl in passwordResets
|
||||
&& passwordResets[siteUrl] > Date.now() - CoreConstants.MILLISECONDS_HOUR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired password reset records from the database.
|
||||
*/
|
||||
async cleanUpPasswordResets(): Promise<void> {
|
||||
const passwordResets = await this.getPasswordResets();
|
||||
const siteUrls = Object.keys(passwordResets);
|
||||
|
||||
for (const siteUrl of siteUrls) {
|
||||
if (passwordResets[siteUrl] > Date.now() - CoreConstants.MILLISECONDS_HOUR) {
|
||||
continue;
|
||||
}
|
||||
|
||||
delete passwordResets[siteUrl];
|
||||
}
|
||||
|
||||
if (Object.values(passwordResets).length === 0) {
|
||||
await CoreConfig.delete(PASSWORD_RESETS_CONFIG_KEY);
|
||||
} else {
|
||||
await CoreConfig.set(PASSWORD_RESETS_CONFIG_KEY, JSON.stringify(passwordResets));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a record indexing the last time a password reset was requested for a site.
|
||||
*
|
||||
* @returns Password resets.
|
||||
*/
|
||||
protected async getPasswordResets(): Promise<Record<string, number>> {
|
||||
const passwordResetsJson = await CoreConfig.get(PASSWORD_RESETS_CONFIG_KEY, '{}');
|
||||
|
||||
return CoreTextUtils.parseJSON<Record<string, number>>(passwordResetsJson, {});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const CoreLoginHelper = makeSingleton(CoreLoginHelperProvider);
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
@auth @core_auth @app @javascript @lms_from4.0 @lms_upto4.0
|
||||
Feature: Test basic usage of login in app
|
||||
I need basic login functionality to work
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname |
|
||||
| student1 | david | student |
|
||||
|
||||
Scenario: Forgot password
|
||||
When I enter the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
And I set the field "Enter either username or email address" to "student1"
|
||||
And I press "Search" in the app
|
||||
Then I should find "Success" in the app
|
||||
|
||||
When I press "OK" in the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
Then I should find "Contact support" in the app
|
|
@ -0,0 +1,19 @@
|
|||
@auth @core_auth @app @javascript @lms_upto3.11
|
||||
Feature: Test basic usage of login in app
|
||||
I need basic login functionality to work
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname |
|
||||
| student1 | david | student |
|
||||
|
||||
Scenario: Forgot password
|
||||
When I enter the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
And I set the field "Enter either username or email address" to "student1"
|
||||
And I press "Search" in the app
|
||||
Then I should find "Success" in the app
|
||||
|
||||
When I press "OK" in the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
Then I should not find "Contact support" in the app
|
|
@ -39,14 +39,10 @@ Feature: Test basic usage of login in app
|
|||
But I should not find "Log in" in the app
|
||||
|
||||
Scenario: Add a non existing account
|
||||
When I enter the app
|
||||
And I log in as "student1"
|
||||
When I log out in the app
|
||||
And I press "Add" in the app
|
||||
And I set the field "Your site" to "Wrong Site Address" in the app
|
||||
And I press enter in the app
|
||||
Then I should find "Cannot connect" in the app
|
||||
And I should find "Wrong Site Address" in the app
|
||||
When I launch the app
|
||||
And I set the field "Your site" to "wrongsiteaddress" in the app
|
||||
And I press "Connect to your site" in the app
|
||||
Then I should find "Site not found" in the app
|
||||
|
||||
Scenario: Add a non existing account from accounts switcher
|
||||
When I enter the app
|
||||
|
@ -55,10 +51,9 @@ Feature: Test basic usage of login in app
|
|||
And I press "Switch account" in the app
|
||||
And I press "Add" in the app
|
||||
And I wait the app to restart
|
||||
And I set the field "Your site" to "Wrong Site Address" in the app
|
||||
And I press enter in the app
|
||||
Then I should find "Cannot connect" in the app
|
||||
And I should find "Wrong Site Address" in the app
|
||||
And I set the field "Your site" to "wrongsiteaddress" in the app
|
||||
And I press "Connect to your site" in the app
|
||||
Then I should find "Site not found" in the app
|
||||
|
||||
Scenario: Log out from the app
|
||||
Given I entered the app as "student1"
|
||||
|
@ -139,3 +134,17 @@ Feature: Test basic usage of login in app
|
|||
|
||||
When I press "Reconnect" in the app
|
||||
Then I should find "Acceptance test site" in the app
|
||||
|
||||
@lms_from4.1
|
||||
Scenario: Forgot password
|
||||
Given the following config values are set as admin:
|
||||
| supportavailability | 2 |
|
||||
When I enter the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
And I set the field "Enter either username or email address" to "student1"
|
||||
And I press "Search" in the app
|
||||
Then I should find "Success" in the app
|
||||
|
||||
When I press "OK" in the app
|
||||
And I press "Forgotten your username or password?" in the app
|
||||
Then I should find "Contact support" in the app
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
// (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 { CoreSharedModule } from '@/core/shared.module';
|
||||
import { findElement, mockSingleton, renderPageComponent, requireElement } from '@/testing/utils';
|
||||
import { CoreLoginError } from '@classes/errors/loginerror';
|
||||
import { CoreLoginComponentsModule } from '@features/login/components/components.module';
|
||||
import { CoreLoginCredentialsPage } from '@features/login/pages/credentials/credentials';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
||||
describe('Credentials page', () => {
|
||||
|
||||
it('renders', async () => {
|
||||
// Arrange.
|
||||
const siteUrl = 'https://campus.example.edu';
|
||||
|
||||
mockSingleton(CoreSites, {
|
||||
getPublicSiteConfigByUrl: async () => ({
|
||||
wwwroot: 'https://campus.example.edu',
|
||||
httpswwwroot: 'https://campus.example.edu',
|
||||
sitename: 'Example Campus',
|
||||
guestlogin: 0,
|
||||
rememberusername: 0,
|
||||
authloginviaemail: 0,
|
||||
registerauth: '',
|
||||
forgottenpasswordurl: '',
|
||||
authinstructions: '',
|
||||
authnoneenabled: 0,
|
||||
enablewebservices: 1,
|
||||
enablemobilewebservice: 1,
|
||||
maintenanceenabled: 0,
|
||||
maintenancemessage: '',
|
||||
typeoflogin: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
// Act.
|
||||
const fixture = await renderPageComponent(CoreLoginCredentialsPage, {
|
||||
routeParams: { siteUrl },
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
CoreLoginComponentsModule,
|
||||
],
|
||||
});
|
||||
|
||||
// Assert.
|
||||
expect(findElement(fixture, '.core-siteurl', siteUrl)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('suggests contacting support after multiple failed attempts', async () => {
|
||||
// Arrange.
|
||||
mockSingleton(CoreSites, {
|
||||
getUserToken: () => {
|
||||
throw new CoreLoginError({
|
||||
message: '',
|
||||
errorcode: 'invalidlogin',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const fixture = await renderPageComponent(CoreLoginCredentialsPage, {
|
||||
routeParams: {
|
||||
siteUrl: 'https://campus.example.edu',
|
||||
siteConfig: { supportpage: '' },
|
||||
},
|
||||
imports: [
|
||||
CoreSharedModule,
|
||||
CoreLoginComponentsModule,
|
||||
],
|
||||
});
|
||||
|
||||
// Act.
|
||||
const form = requireElement<HTMLFormElement>(fixture, 'form');
|
||||
const formControls = fixture.componentInstance.credForm.controls;
|
||||
|
||||
formControls['username'].setValue('student');
|
||||
formControls['password'].setValue('secret');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
form.submit();
|
||||
|
||||
await fixture.whenRenderingDone();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
// Assert.
|
||||
expect(findElement(fixture, 'ion-label', 'core.login.exceededloginattempts')).not.toBeNull();
|
||||
});
|
||||
|
||||
});
|
|
@ -66,6 +66,14 @@
|
|||
<p class="item-heading">{{ 'core.settings.preferences' | translate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item *ngIf="displayContactSupport" button (click)="contactSupport($event)"
|
||||
[attr.aria-label]="'core.user.support' | translate" detail="true" detailIcon="open-outline" class="core-user-menu-support">
|
||||
<ion-icon name="fas-envelope" slot="start" aria-hidden="true"></ion-icon>
|
||||
<ion-label>
|
||||
<p class="item-heading">{{ 'core.user.support' | translate }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</core-loading>
|
||||
</ion-content>
|
||||
|
|
|
@ -18,6 +18,8 @@ import { CoreSite, CoreSiteInfo } from '@classes/site';
|
|||
import { CoreFilter } from '@features/filter/services/filter';
|
||||
import { CoreLoginSitesComponent } from '@features/login/components/sites/sites';
|
||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
import { CoreUser, CoreUserProfile } from '@features/user/services/user';
|
||||
import {
|
||||
CoreUserProfileHandlerData,
|
||||
|
@ -51,6 +53,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
|||
handlersLoaded = false;
|
||||
user?: CoreUserProfile;
|
||||
displaySwitchAccount = true;
|
||||
displayContactSupport = false;
|
||||
removeAccountOnLogout = false;
|
||||
|
||||
protected subscription!: Subscription;
|
||||
|
@ -65,6 +68,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
|||
this.siteName = currentSite.getSiteName();
|
||||
this.siteUrl = currentSite.getURL();
|
||||
this.displaySwitchAccount = !currentSite.isFeatureDisabled('NoDelegate_SwitchAccount');
|
||||
this.displayContactSupport = new CoreUserAuthenticatedSupportConfig(currentSite).canContactSupport();
|
||||
this.removeAccountOnLogout = !!CoreConstants.CONFIG.removeaccountonlogout;
|
||||
|
||||
this.loadSiteLogo(currentSite);
|
||||
|
@ -173,6 +177,16 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
|||
handler.action(event, this.user, CoreUserDelegateContext.USER_MENU);
|
||||
}
|
||||
|
||||
/**
|
||||
* Contact site support.
|
||||
*
|
||||
* @param event Click event.
|
||||
*/
|
||||
async contactSupport(event: Event): Promise<void> {
|
||||
await this.close(event);
|
||||
await CoreUserSupport.contact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the user.
|
||||
*
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
import { Input, OnInit, ElementRef, Directive } from '@angular/core';
|
||||
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreTextUtils } from '@services/utils/text';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreSitePluginsPluginContentComponent } from '../components/plugin-content/plugin-content';
|
||||
|
@ -72,7 +73,12 @@ export class CoreSitePluginsCallWSOnClickBaseDirective extends CoreSitePluginsCa
|
|||
await super.callWS();
|
||||
} catch (error) {
|
||||
if (this.showError === undefined || CoreUtils.isTrueOrOne(this.showError)) {
|
||||
CoreDomUtils.showErrorModalDefault(error, 'core.serverconnection', true);
|
||||
CoreDomUtils.showErrorModalDefault(
|
||||
error,
|
||||
Translate.instant('core.serverconnection', {
|
||||
details: CoreTextUtils.getErrorMessageFromError(error) ?? 'Unknown error',
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
modal.dismiss();
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
// (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 { CoreSite, CoreSiteConfigSupportAvailability } from '@classes/site';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUserSupportConfig } from './support-config';
|
||||
|
||||
/**
|
||||
* Support config for an authenticated user.
|
||||
*/
|
||||
export class CoreUserAuthenticatedSupportConfig extends CoreUserSupportConfig {
|
||||
|
||||
/**
|
||||
* Get config for the current site.
|
||||
*
|
||||
* @returns Support config.
|
||||
*/
|
||||
static forCurrentSite(): CoreUserAuthenticatedSupportConfig {
|
||||
return new CoreUserAuthenticatedSupportConfig(CoreSites.getRequiredCurrentSite());
|
||||
}
|
||||
|
||||
private site: CoreSite;
|
||||
|
||||
constructor(site: CoreSite) {
|
||||
super();
|
||||
|
||||
this.site = site;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
canContactSupport(): boolean {
|
||||
if (this.site.isFeatureDisabled('NoDelegate_CoreUserSupport')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.site.isVersionGreaterEqualThan('4.1')) {
|
||||
if (!this.site.config || !('supportavailability' in this.site.config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const supportAvailability = Number(this.site.config.supportavailability);
|
||||
|
||||
return supportAvailability === CoreSiteConfigSupportAvailability.Authenticated
|
||||
|| supportAvailability === CoreSiteConfigSupportAvailability.Anyone;
|
||||
}
|
||||
|
||||
if (this.site.isVersionGreaterEqualThan('4.0')) {
|
||||
// This feature was always available in 4.0.
|
||||
return true;
|
||||
}
|
||||
|
||||
// This feature wasn't available before 4.0.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected buildSupportPageUrl(): string {
|
||||
return this.site.config?.supportpage?.trim()
|
||||
|| `${this.site.config?.httpswwwroot ?? this.site.config?.wwwroot ?? this.site.siteUrl}/user/contactsitesupport.php`;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
// (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 { CoreSiteConfigSupportAvailability, CoreSitePublicConfigResponse } from '@classes/site';
|
||||
import { CoreUserNullSupportConfig } from '@features/user/classes/support/null-support-config';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreUserSupportConfig } from './support-config';
|
||||
|
||||
/**
|
||||
* Support config for a guest user.
|
||||
*/
|
||||
export class CoreUserGuestSupportConfig extends CoreUserSupportConfig {
|
||||
|
||||
/**
|
||||
* Get support config for a site with given url.
|
||||
*
|
||||
* @param siteUrl Site url.
|
||||
* @returns Support config.
|
||||
*/
|
||||
static async forSite(siteUrl: string): Promise<CoreUserSupportConfig> {
|
||||
const siteConfig = await CoreUtils.ignoreErrors(CoreSites.getPublicSiteConfigByUrl(siteUrl));
|
||||
|
||||
if (!siteConfig) {
|
||||
return new CoreUserNullSupportConfig();
|
||||
}
|
||||
|
||||
return new CoreUserGuestSupportConfig(siteConfig);
|
||||
}
|
||||
|
||||
private config: CoreSitePublicConfigResponse;
|
||||
|
||||
constructor(config: CoreSitePublicConfigResponse) {
|
||||
super();
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
canContactSupport(): boolean {
|
||||
// This config was introduced in 4.1, if it's missing we can assume the site is 4.0 or lower.
|
||||
if ('supportavailability' in this.config) {
|
||||
return this.config.supportavailability === CoreSiteConfigSupportAvailability.Anyone;
|
||||
}
|
||||
|
||||
// This config is only available to guests since 4.0, if it's missing we can assume guests can't contact support.
|
||||
return 'supportpage' in this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected buildSupportPageUrl(): string {
|
||||
return this.config.supportpage?.trim()
|
||||
|| `${this.config.httpswwwroot || this.config.wwwroot}/user/contactsitesupport.php`;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
// (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 { CoreUserSupportConfig } from './support-config';
|
||||
|
||||
/**
|
||||
* Null representation for a support config object.
|
||||
*
|
||||
* This class can be used in place of a functional support config when it's hasn't been possible
|
||||
* to obtain any site configuration to extract information about support.
|
||||
*/
|
||||
export class CoreUserNullSupportConfig extends CoreUserSupportConfig {
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
canContactSupport(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected buildSupportPageUrl(): string {
|
||||
throw new Error('Can\'t build a support page url from a null config');
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// (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.
|
||||
|
||||
/**
|
||||
* Encapsulates the support affordances a user has access to.
|
||||
*/
|
||||
export abstract class CoreUserSupportConfig {
|
||||
|
||||
/**
|
||||
* Check whether the user can contact support or not.
|
||||
*
|
||||
* @return Whether the user can contact support.
|
||||
*/
|
||||
public abstract canContactSupport(): boolean;
|
||||
|
||||
/**
|
||||
* Get url to use for contacting support.
|
||||
*
|
||||
* @returns Support page url.
|
||||
*/
|
||||
getSupportPageUrl(): string {
|
||||
if (!this.canContactSupport()) {
|
||||
throw new Error('Can\'t get support page url');
|
||||
}
|
||||
|
||||
return this.buildSupportPageUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build page url string with the internal information.
|
||||
*
|
||||
* @return Support page url.
|
||||
*/
|
||||
protected abstract buildSupportPageUrl(): string;
|
||||
|
||||
}
|
|
@ -3,8 +3,10 @@
|
|||
"useraccount": "User account",
|
||||
"city": "City/town",
|
||||
"completeprofile": "Complete profile",
|
||||
"completeprofilehelp": "If you have problems completing your profile, try again later or contact your school or learning provider.",
|
||||
"completeprofilenotice": "Before you continue, please fill in the required fields in your user profile.",
|
||||
"completeprofilereconnectinstructions": "If you didn't complete your profile correctly, you'll be asked to do it again.",
|
||||
"completeprofilesupportsubject": "Need help completing my profile",
|
||||
"completeyourprofile": "Complete your profile",
|
||||
"contact": "Contact",
|
||||
"country": "Country",
|
||||
|
@ -28,6 +30,7 @@
|
|||
"roles": "Roles",
|
||||
"sendemail": "Email",
|
||||
"student": "Student",
|
||||
"support": "Support",
|
||||
"teacher": "Non-editing teacher",
|
||||
"userwithid": "User with ID {{id}}",
|
||||
"webpage": "Web page"
|
||||
|
|
|
@ -15,12 +15,12 @@
|
|||
import { Component, OnDestroy } from '@angular/core';
|
||||
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
|
||||
/**
|
||||
* Page that shows instructions to complete the profile.
|
||||
|
@ -42,13 +42,12 @@ export class CoreUserCompleteProfilePage implements OnDestroy {
|
|||
}
|
||||
|
||||
/**
|
||||
* Show a help modal.
|
||||
* Show help modal.
|
||||
*/
|
||||
showHelp(): void {
|
||||
// @todo MOBILE-4059: Change this message.
|
||||
CoreDomUtils.showAlert(
|
||||
Translate.instant('core.help'),
|
||||
Translate.instant('core.login.changepasswordhelp'),
|
||||
CoreUserSupport.showHelp(
|
||||
Translate.instant('core.user.completeprofilehelp'),
|
||||
Translate.instant('core.user.completeprofilesupportsubject'),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,116 @@
|
|||
// (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 { CoreUserSupportConfig } from '@features/user/classes/support/support-config';
|
||||
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
|
||||
import { InAppBrowserObject } from '@ionic-native/in-app-browser';
|
||||
import { CorePlatform } from '@services/platform';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { makeSingleton, Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
import { CoreSubscriptions } from '@singletons/subscriptions';
|
||||
import { AlertButton } from '@ionic/angular';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
|
||||
/**
|
||||
* Handle site support.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CoreUserSupportService {
|
||||
|
||||
/**
|
||||
* Contact site support.
|
||||
*
|
||||
* @param options Options to configure the interaction with support.
|
||||
*/
|
||||
async contact(options: CoreUserSupportContactOptions = {}): Promise<void> {
|
||||
const supportConfig = options.supportConfig ?? CoreUserAuthenticatedSupportConfig.forCurrentSite();
|
||||
const supportPageUrl = supportConfig.getSupportPageUrl();
|
||||
const autoLoginUrl = await CoreSites.getCurrentSite()?.getAutoLoginUrl(supportPageUrl, false);
|
||||
const browser = CoreUtils.openInApp(autoLoginUrl ?? supportPageUrl);
|
||||
|
||||
if (supportPageUrl.endsWith('/user/contactsitesupport.php')) {
|
||||
this.populateSupportForm(browser, options.subject, options.message);
|
||||
}
|
||||
|
||||
await CoreEvents.waitUntil(CoreEvents.IAB_EXIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a help modal that suggests contacting support if available.
|
||||
*
|
||||
* @param message Help message.
|
||||
* @param supportSubject Support subject.
|
||||
*/
|
||||
showHelp(message: string, supportSubject: string): void {
|
||||
const supportConfig = CoreUserAuthenticatedSupportConfig.forCurrentSite();
|
||||
const buttons: (AlertButton | string)[] = [];
|
||||
|
||||
if (supportConfig.canContactSupport()) {
|
||||
buttons.push({
|
||||
text: Translate.instant('core.contactsupport'),
|
||||
handler: () => CoreUserSupport.contact({
|
||||
supportConfig,
|
||||
subject: supportSubject,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push(Translate.instant('core.close'));
|
||||
|
||||
CoreDomUtils.showAlertWithOptions({
|
||||
header: Translate.instant('core.help'),
|
||||
message,
|
||||
buttons,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject error details into contact support form.
|
||||
*
|
||||
* @param browser In App browser containing the support form.
|
||||
* @param subject Title to fill into the form.
|
||||
* @param message Details to fill into the form.
|
||||
*/
|
||||
protected populateSupportForm(browser: InAppBrowserObject, subject?: string | null, message?: string | null): void {
|
||||
if (!CorePlatform.isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = CoreSubscriptions.once(browser.on('loadstop'), () => {
|
||||
browser.executeScript({
|
||||
code: `
|
||||
document.querySelector('#id_subject').value = ${JSON.stringify(subject ?? '')};
|
||||
document.querySelector('#id_message').value = ${JSON.stringify(message ?? '')};
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
CoreEvents.once(CoreEvents.IAB_EXIT, () => unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const CoreUserSupport = makeSingleton(CoreUserSupportService);
|
||||
|
||||
/**
|
||||
* Options to configure interaction with support.
|
||||
*/
|
||||
export interface CoreUserSupportContactOptions {
|
||||
supportConfig?: CoreUserSupportConfig | null;
|
||||
subject?: string | null;
|
||||
message?: string | null;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
@core @core_user @app @javascript @lms_upto3.11
|
||||
Feature: Site support
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname |
|
||||
| student1 | Student | Student |
|
||||
|
||||
Scenario: Cannot contact support
|
||||
Given I entered the app as "student1"
|
||||
When I press the user menu button in the app
|
||||
Then I should find "Blog entries" in the app
|
||||
But I should not find "Support" in the app
|
|
@ -0,0 +1,34 @@
|
|||
@core @core_user @app @javascript @lms_from4.0
|
||||
Feature: Site support
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname |
|
||||
| student1 | Student | Student |
|
||||
And I am logged in as "student1"
|
||||
|
||||
Scenario: Uses default support page
|
||||
Given I entered the app as "student1"
|
||||
When I press the user menu button in the app
|
||||
Then I should find "Support" in the app
|
||||
|
||||
When I press "Support" in the app
|
||||
Then the app should have opened a browser tab with url ".*\/user\/contactsitesupport\.php"
|
||||
|
||||
Scenario: Uses custom support page
|
||||
Given the following config values are set as admin:
|
||||
| supportpage | https://campus.example.edu/support |
|
||||
And I entered the app as "student1"
|
||||
When I press the user menu button in the app
|
||||
Then I should find "Support" in the app
|
||||
|
||||
When I press "Support" in the app
|
||||
Then the app should have opened a browser tab with url "https:\/\/campus\.example\.edu\/support"
|
||||
|
||||
Scenario: Cannot contact support
|
||||
Given the following config values are set as admin:
|
||||
| disabledfeatures | NoDelegate_CoreUserSupport | tool_mobile |
|
||||
And I entered the app as "student1"
|
||||
When I press the user menu button in the app
|
||||
Then I should find "Blog entries" in the app
|
||||
But I should not find "Support" in the app
|
|
@ -0,0 +1,95 @@
|
|||
// (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 { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||
import { CoreUserAuthenticatedSupportConfig } from '@features/user/classes/support/authenticated-support-config';
|
||||
import { CoreUserNullSupportConfig } from '@features/user/classes/support/null-support-config';
|
||||
import { CoreApp } from '@services/app';
|
||||
import { CoreSites } from '@services/sites';
|
||||
import { CoreCustomURLSchemes } from '@services/urlschemes';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CoreUrlUtils } from '@services/utils/url';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
import { Translate } from '@singletons';
|
||||
import { CoreEvents } from '@singletons/events';
|
||||
|
||||
let lastInAppUrl: string | null = null;
|
||||
|
||||
export default function(): void {
|
||||
// Check URLs loaded in any InAppBrowser.
|
||||
CoreEvents.on(CoreEvents.IAB_LOAD_START, async (event) => {
|
||||
// URLs with a custom scheme can be prefixed with "http://" or "https://", we need to remove this.
|
||||
const protocol = CoreUrlUtils.getUrlProtocol(event.url);
|
||||
const url = event.url.replace(/^https?:\/\//, '');
|
||||
const urlScheme = CoreUrlUtils.getUrlProtocol(url);
|
||||
const isExternalApp = urlScheme && urlScheme !== 'file' && urlScheme !== 'cdvfile';
|
||||
|
||||
if (CoreCustomURLSchemes.isCustomURL(url)) {
|
||||
// Close the browser if it's a valid SSO URL.
|
||||
CoreCustomURLSchemes.handleCustomURL(url).catch((error) => {
|
||||
CoreCustomURLSchemes.treatHandleCustomURLError(error);
|
||||
});
|
||||
CoreUtils.closeInAppBrowser();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternalApp && url.includes('://token=')) {
|
||||
// It's an SSO token for another app. Close the IAB and show an error.
|
||||
CoreUtils.closeInAppBrowser();
|
||||
CoreDomUtils.showErrorModal(new CoreSiteError({
|
||||
supportConfig: CoreSites.getCurrentSite()
|
||||
? CoreUserAuthenticatedSupportConfig.forCurrentSite()
|
||||
: new CoreUserNullSupportConfig(),
|
||||
message: Translate.instant('core.errorurlschemeinvalidscheme', { $a: urlScheme }),
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!CoreApp.isAndroid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the URL has a custom URL scheme. In Android they need to be opened manually.
|
||||
if (!isExternalApp) {
|
||||
lastInAppUrl = protocol ? `${protocol}://${url}` : url;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Open in browser should launch the right app if found and do nothing if not found.
|
||||
CoreUtils.openInBrowser(url, { showBrowserWarning: false });
|
||||
|
||||
// At this point the InAppBrowser is showing a "Webpage not available" error message.
|
||||
// Try to navigate to last loaded URL so this error message isn't found.
|
||||
if (lastInAppUrl) {
|
||||
CoreUtils.openInApp(lastInAppUrl);
|
||||
} else {
|
||||
// No last URL loaded, close the InAppBrowser.
|
||||
CoreUtils.closeInAppBrowser();
|
||||
}
|
||||
});
|
||||
|
||||
// Check InAppBrowser closed.
|
||||
CoreEvents.on(CoreEvents.IAB_EXIT, () => {
|
||||
lastInAppUrl = null;
|
||||
|
||||
if (CoreLoginHelper.isWaitingForBrowser()) {
|
||||
CoreLoginHelper.stopWaitingForBrowser();
|
||||
CoreLoginHelper.checkLogout();
|
||||
}
|
||||
});
|
||||
}
|
|
@ -3,7 +3,7 @@
|
|||
"add": "Add",
|
||||
"agelocationverification": "Age and location verification",
|
||||
"ago": "{{$a}} ago",
|
||||
"ajaxendpointnotfound": "<p>AJAX endpoint not found. This can happen if the Moodle site is too old or it blocks access to this endpoint. The Moodle app only supports Moodle systems {{$a}} onwards. Please contact your site administrator.</p>\n<p>{{whoisadmin}}</p>",
|
||||
"ajaxendpointnotfound": "AJAX endpoint not found. This can happen if the Moodle site is too old or it blocks access to this endpoint. The Moodle app only supports Moodle systems {{$a}} onwards.",
|
||||
"all": "All",
|
||||
"allgroups": "All groups",
|
||||
"allparticipants": "All participants",
|
||||
|
@ -14,10 +14,8 @@
|
|||
"browser": "Browser",
|
||||
"calculating": "Calculating",
|
||||
"cancel": "Cancel",
|
||||
"cannotconnect": "Cannot connect",
|
||||
"cannotconnecttrouble": "We're having trouble connecting to your site.",
|
||||
"cannotconnectverify": "<strong>Please check the address is correct.</strong>",
|
||||
"cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.",
|
||||
"cannotconnect": "Can't connect to site",
|
||||
"cannotdownloadfiles": "Your school or learning provider has disabled downloading files.",
|
||||
"cannotinstallapk": "For security reasons, you can't install unknown apps on your device from this app. Please open the file using a browser.",
|
||||
"cannotlogoutpageblocks": "Please save or discard your changes before continuing.",
|
||||
"cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?",
|
||||
|
@ -27,7 +25,7 @@
|
|||
"captureimage": "Take picture",
|
||||
"capturevideo": "Record video",
|
||||
"category": "Category",
|
||||
"certificaterror": "<p>The certificate of this site cannot be trusted by your device. Please contact your site administrator.</p>\n<p>{{whoisadmin}}</p>",
|
||||
"certificaterror": "The certificate of this site cannot be trusted by your device: {{details}}",
|
||||
"choose": "Choose",
|
||||
"choosedots": "Choose...",
|
||||
"clearsearch": "Clear search",
|
||||
|
@ -53,7 +51,9 @@
|
|||
"confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.",
|
||||
"confirmloss": "Are you sure? All changes will be lost.",
|
||||
"confirmopeninbrowser": "Do you want to open it in a web browser?",
|
||||
"connectionlost": "Connection to site lost",
|
||||
"considereddigitalminor": "You are too young to create an account on this site.",
|
||||
"contactsupport": "Contact support",
|
||||
"content": "Content",
|
||||
"contenteditingsynced": "The content you are editing has been synced.",
|
||||
"continue": "Continue",
|
||||
|
@ -100,12 +100,15 @@
|
|||
"endonesteptour": "Got it",
|
||||
"error": "Error",
|
||||
"errorchangecompletion": "An error occurred while changing the completion status. Please try again.",
|
||||
"errorcode": "Error code",
|
||||
"errordeletefile": "Error deleting the file. Please try again.",
|
||||
"errordetailshide": "Hide error details",
|
||||
"errordetailsshow": "Show error details",
|
||||
"errordownloading": "Error downloading file.",
|
||||
"errordownloadingsomefiles": "Error downloading files. Some files might be missing.",
|
||||
"errorfileexistssamename": "A file with this name already exists.",
|
||||
"errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.",
|
||||
"errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.",
|
||||
"errorinvalidresponse": "Invalid response received for {{method}} webservice.",
|
||||
"errorloadingcontent": "Error loading content.",
|
||||
"errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.",
|
||||
"erroropenfiledownloading": "Error opening file: you need to wait for the download to complete.",
|
||||
|
@ -270,13 +273,16 @@
|
|||
"selectagroup": "Select a group",
|
||||
"send": "Send",
|
||||
"sending": "Sending",
|
||||
"serverconnection": "Error connecting to the server",
|
||||
"serverconnection": "Error connecting to the server: {{details}}",
|
||||
"show": "Show",
|
||||
"showadvanced": "Show advanced",
|
||||
"showless": "Show less...",
|
||||
"showmore": "Show more...",
|
||||
"site": "Site",
|
||||
"sitemaintenance": "The site is undergoing maintenance and is currently not available",
|
||||
"sitenotfound": "Site not found",
|
||||
"sitenotfoundhelp": "We can't find the site you entered. Please check for typos or try again later. If you keep seeing this message, contact your school or learning provider.",
|
||||
"siteunavailablehelp": "The site \"{{site}}\" is not available right now. Please try again later or contact your school or learning provider.",
|
||||
"size": "Size",
|
||||
"sizeb": "bytes",
|
||||
"sizegb": "GB",
|
||||
|
@ -348,7 +354,6 @@
|
|||
"weeks": "weeks",
|
||||
"whatisyourage": "What is your age?",
|
||||
"wheredoyoulive": "In which country do you live?",
|
||||
"whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.",
|
||||
"whyisthishappening": "Why is this happening?",
|
||||
"whyisthisrequired": "Why is this required?",
|
||||
"wsfunctionnotavailable": "The web service function is not available.",
|
||||
|
|
|
@ -55,6 +55,7 @@ import { lazyMap, LazyMap } from '../utils/lazy-map';
|
|||
import { asyncInstance, AsyncInstance } from '../utils/async-instance';
|
||||
import { CoreText } from '@singletons/text';
|
||||
import { CorePromisedValue } from '@classes/promised-value';
|
||||
import { CoreSite } from '@classes/site';
|
||||
|
||||
/*
|
||||
* Factory for handling downloading files and retrieve downloaded files.
|
||||
|
@ -374,7 +375,7 @@ export class CoreFilepoolProvider {
|
|||
|
||||
const site = await CoreSites.getSite(siteId);
|
||||
if (!site.canDownloadFiles()) {
|
||||
throw new CoreError('Site doesn\'t allow downloading files.');
|
||||
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
|
||||
}
|
||||
|
||||
if (!alreadyFixed) {
|
||||
|
@ -509,7 +510,7 @@ export class CoreFilepoolProvider {
|
|||
} else {
|
||||
if (!CoreNetwork.isOnline()) {
|
||||
// Cannot check size in offline, stop.
|
||||
throw new CoreError(Translate.instant('core.cannotconnect'));
|
||||
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||
}
|
||||
|
||||
size = await CoreWS.getRemoteFileSize(fileUrl);
|
||||
|
@ -745,7 +746,7 @@ export class CoreFilepoolProvider {
|
|||
|
||||
this.filePromises[siteId][downloadId] = CoreSites.getSite(siteId).then(async (site) => {
|
||||
if (!site.canDownloadFiles()) {
|
||||
throw new CoreError('Site doesn\'t allow downloading files.');
|
||||
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
|
||||
}
|
||||
|
||||
const entry = await CoreWS.downloadFile(fileUrl, path, addExtension, onProgress);
|
||||
|
|
|
@ -34,7 +34,7 @@ import {
|
|||
} from '@classes/site';
|
||||
import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb';
|
||||
import { CoreError } from '@classes/errors/error';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreLoginError, CoreLoginErrorOptions } from '@classes/errors/loginerror';
|
||||
import { makeSingleton, Translate, Http } from '@singletons';
|
||||
import { CoreLogger } from '@singletons/logger';
|
||||
import {
|
||||
|
@ -61,6 +61,7 @@ import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/da
|
|||
import { asyncInstance, AsyncInstance } from '../utils/async-instance';
|
||||
import { CoreConfig } from './config';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
|
||||
|
||||
export const CORE_SITE_SCHEMAS = new InjectionToken<CoreSiteSchema[]>('CORE_SITE_SCHEMAS');
|
||||
export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id';
|
||||
|
@ -245,7 +246,7 @@ export class CoreSitesProvider {
|
|||
} else if (CoreTextUtils.getErrorMessageFromError(secondError)) {
|
||||
throw secondError;
|
||||
} else {
|
||||
throw new CoreError(Translate.instant('core.cannotconnecttrouble'));
|
||||
throw new CoreError(Translate.instant('core.sitenotfoundhelp'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -296,13 +297,17 @@ export class CoreSitesProvider {
|
|||
|
||||
// Check that the user can authenticate.
|
||||
if (!config.enablewebservices) {
|
||||
throw new CoreSiteError({
|
||||
message: Translate.instant('core.login.webservicesnotenabled'),
|
||||
throw this.createCannotConnectLoginError(config.httpswwwroot || config.wwwroot, {
|
||||
supportConfig: new CoreUserGuestSupportConfig(config),
|
||||
errorcode: 'webservicesnotenabled',
|
||||
errorDetails: Translate.instant('core.login.webservicesnotenabled'),
|
||||
critical: true,
|
||||
});
|
||||
} else if (!config.enablemobilewebservice) {
|
||||
throw new CoreSiteError({
|
||||
message: Translate.instant('core.login.mobileservicesnotenabled'),
|
||||
throw this.createCannotConnectLoginError(config.httpswwwroot || config.wwwroot, {
|
||||
supportConfig: new CoreUserGuestSupportConfig(config),
|
||||
errorcode: 'mobileservicesnotenabled',
|
||||
errorDetails: Translate.instant('core.login.mobileservicesnotenabled'),
|
||||
critical: true,
|
||||
});
|
||||
} else if (config.maintenanceenabled) {
|
||||
|
@ -311,7 +316,7 @@ export class CoreSitesProvider {
|
|||
message += config.maintenancemessage;
|
||||
}
|
||||
|
||||
throw new CoreSiteError({
|
||||
throw new CoreLoginError({
|
||||
message,
|
||||
critical: true,
|
||||
});
|
||||
|
@ -323,47 +328,73 @@ export class CoreSitesProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Treat an error returned by getPublicConfig in checkSiteWithProtocol. Converts the error to a CoreSiteError.
|
||||
* Create an error to be thrown when it isn't possible to login to a site.
|
||||
*
|
||||
* @param options Error options.
|
||||
* @return Cannot connect error.
|
||||
*/
|
||||
protected createCannotConnectLoginError(siteUrl: string | null, options?: Partial<CoreLoginErrorOptions>): CoreLoginError {
|
||||
return new CoreLoginError({
|
||||
...options,
|
||||
message: !this.isLoggedIn() && siteUrl === null
|
||||
? Translate.instant('core.sitenotfoundhelp')
|
||||
: Translate.instant('core.siteunavailablehelp', { site: siteUrl ?? this.currentSite?.siteUrl }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat an error returned by getPublicConfig in checkSiteWithProtocol. Converts the error to a CoreLoginError.
|
||||
*
|
||||
* @param siteUrl Site URL.
|
||||
* @param error Error returned.
|
||||
* @return Promise resolved with the treated error.
|
||||
*/
|
||||
protected async treatGetPublicConfigError(siteUrl: string, error: CoreAjaxError | CoreAjaxWSError): Promise<CoreSiteError> {
|
||||
if (!('errorcode' in error)) {
|
||||
protected async treatGetPublicConfigError(
|
||||
siteUrl: string,
|
||||
error: CoreError | CoreAjaxError | CoreAjaxWSError,
|
||||
): Promise<CoreLoginError> {
|
||||
if (error instanceof CoreAjaxError || !('errorcode' in error)) {
|
||||
// The WS didn't return data, probably cannot connect.
|
||||
return new CoreSiteError({
|
||||
message: error.message || '',
|
||||
return new CoreLoginError({
|
||||
title: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
message: Translate.instant('core.siteunavailablehelp', { site: siteUrl }),
|
||||
errorcode: 'publicconfigfailed',
|
||||
errorDetails: error.message || '',
|
||||
critical: false, // Allow fallback to http if siteUrl uses https.
|
||||
});
|
||||
}
|
||||
|
||||
// Service supported but an error happened. Return error.
|
||||
let critical = true;
|
||||
const options: CoreLoginErrorOptions = {
|
||||
critical: true,
|
||||
title: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
message: Translate.instant('core.siteunavailablehelp', { site: siteUrl }),
|
||||
errorcode: error.errorcode,
|
||||
supportConfig: error.supportConfig,
|
||||
errorDetails: error.errorDetails ?? error.message,
|
||||
};
|
||||
|
||||
if (error.errorcode === 'codingerror') {
|
||||
// This could be caused by a redirect. Check if it's the case.
|
||||
const redirect = await CoreUtils.checkRedirect(siteUrl);
|
||||
|
||||
options.message = Translate.instant('core.siteunavailablehelp', { site: siteUrl });
|
||||
|
||||
if (redirect) {
|
||||
error.message = Translate.instant('core.login.sitehasredirect');
|
||||
critical = false; // Keep checking fallback URLs.
|
||||
} else {
|
||||
// We can't be sure if there is a redirect or not. Display cannot connect error.
|
||||
error.message = Translate.instant('core.cannotconnecttrouble');
|
||||
options.errorcode = 'sitehasredirect';
|
||||
options.errorDetails = Translate.instant('core.login.sitehasredirect');
|
||||
options.critical = false; // Keep checking fallback URLs.
|
||||
}
|
||||
} else if (error.errorcode === 'invalidrecord') {
|
||||
// WebService not found, site not supported.
|
||||
error.message = Translate.instant('core.login.invalidmoodleversion', { $a: CoreSite.MINIMUM_MOODLE_VERSION });
|
||||
options.message = Translate.instant('core.siteunavailablehelp', { site: siteUrl });
|
||||
options.errorcode = 'invalidmoodleversion';
|
||||
options.errorDetails = Translate.instant('core.login.invalidmoodleversion', { $a: CoreSite.MINIMUM_MOODLE_VERSION });
|
||||
} else if (error.errorcode === 'redirecterrordetected') {
|
||||
critical = false; // Keep checking fallback URLs.
|
||||
options.critical = false; // Keep checking fallback URLs.
|
||||
}
|
||||
|
||||
return new CoreSiteError({
|
||||
message: error.message,
|
||||
errorcode: error.errorcode,
|
||||
critical,
|
||||
});
|
||||
return new CoreLoginError(options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -383,30 +414,37 @@ export class CoreSitesProvider {
|
|||
data = await Http.post(siteUrl + '/login/token.php', { appsitecheck: 1 }).pipe(timeout(CoreWS.getRequestTimeout()))
|
||||
.toPromise();
|
||||
} catch (error) {
|
||||
// Default error messages are kinda bad, return our own message.
|
||||
throw new CoreSiteError({
|
||||
message: Translate.instant('core.cannotconnecttrouble'),
|
||||
throw this.createCannotConnectLoginError(null, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
errorcode: 'sitecheckfailed',
|
||||
errorDetails: CoreDomUtils.getErrorMessage(error) ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
// Cannot connect.
|
||||
throw new CoreSiteError({
|
||||
message: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
||||
throw this.createCannotConnectLoginError(null, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
errorcode: 'appsitecheckfailed',
|
||||
errorDetails: 'A request to /login/token.php with appsitecheck=1 returned an empty response',
|
||||
});
|
||||
}
|
||||
|
||||
if (data.errorcode && (data.errorcode == 'enablewsdescription' || data.errorcode == 'requirecorrectaccess')) {
|
||||
throw new CoreSiteError({
|
||||
throw this.createCannotConnectLoginError(siteUrl, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
critical: data.errorcode == 'enablewsdescription',
|
||||
errorcode: data.errorcode,
|
||||
message: data.error ?? '',
|
||||
errorDetails: data.error,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.error && data.error == 'Web services must be enabled in Advanced features.') {
|
||||
throw new CoreSiteError({
|
||||
throw this.createCannotConnectLoginError(siteUrl, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
critical: true,
|
||||
errorcode: 'enablewsdescription',
|
||||
message: data.error,
|
||||
errorDetails: data.error,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -446,11 +484,19 @@ export class CoreSitesProvider {
|
|||
try {
|
||||
data = await Http.post(loginUrl, params).pipe(timeout(CoreWS.getRequestTimeout())).toPromise();
|
||||
} catch (error) {
|
||||
throw new CoreError(Translate.instant('core.cannotconnecttrouble'));
|
||||
throw new CoreError(
|
||||
this.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: this.currentSite?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp'),
|
||||
);
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
throw new CoreError(Translate.instant('core.cannotconnecttrouble'));
|
||||
throw new CoreError(
|
||||
this.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: this.currentSite?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp'),
|
||||
);
|
||||
} else {
|
||||
if (data.token !== undefined) {
|
||||
return { token: data.token, siteUrl, privateToken: data.privatetoken };
|
||||
|
@ -466,15 +512,18 @@ export class CoreSitesProvider {
|
|||
const redirect = await CoreUtils.checkRedirect(loginUrl);
|
||||
|
||||
if (redirect) {
|
||||
throw new CoreSiteError({
|
||||
message: Translate.instant('core.login.sitehasredirect'),
|
||||
throw this.createCannotConnectLoginError(siteUrl, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
errorcode: 'sitehasredirect',
|
||||
errorDetails: Translate.instant('core.login.sitehasredirect'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new CoreSiteError({
|
||||
message: data.error,
|
||||
throw this.createCannotConnectLoginError(siteUrl, {
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
errorcode: data.errorcode,
|
||||
errorDetails: data.error,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -615,7 +664,7 @@ export class CoreSitesProvider {
|
|||
await this.setSiteLoggedOut(siteId);
|
||||
}
|
||||
|
||||
throw new CoreSiteError({
|
||||
throw new CoreLoginError({
|
||||
message: Translate.instant(errorKey, translateParams),
|
||||
errorcode: errorCode,
|
||||
loggedOut: true,
|
||||
|
@ -1081,6 +1130,18 @@ export class CoreSitesProvider {
|
|||
return this.addSiteFromSiteListEntry(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the public type config for a site with the given url.
|
||||
*
|
||||
* @param siteUrl The site URL.
|
||||
* @return Promise resolved with public config or null.
|
||||
*/
|
||||
async getPublicSiteConfigByUrl(siteUrl: string): Promise<CoreSitePublicConfigResponse> {
|
||||
const site = await this.getSiteByUrl(siteUrl);
|
||||
|
||||
return site.getPublicConfig({ readingStrategy: CoreSitesReadingStrategy.ONLY_CACHE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a site from an entry of the sites list DB. The new site is added to the list of "cached" sites: this.sites.
|
||||
*
|
||||
|
|
|
@ -40,7 +40,7 @@ describe('CoreNavigator', () => {
|
|||
|
||||
router = mockSingleton(Router, { url: '/' });
|
||||
|
||||
mockSingleton(CoreSites, { getCurrentSiteId: () => 42, isLoggedIn: () => true });
|
||||
mockSingleton(CoreSites, { getCurrentSiteId: () => '42', isLoggedIn: () => true });
|
||||
mockSingleton(CoreMainMenu, { isMainMenuTab: path => Promise.resolve(currentMainMenuHandlers.includes(path)) });
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
// (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 { CoreDomUtilsProvider } from '@services/utils/dom';
|
||||
import { AlertController, Translate } from '@singletons';
|
||||
|
||||
import { mock, mockSingleton, mockTranslate } from '@/testing/utils';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
||||
describe('CoreDomUtilsProvider', () => {
|
||||
|
||||
let domUtils: CoreDomUtilsProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
domUtils = new CoreDomUtilsProvider();
|
||||
});
|
||||
|
||||
it('shows site unavailable errors', async () => {
|
||||
// Arrange.
|
||||
mockTranslate({
|
||||
'core.siteunavailablehelp': 'The site "{{site}}" is not available right now.',
|
||||
});
|
||||
|
||||
const message = Translate.instant('core.siteunavailablehelp', { site: 'https://campus.example.edu' });
|
||||
const mockAlert = mock<HTMLIonAlertElement>({
|
||||
present: () => Promise.resolve(),
|
||||
onDidDismiss: async <T>() => new Promise<T>(() => {
|
||||
// Never resolve.
|
||||
}),
|
||||
});
|
||||
|
||||
mockSingleton(AlertController, mock({ create: () => Promise.resolve(mockAlert) }));
|
||||
mockSingleton(CoreSites, mock({ isLoggedIn: () => true }));
|
||||
|
||||
// Act.
|
||||
await domUtils.showErrorModal(new CoreSiteError({ message }));
|
||||
|
||||
// Assert.
|
||||
expect(mockAlert.present).toHaveBeenCalled();
|
||||
expect(AlertController.create).toHaveBeenCalledWith({
|
||||
message,
|
||||
header: Translate.instant('core.connectionlost'),
|
||||
buttons: [Translate.instant('core.ok')],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
|
@ -54,6 +54,10 @@ import { Subscription } from 'rxjs';
|
|||
import { CoreComponentsRegistry } from '@singletons/components-registry';
|
||||
import { CoreDom } from '@singletons/dom';
|
||||
import { CoreNetwork } from '@services/network';
|
||||
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||
import { CoreUserSupport } from '@features/user/services/support';
|
||||
import { CoreErrorInfoComponent } from '@components/error-info/error-info';
|
||||
import { CoreSite } from '@classes/site';
|
||||
|
||||
/*
|
||||
* "Utils" service with helper functions for UI, DOM elements and HTML code.
|
||||
|
@ -573,6 +577,20 @@ export class CoreDomUtilsProvider {
|
|||
error instanceof CoreNetworkError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a message, check if it's a site unavailable error.
|
||||
*
|
||||
* @param message Message text.
|
||||
* @returns Whether the message is a site unavailable error.
|
||||
*/
|
||||
protected isSiteUnavailableError(message: string): boolean {
|
||||
let siteUnavailableMessage = Translate.instant('core.siteunavailablehelp', { site: 'SITEURLPLACEHOLDER' });
|
||||
siteUnavailableMessage = CoreTextUtils.escapeForRegex(siteUnavailableMessage);
|
||||
siteUnavailableMessage = siteUnavailableMessage.replace('SITEURLPLACEHOLDER', '.*');
|
||||
|
||||
return new RegExp(siteUnavailableMessage).test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the error message from an error, including debug data if needed.
|
||||
*
|
||||
|
@ -1342,25 +1360,60 @@ export class CoreDomUtilsProvider {
|
|||
return null;
|
||||
}
|
||||
|
||||
const alertOptions: AlertOptions = {
|
||||
message: message,
|
||||
};
|
||||
const alertOptions: AlertOptions = { message };
|
||||
|
||||
if (this.isNetworkError(message, error)) {
|
||||
alertOptions.cssClass = 'core-alert-network-error';
|
||||
} else if (typeof error !== 'string' && 'title' in error) {
|
||||
}
|
||||
|
||||
if (typeof error !== 'string' && 'title' in error && error.title) {
|
||||
alertOptions.header = error.title || undefined;
|
||||
} else if (message === Translate.instant('core.sitenotfoundhelp')) {
|
||||
alertOptions.header = Translate.instant('core.sitenotfound');
|
||||
} else if (this.isSiteUnavailableError(message)) {
|
||||
alertOptions.header = CoreSites.isLoggedIn()
|
||||
? Translate.instant('core.connectionlost')
|
||||
: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION });
|
||||
} else {
|
||||
alertOptions.header = Translate.instant('core.error');
|
||||
}
|
||||
|
||||
if (typeof error !== 'string' && 'buttons' in error && typeof error.buttons !== 'undefined') {
|
||||
alertOptions.buttons = error.buttons;
|
||||
} else if (error instanceof CoreSiteError) {
|
||||
if (error.errorDetails) {
|
||||
alertOptions.message = `<p>${alertOptions.message}</p><div class="core-error-info-container"></div>`;
|
||||
}
|
||||
|
||||
const supportConfig = error.supportConfig;
|
||||
|
||||
alertOptions.buttons = [Translate.instant('core.ok')];
|
||||
|
||||
if (supportConfig?.canContactSupport()) {
|
||||
alertOptions.buttons.push({
|
||||
text: Translate.instant('core.contactsupport'),
|
||||
handler: () => CoreUserSupport.contact({
|
||||
supportConfig,
|
||||
subject: alertOptions.header,
|
||||
message: `${error.errorcode}\n\n${error.errorDetails}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alertOptions.buttons = [Translate.instant('core.ok')];
|
||||
}
|
||||
|
||||
return this.showAlertWithOptions(alertOptions, autocloseTime);
|
||||
const alertElement = await this.showAlertWithOptions(alertOptions, autocloseTime);
|
||||
|
||||
if (error instanceof CoreSiteError && error.errorDetails) {
|
||||
const containerElement = alertElement.querySelector('.core-error-info-container');
|
||||
|
||||
if (containerElement) {
|
||||
containerElement.innerHTML = CoreErrorInfoComponent.render(error.errorDetails, error.errorcode);
|
||||
}
|
||||
}
|
||||
|
||||
return alertElement;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1553,14 +1606,18 @@ export class CoreDomUtilsProvider {
|
|||
// Default buttons.
|
||||
options.buttons = [
|
||||
{
|
||||
text: buttons && 'cancelText' in buttons ? buttons.cancelText : Translate.instant('core.cancel'),
|
||||
text: buttons && 'cancelText' in buttons
|
||||
? buttons.cancelText as string
|
||||
: Translate.instant('core.cancel'),
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
reject();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: buttons && 'okText' in buttons ? buttons.okText : Translate.instant('core.ok'),
|
||||
text: buttons && 'okText' in buttons
|
||||
? buttons.okText as string
|
||||
: Translate.instant('core.ok'),
|
||||
handler: resolvePromise,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1044,11 +1044,7 @@ export class CoreUtilsProvider {
|
|||
* @param options Override default options passed to InAppBrowser.
|
||||
* @return The opened window.
|
||||
*/
|
||||
openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject | undefined {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject {
|
||||
options = options || {};
|
||||
options.usewkwebview = 'yes'; // Force WKWebView in iOS.
|
||||
options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default.
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
// limitations under the License.
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpResponse, HttpParams } from '@angular/common/http';
|
||||
import { HttpResponse, HttpParams, HttpErrorResponse } from '@angular/common/http';
|
||||
|
||||
import { FileEntry } from '@ionic-native/file/ngx';
|
||||
import { FileUploadOptions, FileUploadResult } from '@ionic-native/file-transfer/ngx';
|
||||
|
@ -39,6 +39,9 @@ import { CoreSite } from '@classes/site';
|
|||
import { CoreHttpError } from '@classes/errors/httperror';
|
||||
import { CorePromisedValue } from '@classes/promised-value';
|
||||
import { CorePlatform } from '@services/platform';
|
||||
import { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror';
|
||||
import { CoreUserGuestSupportConfig } from '@features/user/classes/support/guest-support-config';
|
||||
import { CoreSites } from '@services/sites';
|
||||
|
||||
/**
|
||||
* This service allows performing WS calls and download/upload files.
|
||||
|
@ -456,7 +459,7 @@ export class CoreWSProvider {
|
|||
});
|
||||
}
|
||||
|
||||
return promise.then((response) => {
|
||||
return promise.then(async (response) => {
|
||||
let data = response.body;
|
||||
|
||||
// Some moodle web services return null.
|
||||
|
@ -467,7 +470,18 @@ export class CoreWSProvider {
|
|||
|
||||
// Check if error. Ajax layer should always return an object (if error) or an array (if success).
|
||||
if (!data || typeof data != 'object') {
|
||||
throw new CoreAjaxError(Translate.instant('core.serverconnection'));
|
||||
const message = CoreSites.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: CoreSites.getCurrentSite()?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp');
|
||||
|
||||
throw new CoreAjaxError({
|
||||
message,
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(preSets.siteUrl),
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.serverconnection', {
|
||||
details: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
}),
|
||||
});
|
||||
} else if (data.error) {
|
||||
throw new CoreAjaxWSError(data);
|
||||
}
|
||||
|
@ -480,24 +494,36 @@ export class CoreWSProvider {
|
|||
}
|
||||
|
||||
return data.data;
|
||||
}, (data) => {
|
||||
let message = '';
|
||||
}, async (data: HttpErrorResponse) => {
|
||||
const message = CoreSites.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: CoreSites.getCurrentSite()?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp');
|
||||
|
||||
const options: CoreSiteErrorOptions = {
|
||||
message,
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(preSets.siteUrl),
|
||||
};
|
||||
|
||||
switch (data.status) {
|
||||
case -2: // Certificate error.
|
||||
message = this.getCertificateErrorMessage(data.error);
|
||||
break;
|
||||
case 404: // AJAX endpoint not found.
|
||||
message = Translate.instant('core.ajaxendpointnotfound', {
|
||||
$a: CoreSite.MINIMUM_MOODLE_VERSION,
|
||||
whoisadmin: Translate.instant('core.whoissiteadmin'),
|
||||
options.errorcode = 'invalidcertificate';
|
||||
options.errorDetails = Translate.instant('core.certificaterror', {
|
||||
details: CoreTextUtils.getErrorMessageFromError(data.error) ?? 'Unknown error',
|
||||
});
|
||||
break;
|
||||
case 404: // AJAX endpoint not found.
|
||||
options.errorcode = 'endpointnotfound';
|
||||
options.errorDetails = Translate.instant('core.ajaxendpointnotfound', { $a: CoreSite.MINIMUM_MOODLE_VERSION });
|
||||
break;
|
||||
default:
|
||||
message = Translate.instant('core.serverconnection');
|
||||
options.errorcode = 'serverconnectionajax';
|
||||
options.errorDetails = Translate.instant('core.serverconnection', {
|
||||
details: CoreTextUtils.getErrorMessageFromError(data.error) ?? 'Unknown error',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
throw new CoreAjaxError(message, 1, data.status);
|
||||
throw new CoreAjaxError(options, 1, data.status);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -619,7 +645,7 @@ export class CoreWSProvider {
|
|||
const promise = Http.post(requestUrl, ajaxData, options).pipe(timeout(this.getRequestTimeout())).toPromise();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return promise.then((data: any) => {
|
||||
return promise.then(async (data: any) => {
|
||||
// Some moodle web services return null.
|
||||
// If the responseExpected value is set to false, we create a blank object if the response is null.
|
||||
if (!data && !preSets.responseExpected) {
|
||||
|
@ -627,7 +653,12 @@ export class CoreWSProvider {
|
|||
}
|
||||
|
||||
if (!data) {
|
||||
throw new CoreError(Translate.instant('core.serverconnection'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'serverconnectionpost',
|
||||
errorDetails: Translate.instant('core.serverconnection', {
|
||||
details: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
}),
|
||||
});
|
||||
} else if (typeof data != preSets.typeExpected) {
|
||||
// If responseType is text an string will be returned, parse before returning.
|
||||
if (typeof data == 'string') {
|
||||
|
@ -636,7 +667,10 @@ export class CoreWSProvider {
|
|||
if (isNaN(data)) {
|
||||
this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`);
|
||||
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
});
|
||||
}
|
||||
} else if (preSets.typeExpected == 'boolean') {
|
||||
if (data === 'true') {
|
||||
|
@ -646,17 +680,26 @@ export class CoreWSProvider {
|
|||
} else {
|
||||
this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`);
|
||||
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
|
||||
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
|
||||
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -674,7 +717,7 @@ export class CoreWSProvider {
|
|||
}
|
||||
|
||||
return data;
|
||||
}, (error) => {
|
||||
}, async (error) => {
|
||||
// If server has heavy load, retry after some seconds.
|
||||
if (error.status == 429) {
|
||||
const retryPromise = this.addToRetryQueue<T>(method, siteUrl, ajaxData, preSets);
|
||||
|
@ -697,7 +740,12 @@ export class CoreWSProvider {
|
|||
|
||||
return retryPromise;
|
||||
} else if (error.status === -2) {
|
||||
throw new CoreError(this.getCertificateErrorMessage(error.error));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidcertificate',
|
||||
errorDetails: Translate.instant('core.certificaterror', {
|
||||
details: CoreTextUtils.getErrorMessageFromError(error) ?? 'Unknown error',
|
||||
}),
|
||||
});
|
||||
} else if (error.status > 0) {
|
||||
throw this.createHttpError(error, error.status);
|
||||
}
|
||||
|
@ -706,24 +754,6 @@ export class CoreWSProvider {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message about certificate error.
|
||||
*
|
||||
* @param error Exact error message.
|
||||
* @return Certificate error message.
|
||||
*/
|
||||
protected getCertificateErrorMessage(error?: string): string {
|
||||
const message = Translate.instant('core.certificaterror', {
|
||||
whoisadmin: Translate.instant('core.whoissiteadmin'),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return `${message}\n<p>${error}</p>`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry all requests in the queue.
|
||||
* This function uses recursion in order to add a delay between requests to reduce stress.
|
||||
|
@ -839,10 +869,12 @@ export class CoreWSProvider {
|
|||
}
|
||||
|
||||
if (!data) {
|
||||
throw new CoreError(Translate.instant('core.serverconnection'));
|
||||
throw new CoreError(Translate.instant('core.serverconnection', {
|
||||
details: Translate.instant('core.errorinvalidresponse', { method }),
|
||||
}));
|
||||
} else if (typeof data != preSets.typeExpected) {
|
||||
this.logger.warn('Response of type "' + typeof data + '" received, expecting "' + preSets.typeExpected + '"');
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse', { method }));
|
||||
}
|
||||
|
||||
if (data.exception !== undefined || data.debuginfo !== undefined) {
|
||||
|
@ -912,15 +944,26 @@ export class CoreWSProvider {
|
|||
);
|
||||
|
||||
if (data === null) {
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method: 'upload.php' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new CoreError(Translate.instant('core.serverconnection'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'serverconnectionupload',
|
||||
errorDetails: Translate.instant('core.serverconnection', {
|
||||
details: Translate.instant('core.errorinvalidresponse', { method: 'upload.php' }),
|
||||
}),
|
||||
});
|
||||
} else if (typeof data != 'object') {
|
||||
this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "object"');
|
||||
|
||||
throw new CoreError(Translate.instant('core.errorinvalidresponse'));
|
||||
throw await this.createCannotConnectSiteError(preSets.siteUrl, {
|
||||
errorcode: 'invalidresponse',
|
||||
errorDetails: Translate.instant('core.errorinvalidresponse', { method: 'upload.php' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (data.exception !== undefined) {
|
||||
|
@ -952,7 +995,9 @@ export class CoreWSProvider {
|
|||
*/
|
||||
protected createHttpError(error: CoreTextErrorObject, status: number): CoreHttpError {
|
||||
const message = CoreTextUtils.buildSeveralParagraphsMessage([
|
||||
Translate.instant('core.cannotconnecttrouble'),
|
||||
CoreSites.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: CoreSites.getCurrentSite()?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp'),
|
||||
CoreTextUtils.getHTMLBodyContent(CoreTextUtils.getErrorMessageFromError(error) || ''),
|
||||
]);
|
||||
|
||||
|
@ -1080,6 +1125,26 @@ export class CoreWSProvider {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error to be thrown when it isn't possible to connect to a site.
|
||||
*
|
||||
* @param siteUrl Site url.
|
||||
* @param options Error options.
|
||||
* @return Cannot connect error.
|
||||
*/
|
||||
protected async createCannotConnectSiteError(
|
||||
siteUrl: string,
|
||||
options?: Partial<CoreSiteErrorOptions>,
|
||||
): Promise<CoreSiteError> {
|
||||
return new CoreSiteError({
|
||||
...options,
|
||||
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||
message: CoreSites.isLoggedIn()
|
||||
? Translate.instant('core.siteunavailablehelp', { site: CoreSites.getCurrentSite()?.siteUrl })
|
||||
: Translate.instant('core.sitenotfoundhelp'),
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const CoreWS = makeSingleton(CoreWSProvider);
|
||||
|
|
|
@ -282,6 +282,15 @@ export class CoreEvents {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until an event has been emitted.
|
||||
*
|
||||
* @param eventName Event name.
|
||||
*/
|
||||
static waitUntil(eventName: string): Promise<void> {
|
||||
return new Promise(resolve => this.once(eventName, () => resolve()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -20,6 +20,8 @@ import { CoreEventFormAction, CoreEvents } from '@singletons/events';
|
|||
*/
|
||||
export class CoreForms {
|
||||
|
||||
private static formIds: Record<string, number> = {};
|
||||
|
||||
/**
|
||||
* Get the data from a form. It will only collect elements that have a name.
|
||||
*
|
||||
|
@ -93,6 +95,18 @@ export class CoreForms {
|
|||
}, siteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique id for a form input using the given name.
|
||||
*
|
||||
* @param name Form input name.
|
||||
* @returns Unique id.
|
||||
*/
|
||||
static uniqueId(name: string): string {
|
||||
const count = this.formIds[name] ?? 0;
|
||||
|
||||
return `${name}-${this.formIds[name] = count + 1}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export type CoreFormFields<T = unknown> = Record<string, T>;
|
||||
|
|
|
@ -217,7 +217,10 @@ export const Router = makeSingleton(RouterService);
|
|||
export const DomSanitizer = makeSingleton(DomSanitizerService);
|
||||
|
||||
// Convert external libraries injectables.
|
||||
export const Translate = makeSingleton(TranslateService);
|
||||
export const Translate: Omit<CoreSingletonProxy<TranslateService>, 'instant'> & {
|
||||
instant(keys: string[]): string[];
|
||||
instant(key: string, interpolateParams?: Record<string, unknown>): string;
|
||||
} = makeSingleton(TranslateService);
|
||||
|
||||
// Async singletons.
|
||||
export const AngularFrameworkDelegate = asyncInstance(async () => {
|
||||
|
|
|
@ -12,13 +12,18 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export type CoreObjectWithoutEmpty<T> = {
|
||||
[k in keyof T]: T[k] extends undefined | null ? never : T[k];
|
||||
};
|
||||
import { Pretty } from '@/core/utils/types';
|
||||
|
||||
export type CoreObjectWithoutUndefined<T> = {
|
||||
[k in keyof T]: T[k] extends undefined ? never : T[k];
|
||||
};
|
||||
type ValueWithoutEmpty<T> = T extends null | undefined ? never : T;
|
||||
type ValueWithoutUndefined<T> = T extends undefined ? never : T;
|
||||
|
||||
export type CoreObjectWithoutEmpty<T> = Pretty<{
|
||||
[k in keyof T]: ValueWithoutEmpty<T[k]>;
|
||||
}>;
|
||||
|
||||
export type CoreObjectWithoutUndefined<T> = Pretty<{
|
||||
[k in keyof T]: ValueWithoutUndefined<T[k]>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Singleton with helper functions for objects.
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
// (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.
|
||||
|
||||
/**
|
||||
* Helper type to flatten complex types.
|
||||
*/
|
||||
export type Pretty<T> = T extends infer U ? {[K in keyof U]: U[K]} : never;
|
|
@ -27,8 +27,13 @@ import { CoreExternalContentDirectiveStub } from './stubs/directives/core-extern
|
|||
import { CoreNetwork } from '@services/network';
|
||||
import { CorePlatform } from '@services/platform';
|
||||
import { CoreDB } from '@services/db';
|
||||
import { CoreNavigator } from '@services/navigator';
|
||||
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { TranslateService, TranslateStore } from '@ngx-translate/core';
|
||||
import { CoreIonLoadingElement } from '@classes/ion-loading';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { DefaultUrlSerializer, UrlSerializer } from '@angular/router';
|
||||
import { CoreUtils } from '@services/utils/utils';
|
||||
|
||||
abstract class WrapperComponent<U> {
|
||||
|
||||
|
@ -43,8 +48,6 @@ const textUtils = new CoreTextUtilsProvider();
|
|||
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
|
||||
[Translate, mock({ instant: key => key })],
|
||||
[CoreDB, mock({ getDB: () => mock() })],
|
||||
[CoreNetwork, mock({ onChange: () => new Observable() })],
|
||||
[CoreDomUtils, mock({ showModalLoading: () => Promise.resolve(mock({}, ['dismiss'])) })],
|
||||
[CoreNavigator, mock({ navigateToSitePath: () => Promise.resolve(true) })],
|
||||
[ApplicationInit, mock({
|
||||
donePromise: Promise.resolve(),
|
||||
|
@ -56,9 +59,19 @@ const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unkno
|
|||
ready: () => Promise.resolve(),
|
||||
resume: new Subject<void>(),
|
||||
})],
|
||||
[CoreNetwork, mock({
|
||||
isOnline: () => true,
|
||||
onChange: () => new Observable(),
|
||||
})],
|
||||
[CoreDomUtils, mock({
|
||||
showModalLoading: () => Promise.resolve(mock<CoreIonLoadingElement>({ dismiss: jest.fn() })),
|
||||
})],
|
||||
[CoreUtils, mock({
|
||||
nextTick: () => Promise.resolve(),
|
||||
})],
|
||||
];
|
||||
|
||||
async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<ComponentFixture<T>> {
|
||||
async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<TestingComponentFixture<T>> {
|
||||
config.declarations.push(component);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
|
@ -67,11 +80,15 @@ async function renderAngularComponent<T>(component: Type<T>, config: RenderConfi
|
|||
...config.declarations,
|
||||
],
|
||||
providers: [
|
||||
...getDefaultProviders(),
|
||||
...getDefaultProviders(config),
|
||||
...config.providers,
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
imports: [BrowserModule],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
...config.imports,
|
||||
],
|
||||
});
|
||||
|
||||
testBedInitialized = true;
|
||||
|
@ -106,7 +123,7 @@ function getDefaultDeclarations(): unknown[] {
|
|||
];
|
||||
}
|
||||
|
||||
function getDefaultProviders(): unknown[] {
|
||||
function getDefaultProviders(config: RenderConfig): unknown[] {
|
||||
const serviceProviders = DEFAULT_SERVICE_SINGLETON_MOCKS.map(
|
||||
([singleton, mockInstance]) => ({
|
||||
provide: singleton.injectionToken,
|
||||
|
@ -116,6 +133,19 @@ function getDefaultProviders(): unknown[] {
|
|||
|
||||
return [
|
||||
...serviceProviders,
|
||||
{
|
||||
provide: TranslateStore,
|
||||
useFactory: () => {
|
||||
const store = new TranslateStore();
|
||||
|
||||
store.translations = {
|
||||
en: config.translations ?? {},
|
||||
};
|
||||
|
||||
return store;
|
||||
},
|
||||
},
|
||||
{ provide: UrlSerializer, useClass: DefaultUrlSerializer },
|
||||
{ provide: CORE_SITE_SCHEMAS, multiple: true, useValue: [] },
|
||||
];
|
||||
}
|
||||
|
@ -141,9 +171,54 @@ function createNewServiceInstance(injectionToken: Exclude<ServiceInjectionToken,
|
|||
export interface RenderConfig {
|
||||
declarations: unknown[];
|
||||
providers: unknown[];
|
||||
imports: unknown[];
|
||||
translations?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type WrapperComponentFixture<T> = ComponentFixture<WrapperComponent<T>>;
|
||||
export interface RenderPageConfig extends RenderConfig {
|
||||
routeParams: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TestingComponentFixture<T = unknown> = Omit<ComponentFixture<T>, 'nativeElement'> & { nativeElement: Element };
|
||||
|
||||
export type WrapperComponentFixture<T = unknown> = TestingComponentFixture<WrapperComponent<T>>;
|
||||
|
||||
export function findElement<E = HTMLElement>(
|
||||
fixture: TestingComponentFixture,
|
||||
selector: string,
|
||||
content?: string | RegExp,
|
||||
): E | null {
|
||||
const elements = fixture.nativeElement.querySelectorAll(selector);
|
||||
const matches = typeof content === 'string'
|
||||
? (textContent: string | null) => textContent?.includes(content)
|
||||
: (textContent: string | null) => textContent?.match(content ?? '');
|
||||
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
|
||||
if (content && !matches(element.textContent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return element as unknown as E;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function requireElement<E = HTMLElement>(
|
||||
fixture: TestingComponentFixture,
|
||||
selector: string,
|
||||
content?: string | RegExp,
|
||||
): E {
|
||||
const element = findElement<E>(fixture, selector, content);
|
||||
|
||||
if (!element) {
|
||||
throw Error(`Could not find '${selector}' element`);
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock a certain class, converting its methods to Mock functions and overriding the specified properties and methods.
|
||||
|
@ -158,7 +233,7 @@ export function mock<T>(
|
|||
): T {
|
||||
// If overrides is an object, apply them to the instance.
|
||||
if (!Array.isArray(overrides)) {
|
||||
Object.assign(instance, overrides);
|
||||
Object.assign(instance as Record<string, unknown>, overrides);
|
||||
}
|
||||
|
||||
// Convert instance functions to jest functions.
|
||||
|
@ -182,8 +257,7 @@ export function mock<T>(
|
|||
return instance as T;
|
||||
}
|
||||
|
||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<unknown>, instance?: Record<string, unknown>): T;
|
||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T | Partial<T>): T;
|
||||
export function mockSingleton<T>(
|
||||
singletonClass: CoreSingletonProxy<unknown>,
|
||||
methods: string[],
|
||||
|
@ -200,7 +274,7 @@ export function mockSingleton<T>(
|
|||
const instance = getServiceInstance(singleton.injectionToken) as T;
|
||||
const mockInstance = mock(instance, methods);
|
||||
|
||||
Object.assign(mockInstance, properties);
|
||||
Object.assign(mockInstance as Record<string, unknown>, properties);
|
||||
|
||||
singleton.setInstance(mockInstance);
|
||||
|
||||
|
@ -225,14 +299,36 @@ export function getServiceInstance(injectionToken: ServiceInjectionToken): Recor
|
|||
?? {};
|
||||
}
|
||||
|
||||
export async function renderComponent<T>(component: Type<T>, config: Partial<RenderConfig> = {}): Promise<ComponentFixture<T>> {
|
||||
export async function renderComponent<T>(
|
||||
component: Type<T>,
|
||||
config: Partial<RenderConfig> = {},
|
||||
): Promise<TestingComponentFixture<T>> {
|
||||
return renderAngularComponent(component, {
|
||||
declarations: [],
|
||||
providers: [],
|
||||
imports: [],
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderPageComponent<T>(
|
||||
component: Type<T>,
|
||||
config: Partial<RenderPageConfig> = {},
|
||||
): Promise<TestingComponentFixture<T>> {
|
||||
mockSingleton(CoreNavigator, mock<CoreNavigatorService>({
|
||||
getRequiredRouteParam<T>(name: string) {
|
||||
if (!config.routeParams?.[name]) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
return config.routeParams?.[name] as T;
|
||||
},
|
||||
getRouteParam: <T>(name: string) => config.routeParams?.[name] as T | undefined,
|
||||
}));
|
||||
|
||||
return renderComponent(component, config);
|
||||
}
|
||||
|
||||
export async function renderTemplate<T>(
|
||||
component: Type<T>,
|
||||
template: string,
|
||||
|
@ -246,6 +342,7 @@ export async function renderTemplate<T>(
|
|||
{
|
||||
declarations: [],
|
||||
providers: [],
|
||||
imports: [],
|
||||
...config,
|
||||
},
|
||||
);
|
||||
|
@ -293,8 +390,17 @@ export function wait(time: number): Promise<void> {
|
|||
*
|
||||
* @param translations List of translations.
|
||||
*/
|
||||
export function mockTranslate(translations: Record<string, string>): void {
|
||||
mockSingleton(Translate, {
|
||||
instant: (key) => translations[key] ?? key,
|
||||
export function mockTranslate(translations: Record<string, string> = {}): void {
|
||||
mockSingleton(Translate as CoreSingletonProxy<TranslateService>, {
|
||||
instant: (key, replacements) => {
|
||||
const applyReplacements = (text: string): string => Object.entries(replacements ?? {}).reduce(
|
||||
(text, [name, value]) => text.replace(`{{${name}}}`, value),
|
||||
text,
|
||||
);
|
||||
|
||||
return Array.isArray(key)
|
||||
? key.map(k => applyReplacements(translations[k] ?? k))
|
||||
: applyReplacements(translations[key] ?? key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
html {
|
||||
|
||||
// Spacing
|
||||
@for $i from 0 to 13 {
|
||||
--spacing-#{$i}: #{$i*4}px;
|
||||
}
|
||||
|
||||
// Font sizes
|
||||
--font-size-sm: 12px;
|
||||
--font-size-normal: 14px;
|
||||
|
||||
// Radiuses
|
||||
--small-radius: 4px;
|
||||
--medium-radius: 8px;
|
||||
--big-radius: 16px;
|
||||
--huge-radius: 24px;
|
||||
|
||||
// A11y
|
||||
--a11y-min-target-size: 44px;
|
||||
|
||||
}
|
|
@ -48,18 +48,12 @@ html {
|
|||
}
|
||||
|
||||
// Accessibility vars.
|
||||
--a11y-min-target-size: 44px;
|
||||
--a11y-focus-color: var(--primary);
|
||||
--a11y-focus-width: 2px;
|
||||
--zoom-level: 100%;
|
||||
|
||||
--small-radius: 4px;
|
||||
--medium-radius: 8px;
|
||||
--big-radius: 16px;
|
||||
--huge-radius: 24px;
|
||||
|
||||
--text-color: #{$text-color};
|
||||
--text-size: 14px;
|
||||
--text-size: var(--font-size-normal);
|
||||
--background-color: #{$background-color};
|
||||
--stroke: var(--gray-300);
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
@import "./theme.light.scss";
|
||||
@import "./theme.dark.scss";
|
||||
@import "./theme.custom.scss";
|
||||
@import "./theme.design-system.scss";
|
||||
@import "./theme.base.scss";
|
||||
|
||||
/* Components */
|
||||
|
@ -24,6 +25,7 @@
|
|||
@import "./components/format-text.scss";
|
||||
@import "./components/rubrics.scss";
|
||||
@import "./components/mod-label.scss";
|
||||
@import "../core/components/error-info/error-info.scss";
|
||||
|
||||
/* Some styles from 3rd party libraries. */
|
||||
@import "./bootstrap.scss";
|
||||
|
|
|
@ -12,27 +12,8 @@
|
|||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
declare module '!raw-loader!*' {
|
||||
const contents: string;
|
||||
|
||||
import { CoreSharedModule } from '@/core/shared.module';
|
||||
import { CoreLoginForgottenPasswordPage } from './forgotten-password';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CoreLoginForgottenPasswordPage,
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(routes),
|
||||
CoreSharedModule,
|
||||
],
|
||||
declarations: [
|
||||
CoreLoginForgottenPasswordPage,
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class CoreLoginForgottenPasswordPageModule {}
|
||||
export = contents;
|
||||
}
|
Loading…
Reference in New Issue