commit
ed3d8e333b
|
@ -1,4 +1,5 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
framework: '@storybook/angular',
|
framework: '@storybook/angular',
|
||||||
|
addons: ['@storybook/addon-controls'],
|
||||||
stories: ['../src/**/*.stories.ts'],
|
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',
|
'^.+\\.(ts|html)$': 'ts-jest',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['node_modules/(?!@ionic-native|@ionic)'],
|
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: {
|
globals: {
|
||||||
'ts-jest': {
|
'ts-jest': {
|
||||||
tsConfig: './tsconfig.test.json',
|
tsConfig: './tsconfig.test.json',
|
||||||
|
|
|
@ -5677,6 +5677,22 @@
|
||||||
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.11.0.tgz",
|
||||||
"integrity": "sha512-/IubCWhVXCguyMUp/3zGrg3c882+RJNg/zpiKfyfJL3kRCOwe+/MD8OoAXVGdd+xAohZKIi1Ik+EHFlsptsjLg=="
|
"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": {
|
"@storybook/addons": {
|
||||||
"version": "6.1.21",
|
"version": "6.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.21.tgz",
|
"resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.1.21.tgz",
|
||||||
|
@ -21473,6 +21489,12 @@
|
||||||
"ts-jest": "26.x"
|
"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": {
|
"jest-regex-util": {
|
||||||
"version": "26.0.0",
|
"version": "26.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz",
|
"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",
|
"@angular/language-service": "~10.0.14",
|
||||||
"@ionic/angular-toolkit": "^2.3.3",
|
"@ionic/angular-toolkit": "^2.3.3",
|
||||||
"@ionic/cli": "^6.19.0",
|
"@ionic/cli": "^6.19.0",
|
||||||
|
"@storybook/addon-controls": "~6.1",
|
||||||
"@storybook/angular": "~6.1",
|
"@storybook/angular": "~6.1",
|
||||||
"@types/faker": "^5.1.3",
|
"@types/faker": "^5.1.3",
|
||||||
"@types/node": "^12.12.64",
|
"@types/node": "^12.12.64",
|
||||||
|
@ -172,6 +173,7 @@
|
||||||
"gulp-slash": "^1.1.3",
|
"gulp-slash": "^1.1.3",
|
||||||
"jest": "^26.5.2",
|
"jest": "^26.5.2",
|
||||||
"jest-preset-angular": "^8.3.1",
|
"jest-preset-angular": "^8.3.1",
|
||||||
|
"jest-raw-loader": "^1.0.1",
|
||||||
"jsonc-parser": "^2.3.1",
|
"jsonc-parser": "^2.3.1",
|
||||||
"minimatch": "^5.1.0",
|
"minimatch": "^5.1.0",
|
||||||
"native-run": "^1.4.0",
|
"native-run": "^1.4.0",
|
||||||
|
|
|
@ -1471,8 +1471,6 @@
|
||||||
"core.calculating": "local_moodlemobileapp",
|
"core.calculating": "local_moodlemobileapp",
|
||||||
"core.cancel": "moodle",
|
"core.cancel": "moodle",
|
||||||
"core.cannotconnect": "local_moodlemobileapp",
|
"core.cannotconnect": "local_moodlemobileapp",
|
||||||
"core.cannotconnecttrouble": "local_moodlemobileapp",
|
|
||||||
"core.cannotconnectverify": "local_moodlemobileapp",
|
|
||||||
"core.cannotdownloadfiles": "local_moodlemobileapp",
|
"core.cannotdownloadfiles": "local_moodlemobileapp",
|
||||||
"core.cannotinstallapk": "local_moodlemobileapp",
|
"core.cannotinstallapk": "local_moodlemobileapp",
|
||||||
"core.cannotlogoutpageblocks": "local_moodlemobileapp",
|
"core.cannotlogoutpageblocks": "local_moodlemobileapp",
|
||||||
|
@ -1519,7 +1517,9 @@
|
||||||
"core.confirmleaveunknownchanges": "local_moodlemobileapp",
|
"core.confirmleaveunknownchanges": "local_moodlemobileapp",
|
||||||
"core.confirmloss": "local_moodlemobileapp",
|
"core.confirmloss": "local_moodlemobileapp",
|
||||||
"core.confirmopeninbrowser": "local_moodlemobileapp",
|
"core.confirmopeninbrowser": "local_moodlemobileapp",
|
||||||
|
"core.connectionlost": "local_moodlemobileapp",
|
||||||
"core.considereddigitalminor": "moodle",
|
"core.considereddigitalminor": "moodle",
|
||||||
|
"core.contactsupport": "local_moodlemobileapp",
|
||||||
"core.content": "moodle",
|
"core.content": "moodle",
|
||||||
"core.contenteditingsynced": "local_moodlemobileapp",
|
"core.contenteditingsynced": "local_moodlemobileapp",
|
||||||
"core.contentlinks.chooseaccount": "local_moodlemobileapp",
|
"core.contentlinks.chooseaccount": "local_moodlemobileapp",
|
||||||
|
@ -1696,7 +1696,10 @@
|
||||||
"core.endonesteptour": "tool_usertours",
|
"core.endonesteptour": "tool_usertours",
|
||||||
"core.error": "moodle",
|
"core.error": "moodle",
|
||||||
"core.errorchangecompletion": "local_moodlemobileapp",
|
"core.errorchangecompletion": "local_moodlemobileapp",
|
||||||
|
"core.errorcode": "local_moodlemobileapp",
|
||||||
"core.errordeletefile": "local_moodlemobileapp",
|
"core.errordeletefile": "local_moodlemobileapp",
|
||||||
|
"core.errordetailshide": "local_moodlemobileapp",
|
||||||
|
"core.errordetailsshow": "local_moodlemobileapp",
|
||||||
"core.errordownloading": "local_moodlemobileapp",
|
"core.errordownloading": "local_moodlemobileapp",
|
||||||
"core.errordownloadingsomefiles": "local_moodlemobileapp",
|
"core.errordownloadingsomefiles": "local_moodlemobileapp",
|
||||||
"core.errorfileexistssamename": "local_moodlemobileapp",
|
"core.errorfileexistssamename": "local_moodlemobileapp",
|
||||||
|
@ -1908,16 +1911,17 @@
|
||||||
"core.login.changepasswordbutton": "local_moodlemobileapp",
|
"core.login.changepasswordbutton": "local_moodlemobileapp",
|
||||||
"core.login.changepasswordhelp": "local_moodlemobileapp",
|
"core.login.changepasswordhelp": "local_moodlemobileapp",
|
||||||
"core.login.changepasswordreconnectinstructions": "local_moodlemobileapp",
|
"core.login.changepasswordreconnectinstructions": "local_moodlemobileapp",
|
||||||
|
"core.login.changepasswordsupportsubject": "local_moodlemobileapp",
|
||||||
"core.login.confirmdeletesite": "local_moodlemobileapp",
|
"core.login.confirmdeletesite": "local_moodlemobileapp",
|
||||||
"core.login.connect": "local_moodlemobileapp",
|
"core.login.connect": "local_moodlemobileapp",
|
||||||
"core.login.connecttomoodle": "local_moodlemobileapp",
|
"core.login.connecttomoodle": "local_moodlemobileapp",
|
||||||
"core.login.connecttomoodleapp": "local_moodlemobileapp",
|
"core.login.connecttomoodleapp": "local_moodlemobileapp",
|
||||||
"core.login.connecttoworkplaceapp": "local_moodlemobileapp",
|
"core.login.connecttoworkplaceapp": "local_moodlemobileapp",
|
||||||
"core.login.contactyouradministrator": "local_moodlemobileapp",
|
|
||||||
"core.login.contactyouradministratorissue": "local_moodlemobileapp",
|
|
||||||
"core.login.createaccount": "moodle",
|
"core.login.createaccount": "moodle",
|
||||||
"core.login.createuserandpass": "moodle",
|
"core.login.createuserandpass": "moodle",
|
||||||
"core.login.credentialsdescription": "local_moodlemobileapp",
|
"core.login.credentialsdescription": "local_moodlemobileapp",
|
||||||
|
"core.login.credentialshelp": "local_moodlemobileapp",
|
||||||
|
"core.login.credentialssupportsubject": "local_moodlemobileapp",
|
||||||
"core.login.emailconfirmsent": "moodle",
|
"core.login.emailconfirmsent": "moodle",
|
||||||
"core.login.emailconfirmsentnoemail": "local_moodlemobileapp",
|
"core.login.emailconfirmsentnoemail": "local_moodlemobileapp",
|
||||||
"core.login.emailconfirmsentsuccess": "moodle",
|
"core.login.emailconfirmsentsuccess": "moodle",
|
||||||
|
@ -1927,8 +1931,11 @@
|
||||||
"core.login.errorexampleurl": "local_moodlemobileapp",
|
"core.login.errorexampleurl": "local_moodlemobileapp",
|
||||||
"core.login.errorqrnoscheme": "local_moodlemobileapp",
|
"core.login.errorqrnoscheme": "local_moodlemobileapp",
|
||||||
"core.login.errorupdatesite": "local_moodlemobileapp",
|
"core.login.errorupdatesite": "local_moodlemobileapp",
|
||||||
"core.login.faqcannotconnectanswer": "local_moodlemobileapp",
|
"core.login.exceededloginattempts": "local_moodlemobileapp",
|
||||||
"core.login.faqcannotconnectquestion": "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.faqcannotfindmysiteanswer": "local_moodlemobileapp",
|
||||||
"core.login.faqcannotfindmysitequestion": "local_moodlemobileapp",
|
"core.login.faqcannotfindmysitequestion": "local_moodlemobileapp",
|
||||||
"core.login.faqsetupsiteanswer": "local_moodlemobileapp",
|
"core.login.faqsetupsiteanswer": "local_moodlemobileapp",
|
||||||
|
@ -1945,7 +1952,6 @@
|
||||||
"core.login.forcepasswordchangenotice": "moodle",
|
"core.login.forcepasswordchangenotice": "moodle",
|
||||||
"core.login.forgotten": "moodle",
|
"core.login.forgotten": "moodle",
|
||||||
"core.login.help": "moodle",
|
"core.login.help": "moodle",
|
||||||
"core.login.helpmelogin": "local_moodlemobileapp",
|
|
||||||
"core.login.instructions": "auth",
|
"core.login.instructions": "auth",
|
||||||
"core.login.invalidaccount": "local_moodlemobileapp",
|
"core.login.invalidaccount": "local_moodlemobileapp",
|
||||||
"core.login.invaliddate": "calendar/errorinvaliddate",
|
"core.login.invaliddate": "calendar/errorinvaliddate",
|
||||||
|
@ -1994,7 +2000,9 @@
|
||||||
"core.login.recaptchaexpired": "local_moodlemobileapp",
|
"core.login.recaptchaexpired": "local_moodlemobileapp",
|
||||||
"core.login.recaptchaincorrect": "local_moodlemobileapp",
|
"core.login.recaptchaincorrect": "local_moodlemobileapp",
|
||||||
"core.login.reconnect": "local_moodlemobileapp",
|
"core.login.reconnect": "local_moodlemobileapp",
|
||||||
|
"core.login.reconnecthelp": "local_moodlemobileapp",
|
||||||
"core.login.reconnectssodescription": "local_moodlemobileapp",
|
"core.login.reconnectssodescription": "local_moodlemobileapp",
|
||||||
|
"core.login.reconnectsupportsubject": "local_moodlemobileapp",
|
||||||
"core.login.reconnecttosite": "local_moodlemobileapp",
|
"core.login.reconnecttosite": "local_moodlemobileapp",
|
||||||
"core.login.removeaccount": "local_moodlemobileapp",
|
"core.login.removeaccount": "local_moodlemobileapp",
|
||||||
"core.login.resendemail": "moodle",
|
"core.login.resendemail": "moodle",
|
||||||
|
@ -2266,6 +2274,9 @@
|
||||||
"core.sitehome.sitehome": "moodle",
|
"core.sitehome.sitehome": "moodle",
|
||||||
"core.sitehome.sitenews": "moodle",
|
"core.sitehome.sitenews": "moodle",
|
||||||
"core.sitemaintenance": "admin",
|
"core.sitemaintenance": "admin",
|
||||||
|
"core.sitenotfound": "local_moodlemobileapp",
|
||||||
|
"core.sitenotfoundhelp": "local_moodlemobileapp",
|
||||||
|
"core.siteunavailablehelp": "local_moodlemobileapp",
|
||||||
"core.size": "moodle",
|
"core.size": "moodle",
|
||||||
"core.sizeb": "moodle",
|
"core.sizeb": "moodle",
|
||||||
"core.sizegb": "moodle",
|
"core.sizegb": "moodle",
|
||||||
|
@ -2338,8 +2349,10 @@
|
||||||
"core.user.address": "moodle",
|
"core.user.address": "moodle",
|
||||||
"core.user.city": "moodle",
|
"core.user.city": "moodle",
|
||||||
"core.user.completeprofile": "local_moodlemobileapp",
|
"core.user.completeprofile": "local_moodlemobileapp",
|
||||||
|
"core.user.completeprofilehelp": "local_moodlemobileapp",
|
||||||
"core.user.completeprofilenotice": "local_moodlemobileapp",
|
"core.user.completeprofilenotice": "local_moodlemobileapp",
|
||||||
"core.user.completeprofilereconnectinstructions": "local_moodlemobileapp",
|
"core.user.completeprofilereconnectinstructions": "local_moodlemobileapp",
|
||||||
|
"core.user.completeprofilesupportsubject": "local_moodlemobileapp",
|
||||||
"core.user.completeyourprofile": "local_moodlemobileapp",
|
"core.user.completeyourprofile": "local_moodlemobileapp",
|
||||||
"core.user.contact": "local_moodlemobileapp",
|
"core.user.contact": "local_moodlemobileapp",
|
||||||
"core.user.country": "moodle",
|
"core.user.country": "moodle",
|
||||||
|
@ -2363,6 +2376,7 @@
|
||||||
"core.user.roles": "moodle",
|
"core.user.roles": "moodle",
|
||||||
"core.user.sendemail": "local_moodlemobileapp",
|
"core.user.sendemail": "local_moodlemobileapp",
|
||||||
"core.user.student": "moodle/defaultcoursestudent",
|
"core.user.student": "moodle/defaultcoursestudent",
|
||||||
|
"core.user.support": "local_moodlemobileapp",
|
||||||
"core.user.teacher": "moodle/noneditingteacher",
|
"core.user.teacher": "moodle/noneditingteacher",
|
||||||
"core.user.useraccount": "moodle",
|
"core.user.useraccount": "moodle",
|
||||||
"core.user.userwithid": "local_moodlemobileapp",
|
"core.user.userwithid": "local_moodlemobileapp",
|
||||||
|
@ -2385,7 +2399,6 @@
|
||||||
"core.weeks": "moodle",
|
"core.weeks": "moodle",
|
||||||
"core.whatisyourage": "moodle",
|
"core.whatisyourage": "moodle",
|
||||||
"core.wheredoyoulive": "moodle",
|
"core.wheredoyoulive": "moodle",
|
||||||
"core.whoissiteadmin": "local_moodlemobileapp",
|
|
||||||
"core.whyisthishappening": "local_moodlemobileapp",
|
"core.whyisthishappening": "local_moodlemobileapp",
|
||||||
"core.whyisthisrequired": "moodle",
|
"core.whyisthisrequired": "moodle",
|
||||||
"core.wsfunctionnotavailable": "local_moodlemobileapp",
|
"core.wsfunctionnotavailable": "local_moodlemobileapp",
|
||||||
|
|
|
@ -31,7 +31,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa
|
||||||
implements CanLeave, OnDestroy {
|
implements CanLeave, OnDestroy {
|
||||||
|
|
||||||
canLeaveSafely = false;
|
canLeaveSafely = false;
|
||||||
remainingTimeout?: ReturnType<typeof setTimeout>;
|
remainingTimeout?: number;
|
||||||
|
|
||||||
@ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent;
|
@ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent;
|
||||||
|
|
||||||
|
@ -68,7 +68,7 @@ export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPa
|
||||||
clearTimeout(this.remainingTimeout);
|
clearTimeout(this.remainingTimeout);
|
||||||
}
|
}
|
||||||
// When user finish an activity, he have 10 seconds to leave safely (without show alert).
|
// 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;
|
this.canLeaveSafely = false;
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { CoreSite } from '@classes/site';
|
||||||
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
|
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
|
||||||
import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course';
|
import { CoreCourse, CoreCourseModuleBasicInfo } from '@features/course/services/course';
|
||||||
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
|
||||||
|
@ -313,7 +314,7 @@ export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider
|
||||||
|
|
||||||
if (!CoreNetwork.isOnline()) {
|
if (!CoreNetwork.isOnline()) {
|
||||||
// Cannot sync in offline.
|
// 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()!;
|
const offlineAttempt = offlineAttempts.pop()!;
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
"errordownloadscorm": "Error downloading SCORM: \"{{name}}\".",
|
"errordownloadscorm": "Error downloading SCORM: \"{{name}}\".",
|
||||||
"errorgetscorm": "Error getting SCORM data.",
|
"errorgetscorm": "Error getting SCORM data.",
|
||||||
"errorinvalidversion": "Sorry, the application only supports SCORM 1.2.",
|
"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.",
|
"errornovalidsco": "This SCORM package doesn't have a visible SCO to load.",
|
||||||
"errorpackagefile": "Sorry, the application only supports ZIP packages.",
|
"errorpackagefile": "Sorry, the application only supports ZIP packages.",
|
||||||
"errorsyncscorm": "An error occurred while synchronising. Please try again.",
|
"errorsyncscorm": "An error occurred while synchronising. Please try again.",
|
||||||
|
@ -49,4 +49,4 @@
|
||||||
"toc": "TOC",
|
"toc": "TOC",
|
||||||
"warningofflinedatadeleted": "Some offline data from attempt {{number}} has been discarded because it couldn't be counted as a new attempt.",
|
"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."
|
"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 { CoreLang } from '@services/lang';
|
||||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||||
import { CoreEvents } from '@singletons/events';
|
import { CoreEvents } from '@singletons/events';
|
||||||
import { NgZone, SplashScreen, Translate } from '@singletons';
|
import { NgZone, SplashScreen } from '@singletons';
|
||||||
import { CoreNetwork } from '@services/network';
|
import { CoreNetwork } from '@services/network';
|
||||||
import { CoreApp } from '@services/app';
|
import { CoreApp } from '@services/app';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreSubscriptions } from '@singletons/subscriptions';
|
import { CoreSubscriptions } from '@singletons/subscriptions';
|
||||||
import { CoreWindow } from '@singletons/window';
|
import { CoreWindow } from '@singletons/window';
|
||||||
import { CoreCustomURLSchemes } from '@services/urlschemes';
|
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreUrlUtils } from '@services/utils/url';
|
|
||||||
import { CoreConstants } from '@/core/constants';
|
import { CoreConstants } from '@/core/constants';
|
||||||
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
|
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
@ -46,8 +44,6 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
|
|
||||||
@ViewChild(IonRouterOutlet) outlet?: IonRouterOutlet;
|
@ViewChild(IonRouterOutlet) outlet?: IonRouterOutlet;
|
||||||
|
|
||||||
protected lastInAppUrl?: string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component being initialized.
|
* Component being initialized.
|
||||||
*/
|
*/
|
||||||
|
@ -91,60 +87,6 @@ export class AppComponent implements OnInit, AfterViewInit {
|
||||||
content.classList.toggle('core-footer-shadow', !CoreDom.scrollIsBottom(scrollElement));
|
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(() => {
|
CorePlatform.resume.subscribe(() => {
|
||||||
// Wait a second before setting it to false since in iOS there could be some frozen WS calls.
|
// Wait a second before setting it to false since in iOS there could be some frozen WS calls.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
|
@ -12,18 +12,18 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreSiteError, CoreSiteErrorOptions } from '@classes/errors/siteerror';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic error returned by an Ajax call.
|
* 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.
|
available = 1; // @deprecated since app 4.0. AJAX endpoint should always be available in supported Moodle versions.
|
||||||
status?: number;
|
status?: number;
|
||||||
|
|
||||||
constructor(message: string, available?: number, status?: number) {
|
constructor(messageOrOptions: string | CoreSiteErrorOptions, available?: number, status?: number) {
|
||||||
super(message);
|
super(typeof messageOrOptions === 'string' ? { message: messageOrOptions } : messageOrOptions);
|
||||||
|
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,15 +12,14 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreSiteError } from '@classes/errors/siteerror';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error returned by WS.
|
* Error returned by WS.
|
||||||
*/
|
*/
|
||||||
export class CoreAjaxWSError extends CoreError {
|
export class CoreAjaxWSError extends CoreSiteError {
|
||||||
|
|
||||||
exception?: string; // Name of the Moodle exception.
|
exception?: string; // Name of the Moodle exception.
|
||||||
errorcode?: string;
|
|
||||||
warningcode?: string;
|
warningcode?: string;
|
||||||
link?: string; // Link to the site.
|
link?: string; // Link to the site.
|
||||||
moreinfourl?: string; // Link to a page with more info.
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
constructor(error: any, available?: number) {
|
constructor(error: any, available?: number) {
|
||||||
super(error.message || error.error);
|
super({
|
||||||
|
message: error.message || error.error,
|
||||||
|
errorcode: error.errorcode,
|
||||||
|
});
|
||||||
|
|
||||||
this.exception = error.exception;
|
this.exception = error.exception;
|
||||||
this.errorcode = error.errorcode;
|
|
||||||
this.warningcode = error.warningcode;
|
this.warningcode = error.warningcode;
|
||||||
this.link = error.link;
|
this.link = error.link;
|
||||||
this.moreinfourl = error.moreinfourl;
|
this.moreinfourl = error.moreinfourl;
|
||||||
|
|
|
@ -23,6 +23,7 @@ import { CoreAjaxWSError } from './ajaxwserror';
|
||||||
import { CoreCaptureError } from './captureerror';
|
import { CoreCaptureError } from './captureerror';
|
||||||
import { CoreNetworkError } from './network-error';
|
import { CoreNetworkError } from './network-error';
|
||||||
import { CoreSiteError } from './siteerror';
|
import { CoreSiteError } from './siteerror';
|
||||||
|
import { CoreLoginError } from './loginerror';
|
||||||
import { CoreErrorWithOptions } from './errorwithtitle';
|
import { CoreErrorWithOptions } from './errorwithtitle';
|
||||||
import { CoreHttpError } from './httperror';
|
import { CoreHttpError } from './httperror';
|
||||||
|
|
||||||
|
@ -35,6 +36,7 @@ export const CORE_ERRORS_CLASSES: Type<unknown>[] = [
|
||||||
CoreNetworkError,
|
CoreNetworkError,
|
||||||
CoreSilentError,
|
CoreSilentError,
|
||||||
CoreSiteError,
|
CoreSiteError,
|
||||||
|
CoreLoginError,
|
||||||
CoreWSError,
|
CoreWSError,
|
||||||
CoreErrorWithOptions,
|
CoreErrorWithOptions,
|
||||||
CoreHttpError,
|
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.
|
// limitations under the License.
|
||||||
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
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 {
|
export class CoreSiteError extends CoreError {
|
||||||
|
|
||||||
errorcode?: string;
|
errorcode?: string;
|
||||||
critical?: boolean;
|
errorDetails?: string;
|
||||||
loggedOut?: boolean;
|
supportConfig?: CoreUserSupportConfig;
|
||||||
|
|
||||||
constructor(protected error: SiteError) {
|
constructor(options: CoreSiteErrorOptions) {
|
||||||
super(error.message);
|
super(options.message);
|
||||||
|
|
||||||
this.errorcode = error.errorcode;
|
this.errorcode = options.errorcode;
|
||||||
this.critical = error.critical;
|
this.errorDetails = options.errorDetails;
|
||||||
this.loggedOut = error.loggedOut;
|
this.supportConfig = options.supportConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SiteError = {
|
export type CoreSiteErrorOptions = {
|
||||||
message: string;
|
message: string;
|
||||||
errorcode?: string;
|
errorcode?: string; // Technical error code useful for technical assistance.
|
||||||
critical?: boolean; // Whether the error is important enough to abort the operation.
|
errorDetails?: string; // Technical error details useful for technical assistance.
|
||||||
loggedOut?: boolean; // Whether site has been marked as logged out.
|
|
||||||
|
// 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 { Observable, ObservableInput, ObservedValueOf, OperatorFunction, Subject } from 'rxjs';
|
||||||
import { finalize, map, mergeMap } from 'rxjs/operators';
|
import { finalize, map, mergeMap } from 'rxjs/operators';
|
||||||
import { firstValueFrom } from '../utils/rxjs';
|
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.
|
* QR Code type enumeration.
|
||||||
|
@ -808,9 +810,7 @@ export class CoreSite {
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (preSets.forceOffline) {
|
if (preSets.forceOffline) {
|
||||||
// Don't call the WS, just fail.
|
// Don't call the WS, just fail.
|
||||||
throw new CoreError(
|
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1130,7 +1130,12 @@ export class CoreSite {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data || !data.responses) {
|
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) => {
|
requests.forEach((request, i) => {
|
||||||
|
@ -1670,6 +1675,10 @@ export class CoreSite {
|
||||||
*/
|
*/
|
||||||
async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise<CoreSitePublicConfigResponse> {
|
async getPublicConfig(options: { readingStrategy?: CoreSitesReadingStrategy } = {}): Promise<CoreSitePublicConfigResponse> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
|
if (options.readingStrategy === CoreSitesReadingStrategy.ONLY_CACHE) {
|
||||||
|
throw new CoreError('Cache not available to read public config');
|
||||||
|
}
|
||||||
|
|
||||||
return this.requestPublicConfig();
|
return this.requestPublicConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1714,9 +1723,7 @@ export class CoreSite {
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
if (cachePreSets.forceOffline) {
|
if (cachePreSets.forceOffline) {
|
||||||
// Don't call the WS, just fail.
|
// Don't call the WS, just fail.
|
||||||
throw new CoreError(
|
throw new CoreError(Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }));
|
||||||
Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the WS.
|
// Call the WS.
|
||||||
|
@ -2742,10 +2749,21 @@ export type CoreSiteConfigResponse = {
|
||||||
warnings?: CoreWSExternalWarning[];
|
warnings?: CoreWSExternalWarning[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible values for 'supportavailability' config.
|
||||||
|
*/
|
||||||
|
export const enum CoreSiteConfigSupportAvailability {
|
||||||
|
Disabled = 0,
|
||||||
|
Authenticated = 1,
|
||||||
|
Anyone = 2,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site config indexed by name.
|
* 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.
|
* Result of WS tool_mobile_get_public_config.
|
||||||
|
@ -2777,6 +2795,8 @@ export type CoreSitePublicConfigResponse = {
|
||||||
agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled.
|
agedigitalconsentverification?: boolean; // Whether age digital consent verification is enabled.
|
||||||
supportname?: string; // Site support contact name (only if age 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).
|
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.
|
autolang?: number; // Whether to detect default language from browser setting.
|
||||||
lang?: string; // Default language for the site.
|
lang?: string; // Default language for the site.
|
||||||
langmenu?: number; // Whether the language menu should be displayed.
|
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');
|
const table = new CoreDatabaseTableProxy<User>(config, database, 'users');
|
||||||
|
|
||||||
mockSingleton(CoreConfig, { isReady: () => Promise.resolve() });
|
mockSingleton(CoreConfig, { ready: () => Promise.resolve() });
|
||||||
|
|
||||||
return [records, database, table];
|
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);
|
expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0);
|
||||||
|
|
||||||
const icon = fixture.nativeElement.querySelector('ion-icon');
|
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(icon).not.toBeNull();
|
||||||
expect(name).toEqual('fa-thumbs-up');
|
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');
|
const iframe = nativeElement.querySelector('iframe');
|
||||||
expect(iframe).not.toBeNull();
|
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');
|
const image = nativeElement.querySelector('img');
|
||||||
expect(image).not.toBeNull();
|
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 { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
import { CoreConstants } from '../constants';
|
import { CoreConstants } from '../constants';
|
||||||
import { CoreNetwork } from '@services/network';
|
import { CoreNetwork } from '@services/network';
|
||||||
|
import { Translate } from '@singletons';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directive to handle external content.
|
* Directive to handle external content.
|
||||||
|
@ -217,7 +218,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges, O
|
||||||
if (!site.canDownloadFiles() && isSiteFile) {
|
if (!site.canDownloadFiles() && isSiteFile) {
|
||||||
this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken.
|
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);
|
const finalUrl = await this.getUrlToUse(targetAttr, url, site);
|
||||||
|
|
|
@ -29,7 +29,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy {
|
||||||
|
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
pressGesture?: Gesture;
|
pressGesture?: Gesture;
|
||||||
timeout?: NodeJS.Timeout;
|
timeout?: number;
|
||||||
|
|
||||||
@Output() longPress = new EventEmitter();
|
@Output() longPress = new EventEmitter();
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ export class CoreLongPressDirective implements OnInit, OnDestroy {
|
||||||
disableScroll: true,
|
disableScroll: true,
|
||||||
gestureName: 'longpress',
|
gestureName: 'longpress',
|
||||||
onStart: (event) => {
|
onStart: (event) => {
|
||||||
this.timeout = setTimeout(() => {
|
this.timeout = window.setTimeout(() => {
|
||||||
this.longPress.emit(event);
|
this.longPress.emit(event);
|
||||||
|
|
||||||
delete this.timeout;
|
delete this.timeout;
|
||||||
|
|
|
@ -33,7 +33,15 @@ describe('CoreFormatTextDirective', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSingleton(CoreSites, { getSite: () => Promise.reject() });
|
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(CoreFilter, { formatText: text => Promise.resolve(text) });
|
||||||
mockSingleton(CoreFilterHelper, { getFiltersAndFormatText: text => Promise.resolve({ text, filters: [] }) });
|
mockSingleton(CoreFilterHelper, { getFiltersAndFormatText: text => Promise.resolve({ text, filters: [] }) });
|
||||||
|
|
||||||
|
@ -59,12 +67,12 @@ describe('CoreFormatTextDirective', () => {
|
||||||
// Assert
|
// Assert
|
||||||
const text = fixture.nativeElement.querySelector('core-format-text');
|
const text = fixture.nativeElement.querySelector('core-format-text');
|
||||||
expect(text).not.toBeNull();
|
expect(text).not.toBeNull();
|
||||||
expect(text.innerHTML).toEqual(sentence);
|
expect(text?.innerHTML).toEqual(sentence);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format text', async () => {
|
it('should format text', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
mockSingleton(CoreFilter, { formatText: () => 'Formatted text' });
|
mockSingleton(CoreFilter, { formatText: () => Promise.resolve('Formatted text') });
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const { nativeElement } = await renderTemplate(
|
const { nativeElement } = await renderTemplate(
|
||||||
|
@ -75,7 +83,7 @@ describe('CoreFormatTextDirective', () => {
|
||||||
// Assert
|
// Assert
|
||||||
const text = nativeElement.querySelector('core-format-text');
|
const text = nativeElement.querySelector('core-format-text');
|
||||||
expect(text).not.toBeNull();
|
expect(text).not.toBeNull();
|
||||||
expect(text.textContent).toEqual('Formatted text');
|
expect(text?.textContent).toEqual('Formatted text');
|
||||||
|
|
||||||
expect(CoreFilter.formatText).toHaveBeenCalledTimes(1);
|
expect(CoreFilter.formatText).toHaveBeenCalledTimes(1);
|
||||||
expect(CoreFilter.formatText).toHaveBeenCalledWith(
|
expect(CoreFilter.formatText).toHaveBeenCalledWith(
|
||||||
|
@ -107,7 +115,7 @@ describe('CoreFormatTextDirective', () => {
|
||||||
// Assert
|
// Assert
|
||||||
const text = nativeElement.querySelector('core-format-text');
|
const text = nativeElement.querySelector('core-format-text');
|
||||||
expect(text).not.toBeNull();
|
expect(text).not.toBeNull();
|
||||||
expect(text.textContent).toEqual('Formatted text');
|
expect(text?.textContent).toEqual('Formatted text');
|
||||||
|
|
||||||
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledTimes(1);
|
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledTimes(1);
|
||||||
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledWith(
|
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledWith(
|
||||||
|
@ -131,7 +139,7 @@ describe('CoreFormatTextDirective', () => {
|
||||||
mockSingleton(CoreFilepool, { getSrcByUrl: () => Promise.resolve('file://local-path') });
|
mockSingleton(CoreFilepool, { getSrcByUrl: () => Promise.resolve('file://local-path') });
|
||||||
mockSingleton(CoreSites, {
|
mockSingleton(CoreSites, {
|
||||||
getSite: () => Promise.resolve(site),
|
getSite: () => Promise.resolve(site),
|
||||||
getCurrentSite: () => Promise.resolve(site),
|
getCurrentSite: () => site,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
@ -145,7 +153,7 @@ describe('CoreFormatTextDirective', () => {
|
||||||
// Assert
|
// Assert
|
||||||
const image = nativeElement.querySelector('img');
|
const image = nativeElement.querySelector('img');
|
||||||
expect(image).not.toBeNull();
|
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(CoreSites.getSite).toHaveBeenCalledWith(site.getId());
|
||||||
expect(CoreFilepool.getSrcByUrl).toHaveBeenCalledTimes(1);
|
expect(CoreFilepool.getSrcByUrl).toHaveBeenCalledTimes(1);
|
||||||
|
@ -163,7 +171,7 @@ describe('CoreFormatTextDirective', () => {
|
||||||
);
|
);
|
||||||
const anchor = nativeElement.querySelector('a');
|
const anchor = nativeElement.querySelector('a');
|
||||||
|
|
||||||
anchor.click();
|
anchor?.click();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
||||||
|
|
|
@ -31,7 +31,7 @@ describe('CoreLinkDirective', () => {
|
||||||
|
|
||||||
const anchor = fixture.nativeElement.querySelector('a');
|
const anchor = fixture.nativeElement.querySelector('a');
|
||||||
expect(anchor).not.toBeNull();
|
expect(anchor).not.toBeNull();
|
||||||
expect(anchor.href).toEqual('https://moodle.org/');
|
expect(anchor?.href).toEqual('https://moodle.org/');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should capture clicks', async () => {
|
it('should capture clicks', async () => {
|
||||||
|
@ -46,7 +46,7 @@ describe('CoreLinkDirective', () => {
|
||||||
|
|
||||||
const anchor = nativeElement.querySelector('a');
|
const anchor = nativeElement.querySelector('a');
|
||||||
|
|
||||||
anchor.click();
|
anchor?.click();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
||||||
})
|
})
|
||||||
export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy {
|
export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy {
|
||||||
|
|
||||||
protected element: HTMLIonButtonElement;
|
protected element: HTMLIonButtonElement | HTMLElement;
|
||||||
protected mutationObserver: MutationObserver;
|
protected mutationObserver: MutationObserver;
|
||||||
|
|
||||||
constructor(element: ElementRef<HTMLIonButtonElement>) {
|
constructor(element: ElementRef<HTMLIonButtonElement>) {
|
||||||
|
@ -52,7 +52,11 @@ export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestr
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
async ngOnInit(): Promise<void> {
|
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'] });
|
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.componentName = CoreNavigator.getRequiredRouteParam<string>('componentName');
|
||||||
this.itemId = CoreNavigator.getRequiredRouteNumberParam('itemId');
|
this.itemId = CoreNavigator.getRequiredRouteNumberParam('itemId');
|
||||||
this.area = CoreNavigator.getRouteParam('area') || '';
|
this.area = CoreNavigator.getRouteParam('area') || '';
|
||||||
this.title = CoreNavigator.getRouteNumberParam('title') ||
|
this.title = CoreNavigator.getRouteParam('title') ||
|
||||||
Translate.instant('core.comments.comments');
|
Translate.instant('core.comments.comments');
|
||||||
this.courseId = CoreNavigator.getRouteNumberParam('courseId');
|
this.courseId = CoreNavigator.getRouteNumberParam('courseId');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -193,10 +193,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
||||||
|
|
||||||
// Completion deleted, add a warning if the completion status doesn't match.
|
// Completion deleted, add a warning if the completion status doesn't match.
|
||||||
if (onlineComp.state != entry.completed) {
|
if (onlineComp.state != entry.completed) {
|
||||||
result.warnings.push(Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
result.warnings.push({
|
||||||
name: courseName || courseId,
|
warningcode: 'apperror',
|
||||||
error: Translate.instant('core.course.warningmanualcompletionmodified'),
|
message: Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
||||||
}));
|
name: courseName || courseId,
|
||||||
|
error: Translate.instant('core.course.warningmanualcompletionmodified'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -220,10 +223,13 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
||||||
await CoreCourseOffline.deleteManualCompletion(entry.cmid, siteId);
|
await CoreCourseOffline.deleteManualCompletion(entry.cmid, siteId);
|
||||||
|
|
||||||
// Completion deleted, add a warning.
|
// Completion deleted, add a warning.
|
||||||
result.warnings.push(Translate.instant('core.course.warningofflinemanualcompletiondeleted', {
|
result.warnings.push({
|
||||||
name: courseName || courseId,
|
warningcode: 'apperror',
|
||||||
error: CoreTextUtils.getErrorMessageFromError(error),
|
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 { CoreLoginSiteHelpComponent } from './site-help/site-help';
|
||||||
import { CoreLoginSitesComponent } from './sites/sites';
|
import { CoreLoginSitesComponent } from './sites/sites';
|
||||||
import { CoreLoginMethodsComponent } from './login-methods/login-methods';
|
import { CoreLoginMethodsComponent } from './login-methods/login-methods';
|
||||||
|
import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
CoreLoginExceededAttemptsComponent,
|
||||||
CoreLoginSiteOnboardingComponent,
|
CoreLoginSiteOnboardingComponent,
|
||||||
CoreLoginSiteHelpComponent,
|
CoreLoginSiteHelpComponent,
|
||||||
CoreLoginSitesComponent,
|
CoreLoginSitesComponent,
|
||||||
|
@ -30,6 +32,7 @@ import { CoreLoginMethodsComponent } from './login-methods/login-methods';
|
||||||
CoreSharedModule,
|
CoreSharedModule,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
CoreLoginExceededAttemptsComponent,
|
||||||
CoreLoginSiteOnboardingComponent,
|
CoreLoginSiteOnboardingComponent,
|
||||||
CoreLoginSiteHelpComponent,
|
CoreLoginSiteHelpComponent,
|
||||||
CoreLoginSitesComponent,
|
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-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<ion-list>
|
<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-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<h2><strong>{{ 'core.login.faqwhatisurlquestion' | translate }}</strong></h2>
|
<h2><strong>{{ 'core.login.faqwhatisurlquestion' | translate }}</strong></h2>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap core-login-faqwhatisurlanswer">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p [innerHTML]="'core.login.faqwhatisurlanswer' | translate: {$image: urlImageHtml}"></p>
|
<p [innerHTML]="'core.login.faqwhatisurlanswer' | translate: {$image: urlImageHtml}"></p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<h2><strong>{{ 'core.login.faqcannotconnectquestion' | translate }}</strong></h2>
|
<h2><strong>{{ 'core.login.faqcannotfindmysitequestion' | translate }}</strong></h2>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p>{{ 'core.login.faqcannotconnectanswer' | translate }} {{ 'core.whoissiteadmin' | translate }}</p>
|
<p>{{ 'core.login.faqcannotfindmysiteanswer' | translate }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
|
@ -62,7 +52,7 @@
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
<ion-label>
|
<ion-label>
|
||||||
<p>{{ 'core.login.faqtestappanswer' | translate }}</p>
|
<p [innerHTML]="'core.login.faqtestappanswer' | translate"></p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item class="ion-text-wrap" *ngIf="canScanQR">
|
<ion-item class="ion-text-wrap" *ngIf="canScanQR">
|
||||||
|
|
|
@ -6,20 +6,21 @@
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"changepassword": "Change password",
|
"changepassword": "Change password",
|
||||||
"changepasswordbutton": "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.",
|
"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}}?",
|
"confirmdeletesite": "Are you sure you want to remove the account on {{sitename}}?",
|
||||||
"connect": "Connect!",
|
"connect": "Connect!",
|
||||||
"connecttomoodle": "Connect to Moodle",
|
"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.",
|
"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.",
|
"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",
|
"createaccount": "Create my new account",
|
||||||
"createuserandpass": "Choose your username and password",
|
"createuserandpass": "Choose your username and password",
|
||||||
"credentialsdescription": "Please provide your username and password to log in.",
|
"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>",
|
"credentialshelp": "If you have problems logging in, try again later or contact your school or learning provider.",
|
||||||
"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>",
|
"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",
|
"emailconfirmsentsuccess": "Confirmation email sent successfully",
|
||||||
"emailnotmatch": "Emails do not match",
|
"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",
|
"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>",
|
"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.",
|
"errorqrnoscheme": "This URL isn't a valid login URL.",
|
||||||
"errorupdatesite": "An error occurred while updating the site's token.",
|
"errorupdatesite": "An error occurred while updating the site's token.",
|
||||||
"faqcannotconnectanswer": "Please, contact your site administrator.",
|
"exceededloginattempts": "Need help logging in? Try recovering your password or contact your site support.",
|
||||||
"faqcannotconnectquestion": "I typed my site address correctly but I still can't connect.",
|
"exceededloginattemptsfallback": "Need help logging in? Try recovering your password.",
|
||||||
"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.",
|
"exceededloginattemptssupportsubject": "I can't log in",
|
||||||
"faqcannotfindmysitequestion": "I can't find my site.",
|
"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.",
|
"faqsetupsiteanswer": "Visit {{$link}} to check out the different options you have to create your own Moodle site.",
|
||||||
"faqsetupsitelinktitle": "Get started.",
|
"faqsetupsitelinktitle": "Get started.",
|
||||||
"faqsetupsitequestion": "I want to set up my own Moodle site.",
|
"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.",
|
"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": "I just want to test the app, what can I do?",
|
"faqtestappquestion": "Can I test the app on a demo site?",
|
||||||
"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>",
|
"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": "What is my site address? How can I find my site URL?",
|
"faqwhatisurlquestion": "How can I find my site?",
|
||||||
"faqwhereisqrcode": "Where can I find the QR code?",
|
"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",
|
"findyoursite": "Find your site",
|
||||||
"firsttime": "Is this your first time here?",
|
"firsttime": "Is this your first time here?",
|
||||||
"forcepasswordchangenotice": "You must change your password to proceed.",
|
"forcepasswordchangenotice": "You must change your password to proceed.",
|
||||||
"forgotten": "Forgotten your username or password?",
|
"forgotten": "Forgotten your username or password?",
|
||||||
"help": "Help",
|
"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",
|
"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",
|
"invaliddate": "Invalid date",
|
||||||
"invalidemail": "Invalid email address",
|
"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.",
|
"invalidsite": "The site URL is invalid.",
|
||||||
"invalidtime": "Invalid time",
|
"invalidtime": "Invalid time",
|
||||||
"invalidurl": "Invalid URL specified",
|
"invalidurl": "Invalid URL specified",
|
||||||
|
@ -64,7 +67,7 @@
|
||||||
"missingemail": "Missing email address",
|
"missingemail": "Missing email address",
|
||||||
"missingfirstname": "Missing given name",
|
"missingfirstname": "Missing given name",
|
||||||
"missinglastname": "Missing surname",
|
"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",
|
"mustconfirm": "You need to confirm your account",
|
||||||
"newaccount": "New account",
|
"newaccount": "New account",
|
||||||
"notloggedin": "You need to be logged in.",
|
"notloggedin": "You need to be logged in.",
|
||||||
|
@ -94,7 +97,9 @@
|
||||||
"recaptchaexpired": "Verification expired. Answer the security question again.",
|
"recaptchaexpired": "Verification expired. Answer the security question again.",
|
||||||
"recaptchaincorrect": "The security question answer is incorrect.",
|
"recaptchaincorrect": "The security question answer is incorrect.",
|
||||||
"reconnect": "Reconnect",
|
"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.",
|
"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",
|
"reconnecttosite": "Reconnect to the site",
|
||||||
"removeaccount": "Remove account",
|
"removeaccount": "Remove account",
|
||||||
"resendemail": "Resend email",
|
"resendemail": "Resend email",
|
||||||
|
@ -122,7 +127,7 @@
|
||||||
"usernamerequired": "Username required",
|
"usernamerequired": "Username required",
|
||||||
"usernotaddederror": "User not added - error",
|
"usernotaddederror": "User not added - error",
|
||||||
"visitchangepassword": "Do you want to visit the site to change the password?",
|
"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.",
|
"youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.",
|
||||||
"yourenteredsite": "Connect to your site"
|
"yourenteredsite": "Connect to your site"
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { CoreSharedModule } from '@/core/shared.module';
|
||||||
import { CoreLoginHasSitesGuard } from './guards/has-sites';
|
import { CoreLoginHasSitesGuard } from './guards/has-sites';
|
||||||
import { CoreLoginComponentsModule } from './components/components.module';
|
import { CoreLoginComponentsModule } from './components/components.module';
|
||||||
import { CoreLoginHelper } from './services/login-helper';
|
import { CoreLoginHelper } from './services/login-helper';
|
||||||
|
import { CoreLoginForgottenPasswordPage } from '@features/login/pages/forgotten-password/forgotten-password';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
|
@ -42,8 +43,7 @@ const routes: Routes = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'forgottenpassword',
|
path: 'forgottenpassword',
|
||||||
loadChildren: () => import('./pages/forgotten-password/forgotten-password.module')
|
component: CoreLoginForgottenPasswordPage,
|
||||||
.then( m => m.CoreLoginForgottenPasswordPageModule),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'changepassword',
|
path: 'changepassword',
|
||||||
|
@ -70,5 +70,8 @@ const routes: Routes = [
|
||||||
CoreLoginComponentsModule,
|
CoreLoginComponentsModule,
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
],
|
],
|
||||||
|
declarations: [
|
||||||
|
CoreLoginForgottenPasswordPage,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class CoreLoginLazyModule {}
|
export class CoreLoginLazyModule {}
|
||||||
|
|
|
@ -43,7 +43,7 @@ const appRoutes: Routes = [
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
multi: true,
|
multi: true,
|
||||||
useValue: () => {
|
useValue: async () => {
|
||||||
CoreCronDelegate.register(CoreLoginCronHandler.instance);
|
CoreCronDelegate.register(CoreLoginCronHandler.instance);
|
||||||
|
|
||||||
CoreEvents.on(CoreEvents.SESSION_EXPIRED, (data) => {
|
CoreEvents.on(CoreEvents.SESSION_EXPIRED, (data) => {
|
||||||
|
@ -57,6 +57,8 @@ const appRoutes: Routes = [
|
||||||
CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, (data) => {
|
CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, (data) => {
|
||||||
CoreLoginHelper.sitePolicyNotAgreed(data.siteId);
|
CoreLoginHelper.sitePolicyNotAgreed(data.siteId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await CoreLoginHelper.initialize();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -15,12 +15,12 @@
|
||||||
import { Component, OnDestroy } from '@angular/core';
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
|
||||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { CoreUserSupport } from '@features/user/services/support';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page that shows instructions to change the password.
|
* 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 {
|
showHelp(): void {
|
||||||
CoreDomUtils.showAlert(
|
CoreUserSupport.showHelp(
|
||||||
Translate.instant('core.help'),
|
|
||||||
Translate.instant('core.login.changepasswordhelp'),
|
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-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-icon slot="icon-only" name="fas-cog" aria-hidden="true"></ion-icon>
|
||||||
</ion-button>
|
</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-buttons>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
@ -30,6 +33,16 @@
|
||||||
<p class="core-siteurl">{{siteUrl}}</p>
|
<p class="core-siteurl">{{siteUrl}}</p>
|
||||||
</div>
|
</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>
|
<form [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
|
||||||
<ion-item *ngIf="siteChecked && !isBrowserSSO">
|
<ion-item *ngIf="siteChecked && !isBrowserSSO">
|
||||||
<ion-label class="sr-only">{{ 'core.login.username' | translate }}</ion-label>
|
<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 { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
import { CoreSharedModule } from '@/core/shared.module';
|
import { CoreSharedModule } from '@/core/shared.module';
|
||||||
|
import { CoreLoginComponentsModule } from '@features/login/components/components.module';
|
||||||
import { CoreLoginCredentialsPage } from './credentials';
|
import { CoreLoginCredentialsPage } from './credentials';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
|
@ -29,6 +30,7 @@ const routes: Routes = [
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild(routes),
|
RouterModule.forChild(routes),
|
||||||
CoreSharedModule,
|
CoreSharedModule,
|
||||||
|
CoreLoginComponentsModule,
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
CoreLoginCredentialsPage,
|
CoreLoginCredentialsPage,
|
||||||
|
|
|
@ -28,6 +28,9 @@ import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes
|
||||||
import { CoreEvents } from '@singletons/events';
|
import { CoreEvents } from '@singletons/events';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreForms } from '@singletons/form';
|
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.
|
* Page to enter the user credentials.
|
||||||
|
@ -54,6 +57,9 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
||||||
isFixedUrlSet = false;
|
isFixedUrlSet = false;
|
||||||
showForgottenPassword = true;
|
showForgottenPassword = true;
|
||||||
showScanQR = false;
|
showScanQR = false;
|
||||||
|
loginAttempts = 0;
|
||||||
|
supportConfig?: CoreUserSupportConfig;
|
||||||
|
canContactSupport?: boolean;
|
||||||
|
|
||||||
protected siteConfig?: CoreSitePublicConfigResponse;
|
protected siteConfig?: CoreSitePublicConfigResponse;
|
||||||
protected eventThrown = false;
|
protected eventThrown = false;
|
||||||
|
@ -72,11 +78,12 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.siteUrl = CoreNavigator.getRequiredRouteParam<string>('siteUrl');
|
this.siteUrl = CoreNavigator.getRequiredRouteParam<string>('siteUrl');
|
||||||
|
|
||||||
this.siteName = CoreNavigator.getRouteParam('siteName');
|
this.siteName = CoreNavigator.getRouteParam('siteName');
|
||||||
this.logoUrl = !CoreConstants.CONFIG.forceLoginLogo && CoreNavigator.getRouteParam('logoUrl') || undefined;
|
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.urlToOpen = CoreNavigator.getRouteParam('urlToOpen');
|
||||||
|
this.supportConfig = this.siteConfig && new CoreUserGuestSupportConfig(this.siteConfig);
|
||||||
|
this.canContactSupport = this.supportConfig?.canContactSupport();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
CoreDomUtils.showErrorModal(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.
|
* 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.
|
* 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.
|
// Site wasn't checked (it failed) or a previous check determined it was SSO. Let's check again.
|
||||||
await this.checkSite(siteUrl);
|
await this.checkSite(siteUrl);
|
||||||
|
|
||||||
if (!this.isBrowserSSO) {
|
if (!this.isBrowserSSO && this.siteChecked) {
|
||||||
// Site doesn't use browser SSO, throw app's login again.
|
// Site doesn't use browser SSO, throw app's login again.
|
||||||
return this.login();
|
return this.login();
|
||||||
}
|
}
|
||||||
|
@ -274,6 +291,8 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
|
||||||
} else if (error.errorcode == 'forcepasswordchangenotice') {
|
} else if (error.errorcode == 'forcepasswordchangenotice') {
|
||||||
// Reset password field.
|
// Reset password field.
|
||||||
this.credForm.controls.password.reset();
|
this.credForm.controls.password.reset();
|
||||||
|
} else if (error.errorcode === 'invalidlogin') {
|
||||||
|
this.loginAttempts++;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
modal.dismiss();
|
modal.dismiss();
|
||||||
|
|
|
@ -11,6 +11,10 @@
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content>
|
<ion-content>
|
||||||
<div class="list-item-limited-width">
|
<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-list>
|
||||||
<ion-item class="ion-text-wrap">
|
<ion-item class="ion-text-wrap">
|
||||||
|
|
|
@ -22,6 +22,9 @@ import { CoreWSExternalWarning } from '@services/ws';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreForms } from '@singletons/form';
|
import { CoreForms } from '@singletons/form';
|
||||||
import { CorePlatform } from '@services/platform';
|
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.
|
* Page to recover a forgotten password.
|
||||||
|
@ -37,16 +40,16 @@ export class CoreLoginForgottenPasswordPage implements OnInit {
|
||||||
myForm!: FormGroup;
|
myForm!: FormGroup;
|
||||||
siteUrl!: string;
|
siteUrl!: string;
|
||||||
autoFocus!: boolean;
|
autoFocus!: boolean;
|
||||||
|
supportConfig?: CoreUserSupportConfig;
|
||||||
|
canContactSupport?: boolean;
|
||||||
|
wasPasswordResetRequestedRecently = false;
|
||||||
|
|
||||||
constructor(
|
constructor(protected formBuilder: FormBuilder) {}
|
||||||
protected formBuilder: FormBuilder,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the component.
|
* Initialize the component.
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
async ngOnInit(): Promise<void> {
|
||||||
const siteUrl = CoreNavigator.getRouteParam<string>('siteUrl');
|
const siteUrl = CoreNavigator.getRouteParam<string>('siteUrl');
|
||||||
if (!siteUrl) {
|
if (!siteUrl) {
|
||||||
CoreDomUtils.showErrorModal('Site URL not supplied.');
|
CoreDomUtils.showErrorModal('Site URL not supplied.');
|
||||||
|
@ -55,12 +58,18 @@ export class CoreLoginForgottenPasswordPage implements OnInit {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteConfig = CoreNavigator.getRouteParam<CoreSitePublicConfigResponse>('siteConfig');
|
||||||
|
|
||||||
this.siteUrl = siteUrl;
|
this.siteUrl = siteUrl;
|
||||||
this.autoFocus = CorePlatform.is('tablet');
|
this.autoFocus = CorePlatform.is('tablet');
|
||||||
this.myForm = this.formBuilder.group({
|
this.myForm = this.formBuilder.group({
|
||||||
field: ['username', Validators.required],
|
field: ['username', Validators.required],
|
||||||
value: [CoreNavigator.getRouteParam<string>('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.
|
// Success.
|
||||||
CoreForms.triggerFormSubmittedEvent(this.formElement, true);
|
CoreForms.triggerFormSubmittedEvent(this.formElement, true);
|
||||||
|
|
||||||
CoreDomUtils.showAlert(Translate.instant('core.success'), response.notice);
|
await CoreDomUtils.showAlert(Translate.instant('core.success'), response.notice);
|
||||||
CoreNavigator.back();
|
await CoreNavigator.back();
|
||||||
|
await CoreLoginHelper.passwordResetRequested(this.siteUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
CoreDomUtils.showErrorModal(error);
|
CoreDomUtils.showErrorModal(error);
|
||||||
|
|
|
@ -7,6 +7,12 @@
|
||||||
<ion-title>
|
<ion-title>
|
||||||
<h1>{{ 'core.login.reconnect' | translate }}</h1>
|
<h1>{{ 'core.login.reconnect' | translate }}</h1>
|
||||||
</ion-title>
|
</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-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
<ion-content class="ion-padding" (keydown)="keyDown($event)" (keyup)="keyUp($event)">
|
<ion-content class="ion-padding" (keydown)="keyDown($event)" (keyup)="keyUp($event)">
|
||||||
|
@ -36,6 +42,16 @@
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-card>
|
</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>
|
</div>
|
||||||
<form *ngIf="!isOAuth" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #reconnectForm>
|
<form *ngIf="!isOAuth" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #reconnectForm>
|
||||||
<ion-item class="ion-text-wrap core-username item-interactive">
|
<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 { CoreError } from '@classes/errors/error';
|
||||||
import { CoreNavigator, CoreRedirectPayload } from '@services/navigator';
|
import { CoreNavigator, CoreRedirectPayload } from '@services/navigator';
|
||||||
import { CoreForms } from '@singletons/form';
|
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.
|
* Page to enter the user password to reconnect to a site.
|
||||||
|
@ -55,6 +59,9 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
||||||
siteId!: string;
|
siteId!: string;
|
||||||
showScanQR = false;
|
showScanQR = false;
|
||||||
showLoading = true;
|
showLoading = true;
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
supportConfig?: CoreUserSupportConfig;
|
||||||
|
canContactSupport?: boolean;
|
||||||
|
|
||||||
protected siteConfig?: CoreSitePublicConfigResponse;
|
protected siteConfig?: CoreSitePublicConfigResponse;
|
||||||
protected viewLeft = false;
|
protected viewLeft = false;
|
||||||
|
@ -101,6 +108,8 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
|
||||||
this.userAvatar = site.infos.userpictureurl;
|
this.userAvatar = site.infos.userpictureurl;
|
||||||
this.siteUrl = site.infos.siteurl;
|
this.siteUrl = site.infos.siteurl;
|
||||||
this.siteName = site.getSiteName();
|
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.
|
// If login was OAuth we should only reach this page if the OAuth method ID has changed.
|
||||||
this.isOAuth = site.isOAuth();
|
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.
|
* 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') {
|
} else if (error.errorcode == 'forcepasswordchangenotice') {
|
||||||
// Reset password field.
|
// Reset password field.
|
||||||
this.credForm.controls.password.reset();
|
this.credForm.controls.password.reset();
|
||||||
|
} else if (error.errorcode == 'invalidlogin') {
|
||||||
|
this.reconnectAttempts++;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
modal.dismiss();
|
modal.dismiss();
|
||||||
|
|
|
@ -27,7 +27,6 @@ import {
|
||||||
CoreLoginSiteFinderSettings,
|
CoreLoginSiteFinderSettings,
|
||||||
CoreLoginSiteSelectorListMethod,
|
CoreLoginSiteSelectorListMethod,
|
||||||
} from '@features/login/services/login-helper';
|
} from '@features/login/services/login-helper';
|
||||||
import { CoreSite } from '@classes/site';
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { CoreConstants } from '@/core/constants';
|
import { CoreConstants } from '@/core/constants';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
|
@ -40,6 +39,13 @@ import { CoreCustomURLSchemes, CoreCustomURLSchemesHandleError } from '@services
|
||||||
import { CoreTextUtils } from '@services/utils/text';
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
import { CoreForms } from '@singletons/form';
|
import { CoreForms } from '@singletons/form';
|
||||||
import { AlertButton } from '@ionic/core';
|
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.
|
* 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 url The URL the user was trying to connect to.
|
||||||
* @param error Error to display.
|
* @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 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')) {
|
if (error instanceof CoreSiteError) {
|
||||||
const found = this.sites.find((site) => site.url == url);
|
supportConfig = error.supportConfig;
|
||||||
|
errorDetails = error.errorDetails;
|
||||||
if (!found) {
|
errorCode = error.errorcode;
|
||||||
errorMessage += ' ' + Translate.instant('core.cannotconnectverify');
|
siteExists = supportConfig instanceof CoreUserGuestSupportConfig;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = '<p>' + errorMessage + '</p>';
|
if (error instanceof CoreLoginError) {
|
||||||
if (url) {
|
errorTitle = error.title;
|
||||||
const fullUrl = CoreUrlUtils.isAbsoluteURL(url) ? url : 'https://' + url;
|
|
||||||
message += '<p padding><a href="' + fullUrl + '" core-link>' + url + '</a></p>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons: AlertButton[] = [
|
if (errorDetails) {
|
||||||
{
|
errorMessage = `<p>${errorMessage}</p><div class="core-error-info-container"></div>`;
|
||||||
text: Translate.instant('core.needhelp'),
|
}
|
||||||
cssClass: 'core-login-need-help',
|
|
||||||
handler: (): void => {
|
const alertSupportConfig = supportConfig;
|
||||||
this.showHelp();
|
const buttons = [
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
text: Translate.instant('core.tryagain'),
|
text: Translate.instant('core.tryagain'),
|
||||||
role: 'cancel',
|
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.
|
// @TODO: Remove CoreSite.MINIMUM_MOODLE_VERSION, not used on translations since 3.9.0.
|
||||||
CoreDomUtils.showAlertWithOptions({
|
const alertElement = await CoreDomUtils.showAlertWithOptions({
|
||||||
header: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
header: errorTitle ?? (
|
||||||
message,
|
siteExists
|
||||||
buttons,
|
? 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 { CoreText } from '@singletons/text';
|
||||||
import { CorePromisedValue } from '@classes/promised-value';
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
|
|
||||||
|
const PASSWORD_RESETS_CONFIG_KEY = 'password-resets';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper provider that provides some common features regarding authentication.
|
* Helper provider that provides some common features regarding authentication.
|
||||||
*/
|
*/
|
||||||
|
@ -63,6 +65,13 @@ export class CoreLoginHelperProvider {
|
||||||
this.logger = CoreLogger.getInstance('CoreLoginHelper');
|
this.logger = CoreLogger.getInstance('CoreLoginHelper');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize service.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
this.cleanUpPasswordResets();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept site policy.
|
* Accept site policy.
|
||||||
*
|
*
|
||||||
|
@ -183,6 +192,7 @@ export class CoreLoginHelperProvider {
|
||||||
await CoreNavigator.navigate('/login/forgottenpassword', {
|
await CoreNavigator.navigate('/login/forgottenpassword', {
|
||||||
params: {
|
params: {
|
||||||
siteUrl,
|
siteUrl,
|
||||||
|
siteConfig,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1457,6 +1467,65 @@ export class CoreLoginHelperProvider {
|
||||||
return [];
|
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);
|
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
|
But I should not find "Log in" in the app
|
||||||
|
|
||||||
Scenario: Add a non existing account
|
Scenario: Add a non existing account
|
||||||
When I enter the app
|
When I launch the app
|
||||||
And I log in as "student1"
|
And I set the field "Your site" to "wrongsiteaddress" in the app
|
||||||
When I log out in the app
|
And I press "Connect to your site" in the app
|
||||||
And I press "Add" in the app
|
Then I should find "Site not found" 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
|
|
||||||
|
|
||||||
Scenario: Add a non existing account from accounts switcher
|
Scenario: Add a non existing account from accounts switcher
|
||||||
When I enter the app
|
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 "Switch account" in the app
|
||||||
And I press "Add" in the app
|
And I press "Add" in the app
|
||||||
And I wait the app to restart
|
And I wait the app to restart
|
||||||
And I set the field "Your site" to "Wrong Site Address" in the app
|
And I set the field "Your site" to "wrongsiteaddress" in the app
|
||||||
And I press enter in the app
|
And I press "Connect to your site" in the app
|
||||||
Then I should find "Cannot connect" in the app
|
Then I should find "Site not found" in the app
|
||||||
And I should find "Wrong Site Address" in the app
|
|
||||||
|
|
||||||
Scenario: Log out from the app
|
Scenario: Log out from the app
|
||||||
Given I entered the app as "student1"
|
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
|
When I press "Reconnect" in the app
|
||||||
Then I should find "Acceptance test site" 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>
|
<p class="item-heading">{{ 'core.settings.preferences' | translate }}</p>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
</ion-item>
|
</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>
|
</ion-list>
|
||||||
</core-loading>
|
</core-loading>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
|
@ -18,6 +18,8 @@ import { CoreSite, CoreSiteInfo } from '@classes/site';
|
||||||
import { CoreFilter } from '@features/filter/services/filter';
|
import { CoreFilter } from '@features/filter/services/filter';
|
||||||
import { CoreLoginSitesComponent } from '@features/login/components/sites/sites';
|
import { CoreLoginSitesComponent } from '@features/login/components/sites/sites';
|
||||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
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 { CoreUser, CoreUserProfile } from '@features/user/services/user';
|
||||||
import {
|
import {
|
||||||
CoreUserProfileHandlerData,
|
CoreUserProfileHandlerData,
|
||||||
|
@ -51,6 +53,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
||||||
handlersLoaded = false;
|
handlersLoaded = false;
|
||||||
user?: CoreUserProfile;
|
user?: CoreUserProfile;
|
||||||
displaySwitchAccount = true;
|
displaySwitchAccount = true;
|
||||||
|
displayContactSupport = false;
|
||||||
removeAccountOnLogout = false;
|
removeAccountOnLogout = false;
|
||||||
|
|
||||||
protected subscription!: Subscription;
|
protected subscription!: Subscription;
|
||||||
|
@ -65,6 +68,7 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
||||||
this.siteName = currentSite.getSiteName();
|
this.siteName = currentSite.getSiteName();
|
||||||
this.siteUrl = currentSite.getURL();
|
this.siteUrl = currentSite.getURL();
|
||||||
this.displaySwitchAccount = !currentSite.isFeatureDisabled('NoDelegate_SwitchAccount');
|
this.displaySwitchAccount = !currentSite.isFeatureDisabled('NoDelegate_SwitchAccount');
|
||||||
|
this.displayContactSupport = new CoreUserAuthenticatedSupportConfig(currentSite).canContactSupport();
|
||||||
this.removeAccountOnLogout = !!CoreConstants.CONFIG.removeaccountonlogout;
|
this.removeAccountOnLogout = !!CoreConstants.CONFIG.removeaccountonlogout;
|
||||||
|
|
||||||
this.loadSiteLogo(currentSite);
|
this.loadSiteLogo(currentSite);
|
||||||
|
@ -173,6 +177,16 @@ export class CoreMainMenuUserMenuComponent implements OnInit, OnDestroy {
|
||||||
handler.action(event, this.user, CoreUserDelegateContext.USER_MENU);
|
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.
|
* Logout the user.
|
||||||
*
|
*
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
import { Input, OnInit, ElementRef, Directive } from '@angular/core';
|
import { Input, OnInit, ElementRef, Directive } from '@angular/core';
|
||||||
|
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
|
import { CoreTextUtils } from '@services/utils/text';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
import { CoreSitePluginsPluginContentComponent } from '../components/plugin-content/plugin-content';
|
import { CoreSitePluginsPluginContentComponent } from '../components/plugin-content/plugin-content';
|
||||||
|
@ -72,7 +73,12 @@ export class CoreSitePluginsCallWSOnClickBaseDirective extends CoreSitePluginsCa
|
||||||
await super.callWS();
|
await super.callWS();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.showError === undefined || CoreUtils.isTrueOrOne(this.showError)) {
|
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 {
|
} finally {
|
||||||
modal.dismiss();
|
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",
|
"useraccount": "User account",
|
||||||
"city": "City/town",
|
"city": "City/town",
|
||||||
"completeprofile": "Complete profile",
|
"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.",
|
"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.",
|
"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",
|
"completeyourprofile": "Complete your profile",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
|
@ -28,6 +30,7 @@
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"sendemail": "Email",
|
"sendemail": "Email",
|
||||||
"student": "Student",
|
"student": "Student",
|
||||||
|
"support": "Support",
|
||||||
"teacher": "Non-editing teacher",
|
"teacher": "Non-editing teacher",
|
||||||
"userwithid": "User with ID {{id}}",
|
"userwithid": "User with ID {{id}}",
|
||||||
"webpage": "Web page"
|
"webpage": "Web page"
|
||||||
|
|
|
@ -15,12 +15,12 @@
|
||||||
import { Component, OnDestroy } from '@angular/core';
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
|
||||||
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
import { CoreLoginHelper } from '@features/login/services/login-helper';
|
||||||
import { Translate } from '@singletons';
|
import { Translate } from '@singletons';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator } from '@services/navigator';
|
||||||
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
import { CoreEventObserver, CoreEvents } from '@singletons/events';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
|
import { CoreUserSupport } from '@features/user/services/support';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page that shows instructions to complete the profile.
|
* 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 {
|
showHelp(): void {
|
||||||
// @todo MOBILE-4059: Change this message.
|
CoreUserSupport.showHelp(
|
||||||
CoreDomUtils.showAlert(
|
Translate.instant('core.user.completeprofilehelp'),
|
||||||
Translate.instant('core.help'),
|
Translate.instant('core.user.completeprofilesupportsubject'),
|
||||||
Translate.instant('core.login.changepasswordhelp'),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
"add": "Add",
|
||||||
"agelocationverification": "Age and location verification",
|
"agelocationverification": "Age and location verification",
|
||||||
"ago": "{{$a}} ago",
|
"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",
|
"all": "All",
|
||||||
"allgroups": "All groups",
|
"allgroups": "All groups",
|
||||||
"allparticipants": "All participants",
|
"allparticipants": "All participants",
|
||||||
|
@ -14,10 +14,8 @@
|
||||||
"browser": "Browser",
|
"browser": "Browser",
|
||||||
"calculating": "Calculating",
|
"calculating": "Calculating",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"cannotconnect": "Cannot connect",
|
"cannotconnect": "Can't connect to site",
|
||||||
"cannotconnecttrouble": "We're having trouble connecting to your site.",
|
"cannotdownloadfiles": "Your school or learning provider has disabled downloading files.",
|
||||||
"cannotconnectverify": "<strong>Please check the address is correct.</strong>",
|
|
||||||
"cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.",
|
|
||||||
"cannotinstallapk": "For security reasons, you can't install unknown apps on your device from this app. Please open the file using a browser.",
|
"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.",
|
"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?",
|
"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",
|
"captureimage": "Take picture",
|
||||||
"capturevideo": "Record video",
|
"capturevideo": "Record video",
|
||||||
"category": "Category",
|
"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",
|
"choose": "Choose",
|
||||||
"choosedots": "Choose...",
|
"choosedots": "Choose...",
|
||||||
"clearsearch": "Clear search",
|
"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.",
|
"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.",
|
"confirmloss": "Are you sure? All changes will be lost.",
|
||||||
"confirmopeninbrowser": "Do you want to open it in a web browser?",
|
"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.",
|
"considereddigitalminor": "You are too young to create an account on this site.",
|
||||||
|
"contactsupport": "Contact support",
|
||||||
"content": "Content",
|
"content": "Content",
|
||||||
"contenteditingsynced": "The content you are editing has been synced.",
|
"contenteditingsynced": "The content you are editing has been synced.",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
@ -100,12 +100,15 @@
|
||||||
"endonesteptour": "Got it",
|
"endonesteptour": "Got it",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"errorchangecompletion": "An error occurred while changing the completion status. Please try again.",
|
"errorchangecompletion": "An error occurred while changing the completion status. Please try again.",
|
||||||
|
"errorcode": "Error code",
|
||||||
"errordeletefile": "Error deleting the file. Please try again.",
|
"errordeletefile": "Error deleting the file. Please try again.",
|
||||||
|
"errordetailshide": "Hide error details",
|
||||||
|
"errordetailsshow": "Show error details",
|
||||||
"errordownloading": "Error downloading file.",
|
"errordownloading": "Error downloading file.",
|
||||||
"errordownloadingsomefiles": "Error downloading files. Some files might be missing.",
|
"errordownloadingsomefiles": "Error downloading files. Some files might be missing.",
|
||||||
"errorfileexistssamename": "A file with this name already exists.",
|
"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.",
|
"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.",
|
"errorloadingcontent": "Error loading content.",
|
||||||
"errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.",
|
"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.",
|
"erroropenfiledownloading": "Error opening file: you need to wait for the download to complete.",
|
||||||
|
@ -270,13 +273,16 @@
|
||||||
"selectagroup": "Select a group",
|
"selectagroup": "Select a group",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"sending": "Sending",
|
"sending": "Sending",
|
||||||
"serverconnection": "Error connecting to the server",
|
"serverconnection": "Error connecting to the server: {{details}}",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"showadvanced": "Show advanced",
|
"showadvanced": "Show advanced",
|
||||||
"showless": "Show less...",
|
"showless": "Show less...",
|
||||||
"showmore": "Show more...",
|
"showmore": "Show more...",
|
||||||
"site": "Site",
|
"site": "Site",
|
||||||
"sitemaintenance": "The site is undergoing maintenance and is currently not available",
|
"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",
|
"size": "Size",
|
||||||
"sizeb": "bytes",
|
"sizeb": "bytes",
|
||||||
"sizegb": "GB",
|
"sizegb": "GB",
|
||||||
|
@ -348,7 +354,6 @@
|
||||||
"weeks": "weeks",
|
"weeks": "weeks",
|
||||||
"whatisyourage": "What is your age?",
|
"whatisyourage": "What is your age?",
|
||||||
"wheredoyoulive": "In which country do you live?",
|
"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?",
|
"whyisthishappening": "Why is this happening?",
|
||||||
"whyisthisrequired": "Why is this required?",
|
"whyisthisrequired": "Why is this required?",
|
||||||
"wsfunctionnotavailable": "The web service function is not available.",
|
"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 { asyncInstance, AsyncInstance } from '../utils/async-instance';
|
||||||
import { CoreText } from '@singletons/text';
|
import { CoreText } from '@singletons/text';
|
||||||
import { CorePromisedValue } from '@classes/promised-value';
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
|
import { CoreSite } from '@classes/site';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Factory for handling downloading files and retrieve downloaded files.
|
* Factory for handling downloading files and retrieve downloaded files.
|
||||||
|
@ -374,7 +375,7 @@ export class CoreFilepoolProvider {
|
||||||
|
|
||||||
const site = await CoreSites.getSite(siteId);
|
const site = await CoreSites.getSite(siteId);
|
||||||
if (!site.canDownloadFiles()) {
|
if (!site.canDownloadFiles()) {
|
||||||
throw new CoreError('Site doesn\'t allow downloading files.');
|
throw new CoreError(Translate.instant('core.cannotdownloadfiles'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!alreadyFixed) {
|
if (!alreadyFixed) {
|
||||||
|
@ -509,7 +510,7 @@ export class CoreFilepoolProvider {
|
||||||
} else {
|
} else {
|
||||||
if (!CoreNetwork.isOnline()) {
|
if (!CoreNetwork.isOnline()) {
|
||||||
// Cannot check size in offline, stop.
|
// 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);
|
size = await CoreWS.getRemoteFileSize(fileUrl);
|
||||||
|
@ -745,7 +746,7 @@ export class CoreFilepoolProvider {
|
||||||
|
|
||||||
this.filePromises[siteId][downloadId] = CoreSites.getSite(siteId).then(async (site) => {
|
this.filePromises[siteId][downloadId] = CoreSites.getSite(siteId).then(async (site) => {
|
||||||
if (!site.canDownloadFiles()) {
|
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);
|
const entry = await CoreWS.downloadFile(fileUrl, path, addExtension, onProgress);
|
||||||
|
|
|
@ -34,7 +34,7 @@ import {
|
||||||
} from '@classes/site';
|
} from '@classes/site';
|
||||||
import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb';
|
import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb';
|
||||||
import { CoreError } from '@classes/errors/error';
|
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 { makeSingleton, Translate, Http } from '@singletons';
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import {
|
import {
|
||||||
|
@ -61,6 +61,7 @@ import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/da
|
||||||
import { asyncInstance, AsyncInstance } from '../utils/async-instance';
|
import { asyncInstance, AsyncInstance } from '../utils/async-instance';
|
||||||
import { CoreConfig } from './config';
|
import { CoreConfig } from './config';
|
||||||
import { CoreNetwork } from '@services/network';
|
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_SCHEMAS = new InjectionToken<CoreSiteSchema[]>('CORE_SITE_SCHEMAS');
|
||||||
export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id';
|
export const CORE_SITE_CURRENT_SITE_ID_CONFIG = 'current_site_id';
|
||||||
|
@ -245,7 +246,7 @@ export class CoreSitesProvider {
|
||||||
} else if (CoreTextUtils.getErrorMessageFromError(secondError)) {
|
} else if (CoreTextUtils.getErrorMessageFromError(secondError)) {
|
||||||
throw secondError;
|
throw secondError;
|
||||||
} else {
|
} 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.
|
// Check that the user can authenticate.
|
||||||
if (!config.enablewebservices) {
|
if (!config.enablewebservices) {
|
||||||
throw new CoreSiteError({
|
throw this.createCannotConnectLoginError(config.httpswwwroot || config.wwwroot, {
|
||||||
message: Translate.instant('core.login.webservicesnotenabled'),
|
supportConfig: new CoreUserGuestSupportConfig(config),
|
||||||
|
errorcode: 'webservicesnotenabled',
|
||||||
|
errorDetails: Translate.instant('core.login.webservicesnotenabled'),
|
||||||
critical: true,
|
critical: true,
|
||||||
});
|
});
|
||||||
} else if (!config.enablemobilewebservice) {
|
} else if (!config.enablemobilewebservice) {
|
||||||
throw new CoreSiteError({
|
throw this.createCannotConnectLoginError(config.httpswwwroot || config.wwwroot, {
|
||||||
message: Translate.instant('core.login.mobileservicesnotenabled'),
|
supportConfig: new CoreUserGuestSupportConfig(config),
|
||||||
|
errorcode: 'mobileservicesnotenabled',
|
||||||
|
errorDetails: Translate.instant('core.login.mobileservicesnotenabled'),
|
||||||
critical: true,
|
critical: true,
|
||||||
});
|
});
|
||||||
} else if (config.maintenanceenabled) {
|
} else if (config.maintenanceenabled) {
|
||||||
|
@ -311,7 +316,7 @@ export class CoreSitesProvider {
|
||||||
message += config.maintenancemessage;
|
message += config.maintenancemessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CoreSiteError({
|
throw new CoreLoginError({
|
||||||
message,
|
message,
|
||||||
critical: true,
|
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 siteUrl Site URL.
|
||||||
* @param error Error returned.
|
* @param error Error returned.
|
||||||
* @return Promise resolved with the treated error.
|
* @return Promise resolved with the treated error.
|
||||||
*/
|
*/
|
||||||
protected async treatGetPublicConfigError(siteUrl: string, error: CoreAjaxError | CoreAjaxWSError): Promise<CoreSiteError> {
|
protected async treatGetPublicConfigError(
|
||||||
if (!('errorcode' in error)) {
|
siteUrl: string,
|
||||||
|
error: CoreError | CoreAjaxError | CoreAjaxWSError,
|
||||||
|
): Promise<CoreLoginError> {
|
||||||
|
if (error instanceof CoreAjaxError || !('errorcode' in error)) {
|
||||||
// The WS didn't return data, probably cannot connect.
|
// The WS didn't return data, probably cannot connect.
|
||||||
return new CoreSiteError({
|
return new CoreLoginError({
|
||||||
message: error.message || '',
|
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.
|
critical: false, // Allow fallback to http if siteUrl uses https.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service supported but an error happened. Return error.
|
// 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') {
|
if (error.errorcode === 'codingerror') {
|
||||||
// This could be caused by a redirect. Check if it's the case.
|
// This could be caused by a redirect. Check if it's the case.
|
||||||
const redirect = await CoreUtils.checkRedirect(siteUrl);
|
const redirect = await CoreUtils.checkRedirect(siteUrl);
|
||||||
|
|
||||||
|
options.message = Translate.instant('core.siteunavailablehelp', { site: siteUrl });
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
error.message = Translate.instant('core.login.sitehasredirect');
|
options.errorcode = 'sitehasredirect';
|
||||||
critical = false; // Keep checking fallback URLs.
|
options.errorDetails = Translate.instant('core.login.sitehasredirect');
|
||||||
} else {
|
options.critical = false; // Keep checking fallback URLs.
|
||||||
// We can't be sure if there is a redirect or not. Display cannot connect error.
|
|
||||||
error.message = Translate.instant('core.cannotconnecttrouble');
|
|
||||||
}
|
}
|
||||||
} else if (error.errorcode === 'invalidrecord') {
|
} else if (error.errorcode === 'invalidrecord') {
|
||||||
// WebService not found, site not supported.
|
// 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') {
|
} else if (error.errorcode === 'redirecterrordetected') {
|
||||||
critical = false; // Keep checking fallback URLs.
|
options.critical = false; // Keep checking fallback URLs.
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CoreSiteError({
|
return new CoreLoginError(options);
|
||||||
message: error.message,
|
|
||||||
errorcode: error.errorcode,
|
|
||||||
critical,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -383,30 +414,37 @@ export class CoreSitesProvider {
|
||||||
data = await Http.post(siteUrl + '/login/token.php', { appsitecheck: 1 }).pipe(timeout(CoreWS.getRequestTimeout()))
|
data = await Http.post(siteUrl + '/login/token.php', { appsitecheck: 1 }).pipe(timeout(CoreWS.getRequestTimeout()))
|
||||||
.toPromise();
|
.toPromise();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Default error messages are kinda bad, return our own message.
|
throw this.createCannotConnectLoginError(null, {
|
||||||
throw new CoreSiteError({
|
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||||
message: Translate.instant('core.cannotconnecttrouble'),
|
errorcode: 'sitecheckfailed',
|
||||||
|
errorDetails: CoreDomUtils.getErrorMessage(error) ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data === null) {
|
if (data === null) {
|
||||||
// Cannot connect.
|
// Cannot connect.
|
||||||
throw new CoreSiteError({
|
throw this.createCannotConnectLoginError(null, {
|
||||||
message: Translate.instant('core.cannotconnect', { $a: CoreSite.MINIMUM_MOODLE_VERSION }),
|
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')) {
|
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,
|
errorcode: data.errorcode,
|
||||||
message: data.error ?? '',
|
errorDetails: data.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.error && data.error == 'Web services must be enabled in Advanced features.') {
|
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',
|
errorcode: 'enablewsdescription',
|
||||||
message: data.error,
|
errorDetails: data.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -446,11 +484,19 @@ export class CoreSitesProvider {
|
||||||
try {
|
try {
|
||||||
data = await Http.post(loginUrl, params).pipe(timeout(CoreWS.getRequestTimeout())).toPromise();
|
data = await Http.post(loginUrl, params).pipe(timeout(CoreWS.getRequestTimeout())).toPromise();
|
||||||
} catch (error) {
|
} 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) {
|
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 {
|
} else {
|
||||||
if (data.token !== undefined) {
|
if (data.token !== undefined) {
|
||||||
return { token: data.token, siteUrl, privateToken: data.privatetoken };
|
return { token: data.token, siteUrl, privateToken: data.privatetoken };
|
||||||
|
@ -466,15 +512,18 @@ export class CoreSitesProvider {
|
||||||
const redirect = await CoreUtils.checkRedirect(loginUrl);
|
const redirect = await CoreUtils.checkRedirect(loginUrl);
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
throw new CoreSiteError({
|
throw this.createCannotConnectLoginError(siteUrl, {
|
||||||
message: Translate.instant('core.login.sitehasredirect'),
|
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||||
|
errorcode: 'sitehasredirect',
|
||||||
|
errorDetails: Translate.instant('core.login.sitehasredirect'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CoreSiteError({
|
throw this.createCannotConnectLoginError(siteUrl, {
|
||||||
message: data.error,
|
supportConfig: await CoreUserGuestSupportConfig.forSite(siteUrl),
|
||||||
errorcode: data.errorcode,
|
errorcode: data.errorcode,
|
||||||
|
errorDetails: data.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -615,7 +664,7 @@ export class CoreSitesProvider {
|
||||||
await this.setSiteLoggedOut(siteId);
|
await this.setSiteLoggedOut(siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new CoreSiteError({
|
throw new CoreLoginError({
|
||||||
message: Translate.instant(errorKey, translateParams),
|
message: Translate.instant(errorKey, translateParams),
|
||||||
errorcode: errorCode,
|
errorcode: errorCode,
|
||||||
loggedOut: true,
|
loggedOut: true,
|
||||||
|
@ -1081,6 +1130,18 @@ export class CoreSitesProvider {
|
||||||
return this.addSiteFromSiteListEntry(data);
|
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.
|
* 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: '/' });
|
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)) });
|
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 { CoreComponentsRegistry } from '@singletons/components-registry';
|
||||||
import { CoreDom } from '@singletons/dom';
|
import { CoreDom } from '@singletons/dom';
|
||||||
import { CoreNetwork } from '@services/network';
|
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.
|
* "Utils" service with helper functions for UI, DOM elements and HTML code.
|
||||||
|
@ -573,6 +577,20 @@ export class CoreDomUtilsProvider {
|
||||||
error instanceof CoreNetworkError;
|
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.
|
* Get the error message from an error, including debug data if needed.
|
||||||
*
|
*
|
||||||
|
@ -1342,25 +1360,60 @@ export class CoreDomUtilsProvider {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertOptions: AlertOptions = {
|
const alertOptions: AlertOptions = { message };
|
||||||
message: message,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.isNetworkError(message, error)) {
|
if (this.isNetworkError(message, error)) {
|
||||||
alertOptions.cssClass = 'core-alert-network-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;
|
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 {
|
} else {
|
||||||
alertOptions.header = Translate.instant('core.error');
|
alertOptions.header = Translate.instant('core.error');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error !== 'string' && 'buttons' in error && typeof error.buttons !== 'undefined') {
|
if (typeof error !== 'string' && 'buttons' in error && typeof error.buttons !== 'undefined') {
|
||||||
alertOptions.buttons = error.buttons;
|
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 {
|
} else {
|
||||||
alertOptions.buttons = [Translate.instant('core.ok')];
|
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.
|
// Default buttons.
|
||||||
options.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',
|
role: 'cancel',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reject();
|
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,
|
handler: resolvePromise,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -1044,11 +1044,7 @@ export class CoreUtilsProvider {
|
||||||
* @param options Override default options passed to InAppBrowser.
|
* @param options Override default options passed to InAppBrowser.
|
||||||
* @return The opened window.
|
* @return The opened window.
|
||||||
*/
|
*/
|
||||||
openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject | undefined {
|
openInApp(url: string, options?: InAppBrowserOptions): InAppBrowserObject {
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
options = options || {};
|
options = options || {};
|
||||||
options.usewkwebview = 'yes'; // Force WKWebView in iOS.
|
options.usewkwebview = 'yes'; // Force WKWebView in iOS.
|
||||||
options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default.
|
options.enableViewPortScale = options.enableViewPortScale ?? 'yes'; // Enable zoom on iOS by default.
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
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 { FileEntry } from '@ionic-native/file/ngx';
|
||||||
import { FileUploadOptions, FileUploadResult } from '@ionic-native/file-transfer/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 { CoreHttpError } from '@classes/errors/httperror';
|
||||||
import { CorePromisedValue } from '@classes/promised-value';
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
import { CorePlatform } from '@services/platform';
|
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.
|
* 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;
|
let data = response.body;
|
||||||
|
|
||||||
// Some moodle web services return null.
|
// 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).
|
// Check if error. Ajax layer should always return an object (if error) or an array (if success).
|
||||||
if (!data || typeof data != 'object') {
|
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) {
|
} else if (data.error) {
|
||||||
throw new CoreAjaxWSError(data);
|
throw new CoreAjaxWSError(data);
|
||||||
}
|
}
|
||||||
|
@ -480,24 +494,36 @@ export class CoreWSProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.data;
|
return data.data;
|
||||||
}, (data) => {
|
}, async (data: HttpErrorResponse) => {
|
||||||
let message = '';
|
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) {
|
switch (data.status) {
|
||||||
case -2: // Certificate error.
|
case -2: // Certificate error.
|
||||||
message = this.getCertificateErrorMessage(data.error);
|
options.errorcode = 'invalidcertificate';
|
||||||
break;
|
options.errorDetails = Translate.instant('core.certificaterror', {
|
||||||
case 404: // AJAX endpoint not found.
|
details: CoreTextUtils.getErrorMessageFromError(data.error) ?? 'Unknown error',
|
||||||
message = Translate.instant('core.ajaxendpointnotfound', {
|
|
||||||
$a: CoreSite.MINIMUM_MOODLE_VERSION,
|
|
||||||
whoisadmin: Translate.instant('core.whoissiteadmin'),
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 404: // AJAX endpoint not found.
|
||||||
|
options.errorcode = 'endpointnotfound';
|
||||||
|
options.errorDetails = Translate.instant('core.ajaxendpointnotfound', { $a: CoreSite.MINIMUM_MOODLE_VERSION });
|
||||||
|
break;
|
||||||
default:
|
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();
|
const promise = Http.post(requestUrl, ajaxData, options).pipe(timeout(this.getRequestTimeout())).toPromise();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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.
|
// 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 the responseExpected value is set to false, we create a blank object if the response is null.
|
||||||
if (!data && !preSets.responseExpected) {
|
if (!data && !preSets.responseExpected) {
|
||||||
|
@ -627,7 +653,12 @@ export class CoreWSProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
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) {
|
} else if (typeof data != preSets.typeExpected) {
|
||||||
// If responseType is text an string will be returned, parse before returning.
|
// If responseType is text an string will be returned, parse before returning.
|
||||||
if (typeof data == 'string') {
|
if (typeof data == 'string') {
|
||||||
|
@ -636,7 +667,10 @@ export class CoreWSProvider {
|
||||||
if (isNaN(data)) {
|
if (isNaN(data)) {
|
||||||
this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`);
|
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') {
|
} else if (preSets.typeExpected == 'boolean') {
|
||||||
if (data === 'true') {
|
if (data === 'true') {
|
||||||
|
@ -646,17 +680,26 @@ export class CoreWSProvider {
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`);
|
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 {
|
} else {
|
||||||
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
|
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 {
|
} else {
|
||||||
this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`);
|
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;
|
return data;
|
||||||
}, (error) => {
|
}, async (error) => {
|
||||||
// If server has heavy load, retry after some seconds.
|
// If server has heavy load, retry after some seconds.
|
||||||
if (error.status == 429) {
|
if (error.status == 429) {
|
||||||
const retryPromise = this.addToRetryQueue<T>(method, siteUrl, ajaxData, preSets);
|
const retryPromise = this.addToRetryQueue<T>(method, siteUrl, ajaxData, preSets);
|
||||||
|
@ -697,7 +740,12 @@ export class CoreWSProvider {
|
||||||
|
|
||||||
return retryPromise;
|
return retryPromise;
|
||||||
} else if (error.status === -2) {
|
} 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) {
|
} else if (error.status > 0) {
|
||||||
throw this.createHttpError(error, error.status);
|
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.
|
* Retry all requests in the queue.
|
||||||
* This function uses recursion in order to add a delay between requests to reduce stress.
|
* This function uses recursion in order to add a delay between requests to reduce stress.
|
||||||
|
@ -839,10 +869,12 @@ export class CoreWSProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
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) {
|
} else if (typeof data != preSets.typeExpected) {
|
||||||
this.logger.warn('Response of type "' + typeof data + '" received, expecting "' + 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) {
|
if (data.exception !== undefined || data.debuginfo !== undefined) {
|
||||||
|
@ -912,15 +944,26 @@ export class CoreWSProvider {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data === null) {
|
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) {
|
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') {
|
} else if (typeof data != 'object') {
|
||||||
this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "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) {
|
if (data.exception !== undefined) {
|
||||||
|
@ -952,7 +995,9 @@ export class CoreWSProvider {
|
||||||
*/
|
*/
|
||||||
protected createHttpError(error: CoreTextErrorObject, status: number): CoreHttpError {
|
protected createHttpError(error: CoreTextErrorObject, status: number): CoreHttpError {
|
||||||
const message = CoreTextUtils.buildSeveralParagraphsMessage([
|
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) || ''),
|
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);
|
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 {
|
export class CoreForms {
|
||||||
|
|
||||||
|
private static formIds: Record<string, number> = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the data from a form. It will only collect elements that have a name.
|
* Get the data from a form. It will only collect elements that have a name.
|
||||||
*
|
*
|
||||||
|
@ -93,6 +95,18 @@ export class CoreForms {
|
||||||
}, siteId);
|
}, 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>;
|
export type CoreFormFields<T = unknown> = Record<string, T>;
|
||||||
|
|
|
@ -217,7 +217,10 @@ export const Router = makeSingleton(RouterService);
|
||||||
export const DomSanitizer = makeSingleton(DomSanitizerService);
|
export const DomSanitizer = makeSingleton(DomSanitizerService);
|
||||||
|
|
||||||
// Convert external libraries injectables.
|
// 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.
|
// Async singletons.
|
||||||
export const AngularFrameworkDelegate = asyncInstance(async () => {
|
export const AngularFrameworkDelegate = asyncInstance(async () => {
|
||||||
|
|
|
@ -12,13 +12,18 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
export type CoreObjectWithoutEmpty<T> = {
|
import { Pretty } from '@/core/utils/types';
|
||||||
[k in keyof T]: T[k] extends undefined | null ? never : T[k];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CoreObjectWithoutUndefined<T> = {
|
type ValueWithoutEmpty<T> = T extends null | undefined ? never : T;
|
||||||
[k in keyof T]: T[k] extends undefined ? never : T[k];
|
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.
|
* 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 { CoreNetwork } from '@services/network';
|
||||||
import { CorePlatform } from '@services/platform';
|
import { CorePlatform } from '@services/platform';
|
||||||
import { CoreDB } from '@services/db';
|
import { CoreDB } from '@services/db';
|
||||||
import { CoreNavigator } from '@services/navigator';
|
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
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> {
|
abstract class WrapperComponent<U> {
|
||||||
|
|
||||||
|
@ -43,8 +48,6 @@ const textUtils = new CoreTextUtilsProvider();
|
||||||
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
|
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
|
||||||
[Translate, mock({ instant: key => key })],
|
[Translate, mock({ instant: key => key })],
|
||||||
[CoreDB, mock({ getDB: () => mock() })],
|
[CoreDB, mock({ getDB: () => mock() })],
|
||||||
[CoreNetwork, mock({ onChange: () => new Observable() })],
|
|
||||||
[CoreDomUtils, mock({ showModalLoading: () => Promise.resolve(mock({}, ['dismiss'])) })],
|
|
||||||
[CoreNavigator, mock({ navigateToSitePath: () => Promise.resolve(true) })],
|
[CoreNavigator, mock({ navigateToSitePath: () => Promise.resolve(true) })],
|
||||||
[ApplicationInit, mock({
|
[ApplicationInit, mock({
|
||||||
donePromise: Promise.resolve(),
|
donePromise: Promise.resolve(),
|
||||||
|
@ -56,9 +59,19 @@ const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unkno
|
||||||
ready: () => Promise.resolve(),
|
ready: () => Promise.resolve(),
|
||||||
resume: new Subject<void>(),
|
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);
|
config.declarations.push(component);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
|
@ -67,11 +80,15 @@ async function renderAngularComponent<T>(component: Type<T>, config: RenderConfi
|
||||||
...config.declarations,
|
...config.declarations,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
...getDefaultProviders(),
|
...getDefaultProviders(config),
|
||||||
...config.providers,
|
...config.providers,
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
imports: [BrowserModule],
|
imports: [
|
||||||
|
BrowserModule,
|
||||||
|
BrowserAnimationsModule,
|
||||||
|
...config.imports,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
testBedInitialized = true;
|
testBedInitialized = true;
|
||||||
|
@ -106,7 +123,7 @@ function getDefaultDeclarations(): unknown[] {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultProviders(): unknown[] {
|
function getDefaultProviders(config: RenderConfig): unknown[] {
|
||||||
const serviceProviders = DEFAULT_SERVICE_SINGLETON_MOCKS.map(
|
const serviceProviders = DEFAULT_SERVICE_SINGLETON_MOCKS.map(
|
||||||
([singleton, mockInstance]) => ({
|
([singleton, mockInstance]) => ({
|
||||||
provide: singleton.injectionToken,
|
provide: singleton.injectionToken,
|
||||||
|
@ -116,6 +133,19 @@ function getDefaultProviders(): unknown[] {
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...serviceProviders,
|
...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: [] },
|
{ provide: CORE_SITE_SCHEMAS, multiple: true, useValue: [] },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -141,9 +171,54 @@ function createNewServiceInstance(injectionToken: Exclude<ServiceInjectionToken,
|
||||||
export interface RenderConfig {
|
export interface RenderConfig {
|
||||||
declarations: unknown[];
|
declarations: unknown[];
|
||||||
providers: 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.
|
* 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 {
|
): T {
|
||||||
// If overrides is an object, apply them to the instance.
|
// If overrides is an object, apply them to the instance.
|
||||||
if (!Array.isArray(overrides)) {
|
if (!Array.isArray(overrides)) {
|
||||||
Object.assign(instance, overrides);
|
Object.assign(instance as Record<string, unknown>, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert instance functions to jest functions.
|
// Convert instance functions to jest functions.
|
||||||
|
@ -182,8 +257,7 @@ export function mock<T>(
|
||||||
return instance as T;
|
return instance as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T | Partial<T>): T;
|
||||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<unknown>, instance?: Record<string, unknown>): T;
|
|
||||||
export function mockSingleton<T>(
|
export function mockSingleton<T>(
|
||||||
singletonClass: CoreSingletonProxy<unknown>,
|
singletonClass: CoreSingletonProxy<unknown>,
|
||||||
methods: string[],
|
methods: string[],
|
||||||
|
@ -200,7 +274,7 @@ export function mockSingleton<T>(
|
||||||
const instance = getServiceInstance(singleton.injectionToken) as T;
|
const instance = getServiceInstance(singleton.injectionToken) as T;
|
||||||
const mockInstance = mock(instance, methods);
|
const mockInstance = mock(instance, methods);
|
||||||
|
|
||||||
Object.assign(mockInstance, properties);
|
Object.assign(mockInstance as Record<string, unknown>, properties);
|
||||||
|
|
||||||
singleton.setInstance(mockInstance);
|
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, {
|
return renderAngularComponent(component, {
|
||||||
declarations: [],
|
declarations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
imports: [],
|
||||||
...config,
|
...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>(
|
export async function renderTemplate<T>(
|
||||||
component: Type<T>,
|
component: Type<T>,
|
||||||
template: string,
|
template: string,
|
||||||
|
@ -246,6 +342,7 @@ export async function renderTemplate<T>(
|
||||||
{
|
{
|
||||||
declarations: [],
|
declarations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
imports: [],
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -293,8 +390,17 @@ export function wait(time: number): Promise<void> {
|
||||||
*
|
*
|
||||||
* @param translations List of translations.
|
* @param translations List of translations.
|
||||||
*/
|
*/
|
||||||
export function mockTranslate(translations: Record<string, string>): void {
|
export function mockTranslate(translations: Record<string, string> = {}): void {
|
||||||
mockSingleton(Translate, {
|
mockSingleton(Translate as CoreSingletonProxy<TranslateService>, {
|
||||||
instant: (key) => translations[key] ?? key,
|
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.
|
// Accessibility vars.
|
||||||
--a11y-min-target-size: 44px;
|
|
||||||
--a11y-focus-color: var(--primary);
|
--a11y-focus-color: var(--primary);
|
||||||
--a11y-focus-width: 2px;
|
--a11y-focus-width: 2px;
|
||||||
--zoom-level: 100%;
|
--zoom-level: 100%;
|
||||||
|
|
||||||
--small-radius: 4px;
|
|
||||||
--medium-radius: 8px;
|
|
||||||
--big-radius: 16px;
|
|
||||||
--huge-radius: 24px;
|
|
||||||
|
|
||||||
--text-color: #{$text-color};
|
--text-color: #{$text-color};
|
||||||
--text-size: 14px;
|
--text-size: var(--font-size-normal);
|
||||||
--background-color: #{$background-color};
|
--background-color: #{$background-color};
|
||||||
--stroke: var(--gray-300);
|
--stroke: var(--gray-300);
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
@import "./theme.light.scss";
|
@import "./theme.light.scss";
|
||||||
@import "./theme.dark.scss";
|
@import "./theme.dark.scss";
|
||||||
@import "./theme.custom.scss";
|
@import "./theme.custom.scss";
|
||||||
|
@import "./theme.design-system.scss";
|
||||||
@import "./theme.base.scss";
|
@import "./theme.base.scss";
|
||||||
|
|
||||||
/* Components */
|
/* Components */
|
||||||
|
@ -24,6 +25,7 @@
|
||||||
@import "./components/format-text.scss";
|
@import "./components/format-text.scss";
|
||||||
@import "./components/rubrics.scss";
|
@import "./components/rubrics.scss";
|
||||||
@import "./components/mod-label.scss";
|
@import "./components/mod-label.scss";
|
||||||
|
@import "../core/components/error-info/error-info.scss";
|
||||||
|
|
||||||
/* Some styles from 3rd party libraries. */
|
/* Some styles from 3rd party libraries. */
|
||||||
@import "./bootstrap.scss";
|
@import "./bootstrap.scss";
|
||||||
|
|
|
@ -12,27 +12,8 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
declare module '!raw-loader!*' {
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
const contents: string;
|
||||||
|
|
||||||
import { CoreSharedModule } from '@/core/shared.module';
|
export = contents;
|
||||||
import { CoreLoginForgottenPasswordPage } from './forgotten-password';
|
}
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: CoreLoginForgottenPasswordPage,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
RouterModule.forChild(routes),
|
|
||||||
CoreSharedModule,
|
|
||||||
],
|
|
||||||
declarations: [
|
|
||||||
CoreLoginForgottenPasswordPage,
|
|
||||||
],
|
|
||||||
exports: [RouterModule],
|
|
||||||
})
|
|
||||||
export class CoreLoginForgottenPasswordPageModule {}
|
|
Loading…
Reference in New Issue