From d66c744ab8f5d040071652ca9c55c6c9fa1d2fc0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 27 Oct 2020 11:44:35 +0100 Subject: [PATCH 01/12] MOBILE-3565 login: Add help and onboarding modals --- .../login/components/site-help/site-help.html | 76 +++++ .../login/components/site-help/site-help.scss | 9 + .../login/components/site-help/site-help.ts | 52 +++ .../site-onboarding/site-onboarding.html | 59 ++++ .../site-onboarding/site-onboarding.scss | 28 ++ .../site-onboarding/site-onboarding.ts | 94 ++++++ src/app/core/login/lang/en.json | 4 +- src/app/core/login/login.module.ts | 22 +- src/app/core/login/pages/site/site.page.ts | 25 +- src/app/lang/en.json | 309 ++++++++++++++++- src/assets/img/login/faq_qrcode.png | Bin 0 -> 63963 bytes src/assets/img/login/faq_url.png | Bin 0 -> 19328 bytes src/assets/lang/en.json | 311 +++++++++++++++++- src/theme/app.scss | 13 + 14 files changed, 989 insertions(+), 13 deletions(-) create mode 100644 src/app/core/login/components/site-help/site-help.html create mode 100644 src/app/core/login/components/site-help/site-help.scss create mode 100644 src/app/core/login/components/site-help/site-help.ts create mode 100644 src/app/core/login/components/site-onboarding/site-onboarding.html create mode 100644 src/app/core/login/components/site-onboarding/site-onboarding.scss create mode 100644 src/app/core/login/components/site-onboarding/site-onboarding.ts create mode 100644 src/assets/img/login/faq_qrcode.png create mode 100644 src/assets/img/login/faq_url.png diff --git a/src/app/core/login/components/site-help/site-help.html b/src/app/core/login/components/site-help/site-help.html new file mode 100644 index 000000000..5bc9e9615 --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.html @@ -0,0 +1,76 @@ + + + {{ 'core.login.help' | translate }} + + + + + + + + + + + + +

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

+
+
+ + +

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

+
+
+ + +

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

+
+
+ + + +

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

+
+
+ + +

{{ 'core.login.faqcannotconnectanswer' | translate }} {{ 'core.whoissiteadmin' | translate }}

+
+
+ + +

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

+
+
+ + +

+

+
+
+ + +

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

+
+
+ + +

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

+
+
+ + +

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

+
+
+ + +

+
+
+
+
\ No newline at end of file diff --git a/src/app/core/login/components/site-help/site-help.scss b/src/app/core/login/components/site-help/site-help.scss new file mode 100644 index 000000000..0c6f38720 --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.scss @@ -0,0 +1,9 @@ +.core-login-faqwhatisurlanswer img { + max-height: 50px; +} + +.core-login-faqwhereisqrcodeanswer img { + max-height: 220px; + margin-top: 5px; + margin-bottom: 5px; +} \ No newline at end of file diff --git a/src/app/core/login/components/site-help/site-help.ts b/src/app/core/login/components/site-help/site-help.ts new file mode 100644 index 000000000..1cd9030ae --- /dev/null +++ b/src/app/core/login/components/site-help/site-help.ts @@ -0,0 +1,52 @@ +// (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 } from '@angular/core'; + +import { CoreUtils } from '@services/utils/utils'; +import { ModalController, Translate } from '@singletons/core.singletons'; +import { CoreLoginHelperProvider } from '@core/login/services/helper'; + +/** + * Component that displays help to connect to a site. + */ +@Component({ + selector: 'core-login-site-help', + templateUrl: 'site-help.html', + styleUrls: ['site-help.scss'], +}) +export class CoreLoginSiteHelpComponent { + + urlImageHtml: string; + setupLinkHtml: string; + qrCodeImageHtml: string; + canScanQR: boolean; + + constructor() { + + this.canScanQR = CoreUtils.instance.canScanQR(); + this.urlImageHtml = CoreLoginHelperProvider.FAQ_URL_IMAGE_HTML; + this.qrCodeImageHtml = CoreLoginHelperProvider.FAQ_QRCODE_IMAGE_HTML; + this.setupLinkHtml = 'https://moodle.com/getstarted/'; + } + + /** + * Close help modal. + */ + closeHelp(): void { + ModalController.instance.dismiss(); + } + +} diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.html b/src/app/core/login/components/site-onboarding/site-onboarding.html new file mode 100644 index 000000000..9c34463e2 --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.html @@ -0,0 +1,59 @@ + + + + + + + + + + + {{'core.skip' | translate}} + + + + + +
+ + + + + + + + + +
+
diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.scss b/src/app/core/login/components/site-onboarding/site-onboarding.scss new file mode 100644 index 000000000..1bbab53c3 --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.scss @@ -0,0 +1,28 @@ +:host { + .core-login-onboarding-step { + padding: 10px 20px; + text-align: center; + /* @todo @include media-breakpoint-up(md) { + max-width: 80%; + }*/ + margin: 0 auto; + + p { + margin-bottom: 10px; + } + + ul { + margin-bottom: 30px; + list-style-type: none; + // @todo @include text-align('start'); + // @todo @include padding-horizontal(10px, null); + li { + margin-bottom: 10px; + } + } + + .button-block { + margin-top: 20px; + } + } +} diff --git a/src/app/core/login/components/site-onboarding/site-onboarding.ts b/src/app/core/login/components/site-onboarding/site-onboarding.ts new file mode 100644 index 000000000..29e568f4e --- /dev/null +++ b/src/app/core/login/components/site-onboarding/site-onboarding.ts @@ -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 } from '@angular/core'; + +import { CoreConfig } from '@services/config'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLoginHelperProvider } from '@core/login/services/helper'; +import { ModalController } from '@singletons/core.singletons'; + +/** + * Component that displays onboarding help regarding the CoreLoginSitePage. + */ +@Component({ + selector: 'core-login-site-onboarding', + templateUrl: 'site-onboarding.html', + styleUrls: ['site-onboarding.scss', '../../login.scss'], +}) +export class CoreLoginSiteOnboardingComponent { + + step = 0; + + /** + * Go to next step. + * + * @param e Click event. + */ + next(e: Event): void { + e.stopPropagation(); + + this.step++; + } + + /** + * Go to previous step. + * + * @param e Click event. + */ + previous(e: Event): void { + e.stopPropagation(); + + if (this.step == 0) { + ModalController.instance.dismiss(); + } else { + this.step--; + } + } + + /** + * Close modal. + * + * @param e Click event. + */ + skip(e: Event): void { + e.stopPropagation(); + + this.saveOnboardingDone(); + ModalController.instance.dismiss(); + } + + /** + * Create a site. + * + * @param e Click event. + */ + gotoWeb(e: Event): void { + e.stopPropagation(); + + this.saveOnboardingDone(); + + CoreUtils.instance.openInBrowser('https://moodle.com/getstarted/'); + + ModalController.instance.dismiss(); + } + + /** + * Saves the onboarding has finished. + */ + protected saveOnboardingDone(): void { + CoreConfig.instance.set(CoreLoginHelperProvider.ONBOARDING_DONE, 1); + } + +} diff --git a/src/app/core/login/lang/en.json b/src/app/core/login/lang/en.json index e655fb691..29cf529c3 100644 --- a/src/app/core/login/lang/en.json +++ b/src/app/core/login/lang/en.json @@ -36,7 +36,7 @@ "faqsetupsitequestion": "I want to set up my own Moodle site.", "faqtestappanswer": "To test the app in a Moodle Demo Site, type \"teacher\" or \"student\" in the \"Your site\" field and click the \"Connect to your site\" button.", "faqtestappquestion": "I just want to test the app, what can I do?", - "faqwhatisurlanswer": "

Every organisation has their own unique address or URL for their Moodle site. To find the address:

  1. Open a web browser and go to your Moodle site login page.
  2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
    {{$image}}
  3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
  4. Now you can log in to your site using your username and password.
  5. ", + "faqwhatisurlanswer": "

    Every organisation has their own unique address or URL for their Moodle site. To find the address:

    1. Open a web browser and go to your Moodle site login page.
    2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
      {{$image}}
    3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
    4. Now you can log in to your site using your username and password.
    ", "faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "faqwhereisqrcode": "Where can I find the QR code?", "faqwhereisqrcodeanswer": "

    If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.

    {{$image}}", @@ -121,4 +121,4 @@ "webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.", "youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.", "yourenteredsite": "Connect to your site" -} +} \ No newline at end of file diff --git a/src/app/core/login/login.module.ts b/src/app/core/login/login.module.ts index fdfb74a9c..24294daaa 100644 --- a/src/app/core/login/login.module.ts +++ b/src/app/core/login/login.module.ts @@ -13,12 +13,32 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; import { CoreLoginRoutingModule } from './login-routing.module'; +import { CoreLoginSiteHelpComponent } from './components/site-help/site-help'; +import { CoreLoginSiteOnboardingComponent } from './components/site-onboarding/site-onboarding'; @NgModule({ imports: [ CoreLoginRoutingModule, + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreLoginSiteHelpComponent, + CoreLoginSiteOnboardingComponent, + ], + exports: [ + CoreLoginSiteHelpComponent, + CoreLoginSiteOnboardingComponent, ], - declarations: [], }) export class CoreLoginModule {} diff --git a/src/app/core/login/pages/site/site.page.ts b/src/app/core/login/pages/site/site.page.ts index 67aef30cb..9ef042fd1 100644 --- a/src/app/core/login/pages/site/site.page.ts +++ b/src/app/core/login/pages/site/site.page.ts @@ -15,6 +15,7 @@ import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; +import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; @@ -25,10 +26,11 @@ import { CoreLoginHelper, CoreLoginHelperProvider } from '@core/login/services/h import { CoreSite } from '@classes/site'; import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; -import { Translate } from '@singletons/core.singletons'; +import { Translate, ModalController } from '@singletons/core.singletons'; import { CoreUrl } from '@singletons/url'; import { CoreUrlUtils } from '@services/utils/url'; -import { NavController } from '@ionic/angular'; +import { CoreLoginSiteHelpComponent } from '@core/login/components/site-help/site-help'; +import { CoreLoginSiteOnboardingComponent } from '@core/login/components/site-onboarding/site-onboarding'; /** * Page that displays a "splash screen" while the app is being initialized. @@ -215,15 +217,25 @@ export class CoreLoginSitePage implements OnInit { /** * Show a help modal. */ - showHelp(): void { - // @todo + async showHelp(): Promise { + const modal = await ModalController.instance.create({ + component: CoreLoginSiteHelpComponent, + cssClass: 'core-modal-fullscreen', + }); + + await modal.present(); } /** * Show an onboarding modal. */ - showOnboarding(): void { - // @todo + async showOnboarding(): Promise { + const modal = await ModalController.instance.create({ + component: CoreLoginSiteOnboardingComponent, + cssClass: 'core-modal-fullscreen', + }); + + await modal.present(); } /** @@ -360,7 +372,6 @@ export class CoreLoginSitePage implements OnInit { pageParams['logoUrl'] = foundSite.imageurl; } - // @todo Navigate to credentials. this.navCtrl.navigateForward('/login/credentials', { queryParams: pageParams, }); diff --git a/src/app/lang/en.json b/src/app/lang/en.json index bd3da88ca..c6c46dc48 100644 --- a/src/app/lang/en.json +++ b/src/app/lang/en.json @@ -1,18 +1,325 @@ { + "accounts": "Accounts", + "add": "Add", + "agelocationverification": "Age and location verification", + "ago": "{{$a}} ago", + "all": "All", + "allgroups": "All groups", + "allparticipants": "All participants", + "answer": "Answer", + "answered": "Answered", + "areyousure": "Are you sure?", "back": "Back", "browser": "Browser", + "cancel": "Cancel", "cannotconnect": "Cannot connect", "cannotconnecttrouble": "We're having trouble connecting to your site.", "cannotconnectverify": "Please check the address is correct.", + "cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", + "captureaudio": "Record audio", + "capturedimage": "Taken picture.", + "captureimage": "Take picture", + "capturevideo": "Record video", + "category": "Category", + "choose": "Choose", + "choosedots": "Choose...", + "clearsearch": "Clear search", + "clearstoreddata": "Clear storage {{$a}}", + "clicktohideshow": "Click to expand or collapse", + "clicktoseefull": "Click to see full contents.", + "close": "Close", + "comments": "Comments", + "commentscount": "Comments ({{$a}})", + "completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", + "completion-alt-auto-n": "Not completed: {{$a}}", + "completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "completion-alt-auto-pass": "Completed: {{$a}} (achieved pass grade)", + "completion-alt-auto-y": "Completed: {{$a}}", + "completion-alt-auto-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "completion-alt-manual-n": "Not completed: {{$a}}. Select to mark as complete.", + "completion-alt-manual-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as complete.", + "completion-alt-manual-y": "Completed: {{$a}}. Select to mark as not complete.", + "completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", + "confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", + "confirmdeletefile": "Are you sure you want to delete this file?", + "confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.", + "confirmloss": "Are you sure? All changes will be lost.", + "confirmopeninbrowser": "Do you want to open it in a web browser?", + "considereddigitalminor": "You are too young to create an account on this site.", + "content": "Content", + "contenteditingsynced": "The content you are editing has been synced.", + "continue": "Continue", "copiedtoclipboard": "Text copied to clipboard", + "copytoclipboard": "Copy to clipboard", + "course": "Course", + "coursedetails": "Course details", + "coursenogroups": "You are not a member of any group of this course.", + "currentdevice": "Current device", + "datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", + "date": "Date", + "day": "day", + "days": "days", + "decsep": ".", + "defaultvalue": "Default ({{$a}})", + "delete": "Delete", + "deletedoffline": "Deleted offline", + "deleteduser": "Deleted user", + "deleting": "Deleting", + "description": "Description", + "desktop": "Desktop", + "dfdaymonthyear": "MM-DD-YYYY", + "dfdayweekmonth": "ddd, D MMM", + "dffulldate": "dddd, D MMMM YYYY h[:]mm A", + "dflastweekdate": "ddd", + "dfmediumdate": "LLL", + "dftimedate": "h[:]mm A", + "digitalminor": "Digital minor", + "digitalminor_desc": "Please ask your parent/guardian to contact:", + "discard": "Discard", + "dismiss": "Dismiss", + "displayoptions": "Display options", + "done": "Done", + "download": "Download", + "downloaded": "Downloaded", + "downloadfile": "Download file", + "downloading": "Downloading", + "edit": "Edit", + "emptysplit": "This page will appear blank if the left panel is empty or is loading.", + "error": "Error", + "errorchangecompletion": "An error occurred while changing the completion status. Please try again.", + "errordeletefile": "Error deleting the file. Please try again.", + "errordownloading": "Error downloading file.", + "errordownloadingsomefiles": "Error downloading files. Some files might be missing.", + "errorfileexistssamename": "A file with this name already exists.", + "errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.", + "errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.", + "errorloadingcontent": "Error loading content.", + "errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.", + "erroropenfilenoapp": "Error opening file: no app found to open this type of file.", + "erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", + "erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", + "errorrenamefile": "Error renaming file. Please try again.", + "errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", + "errorsync": "An error occurred while synchronising. Please try again.", + "errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", + "errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.", + "errorurlschemeinvalidsite": "This site URL cannot be opened in this app.", + "explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", + "favourites": "Starred", + "filename": "Filename", + "filenameexist": "File name already exists: {{$a}}", + "filenotfound": "File not found, sorry.", + "folder": "Folder", + "forcepasswordchangenotice": "You must change your password to proceed.", + "fulllistofcourses": "All courses", + "fullnameandsitename": "{{fullname}} ({{sitename}})", + "group": "Group", + "groupsseparate": "Separate groups", + "groupsvisible": "Visible groups", + "hasdatatosync": "This {{$a}} has offline data to be synchronised.", + "help": "Help", + "hide": "Hide", + "hour": "hour", + "hours": "hours", + "humanreadablesize": "{{size}} {{unit}}", + "image": "Image", + "imageviewer": "Image viewer", + "info": "Information", + "invalidformdata": "Incorrect form data", + "labelsep": ":", + "filter": "Filter", + "lastaccess": "Last access", + "lastdownloaded": "Last downloaded", + "lastmodified": "Last modified", + "lastsync": "Last synchronisation", + "layoutgrid": "Grid", + "list": "List", + "listsep": ",", "loading": "Loading", + "loadmore": "Load more", + "location": "Location", + "lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", + "maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", + "min": "min", + "mins": "mins", + "misc": "Miscellaneous", + "mod_assign": "Assignment", + "mod_assignment": "Assignment 2.2 (Disabled)", + "mod_book": "Book", + "mod_chat": "Chat", + "mod_choice": "Choice", + "mod_data": "Database", + "mod_database": "Database", + "mod_external-tool": "External tool", + "mod_feedback": "Feedback", + "mod_file": "File", + "mod_folder": "Folder", + "mod_forum": "Forum", + "mod_glossary": "Glossary", + "mod_h5pactivity": "H5P", + "mod_ims": "IMS content package", + "mod_imscp": "IMS content package", + "mod_label": "Label", + "mod_lesson": "Lesson", + "mod_lti": "External tool", + "mod_page": "Page", + "mod_quiz": "Quiz", + "mod_resource": "File", + "mod_scorm": "SCORM package", + "mod_survey": "Survey", + "mod_url": "URL", + "mod_wiki": "Wiki", + "mod_workshop": "Workshop", + "moduleintro": "Description", + "more": "more", + "mygroups": "My groups", + "name": "Name", "needhelp": "Need help?", + "networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.", "networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.", + "never": "Never", + "next": "Next", "no": "No", + "nocomments": "No comments", + "nograde": "No grade", + "none": "None", + "nopasswordchangeforced": "You cannot proceed without changing your password.", + "nopermissionerror": "Sorry, but you do not currently have permissions to do that", + "nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", + "noresults": "No results", + "noselection": "No selection", + "notapplicable": "n/a", + "notavailable": "Not available", + "notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", + "notice": "Notice", + "nooptionavailable": "No option available", + "notingroup": "Sorry, but you need to be part of a group to see this page.", + "notsent": "Not sent", + "now": "now", + "nummore": "{{$a}} more", + "numwords": "{{$a}} words", "offline": "Offline", "ok": "OK", "online": "Online", + "openfile": "Open file", + "openfullimage": "Click here to display the full size image", + "openinbrowser": "Open in browser", + "openmodinbrowser": "Open {{$a}} in browser", + "othergroups": "Other groups", + "pagea": "Page {{$a}}", + "parentlanguage": "", + "paymentinstant": "Use the button below to pay and be enrolled within minutes!", + "percentagenumber": "{{$a}}%", + "phone": "Phone", + "pictureof": "Picture of {{$a}}", + "previous": "Previous", + "proceed": "Proceed", + "pulltorefresh": "Pull to refresh", + "qrscanner": "QR scanner", + "quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.", + "redirectingtosite": "You will be redirected to the site.", + "refresh": "Refresh", + "remove": "Remove", + "removefiles": "Remove files {{$a}}", + "required": "Required", + "requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.
    {{$a}}", + "resourcedisplayopen": "Open", + "resources": "Resources", + "restore": "Restore", + "restricted": "Restricted", + "retry": "Retry", + "save": "Save", + "savechanges": "Save changes", + "scanqr": "Scan QR code", + "search": "Search", + "searching": "Searching", + "searchresults": "Search results", + "sec": "sec", + "secs": "secs", + "seemoredetail": "Click here to see more detail", + "selectacategory": "Please select a category", + "selectacourse": "Select a course", + "selectagroup": "Select a group", + "send": "Send", + "sending": "Sending", + "serverconnection": "Error connecting to the server", + "show": "Show", + "showless": "Show less...", + "showmore": "Show more...", + "site": "Site", + "sitemaintenance": "The site is undergoing maintenance and is currently not available", + "sizeb": "bytes", + "sizegb": "GB", + "sizekb": "KB", + "sizemb": "MB", + "sizetb": "TB", + "skip": "Skip", + "sorry": "Sorry...", + "sort": "Sort", + "sortby": "Sort by", + "start": "Start", + "storingfiles": "Storing files", + "strftimedate": "%d %B %Y", + "strftimedatefullshort": "%d/%m/%y", + "strftimedateshort": "%d %B", + "strftimedatetime": "%d %B %Y, %I:%M %p", + "strftimedatetimeshort": "%d/%m/%y, %H:%M", + "strftimedaydate": "%A, %d %B %Y", + "strftimedaydatetime": "%A, %d %B %Y, %I:%M %p", + "strftimedayshort": "%A, %d %B", + "strftimedaytime": "%a, %H:%M", + "strftimemonthyear": "%B %Y", + "strftimerecent": "%d %b, %H:%M", + "strftimerecentfull": "%a, %d %b %Y, %I:%M %p", + "strftimetime": "%I:%M %p", + "strftimetime12": "%I:%M %p", + "strftimetime24": "%H:%M", + "submit": "Submit", + "success": "Success", + "tablet": "Tablet", + "teachers": "Teachers", + "thereisdatatosync": "There are offline {{$a}} to be synchronised.", + "thisdirection": "ltr", + "time": "Time", + "timesup": "Time is up!", + "today": "Today", "tryagain": "Try again", + "twoparagraphs": "{{p1}}

    {{p2}}", + "uhoh": "Uh oh!", + "unexpectederror": "Unexpected error. Please close and reopen the application then try again.", + "unicodenotsupported": "Some emojis are not supported on this site. Such characters will be removed when the message is sent.", + "unicodenotsupportedcleanerror": "Empty text was found when cleaning Unicode chars.", "unknown": "Unknown", - "yes": "Yes" + "unlimited": "Unlimited", + "unzipping": "Unzipping", + "upgraderunning": "Site is being upgraded, please retry later.", + "updaterequired": "App update required", + "updaterequireddesc": "Please update your app to version {{$a}}", + "user": "User", + "userdeleted": "This user account has been deleted", + "userdetails": "User details", + "usernotfullysetup": "User not fully set-up", + "users": "Users", + "view": "View", + "viewcode": "View code", + "vieweditor": "View editor", + "viewembeddedcontent": "View embedded content", + "viewprofile": "View profile", + "warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}", + "whatisyourage": "What is your age?", + "wheredoyoulive": "In which country do you live?", + "whoissiteadmin": "\"Site Administrators\" are the people who manage the Moodle at your school/university/company or learning organisation. If you don't know how to contact them, please contact your teachers/trainers.", + "whoops": "Oops!", + "whyisthishappening": "Why is this happening?", + "whyisthisrequired": "Why is this required?", + "wsfunctionnotavailable": "The web service function is not available.", + "year": "year", + "years": "years", + "yes": "Yes", + "youreoffline": "You are offline", + "youreonline": "You are back online" } diff --git a/src/assets/img/login/faq_qrcode.png b/src/assets/img/login/faq_qrcode.png new file mode 100644 index 0000000000000000000000000000000000000000..cc936b168ae3e029d9ee6361984b7b609e9d61fb GIT binary patch literal 63963 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 EKl}Dvr~m)} literal 0 HcmV?d00001 diff --git a/src/assets/img/login/faq_url.png b/src/assets/img/login/faq_url.png new file mode 100644 index 0000000000000000000000000000000000000000..13d92cd502b89ec90b482ecf632eeebb5e8a4b84 GIT binary patch literal 19328 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 check the address is correct.", + "core.cannotdownloadfiles": "File downloading is disabled. Please contact your site administrator.", + "core.cannotopeninapp": "This file may not work as expected on this device. Would you like to open it anyway?", + "core.cannotopeninappdownload": "This file may not work as expected on this device. Would you like to download it anyway?", + "core.captureaudio": "Record audio", + "core.capturedimage": "Taken picture.", + "core.captureimage": "Take picture", + "core.capturevideo": "Record video", + "core.category": "Category", + "core.choose": "Choose", + "core.choosedots": "Choose...", + "core.clearsearch": "Clear search", + "core.clearstoreddata": "Clear storage {{$a}}", + "core.clicktohideshow": "Click to expand or collapse", + "core.clicktoseefull": "Click to see full contents.", + "core.close": "Close", + "core.comments": "Comments", + "core.commentscount": "Comments ({{$a}})", + "core.completion-alt-auto-fail": "Completed: {{$a}} (did not achieve pass grade)", + "core.completion-alt-auto-n": "Not completed: {{$a}}", + "core.completion-alt-auto-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "core.completion-alt-auto-pass": "Completed: {{$a}} (achieved pass grade)", + "core.completion-alt-auto-y": "Completed: {{$a}}", + "core.completion-alt-auto-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}})", + "core.completion-alt-manual-n": "Not completed: {{$a}}. Select to mark as complete.", + "core.completion-alt-manual-n-override": "Not completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as complete.", + "core.completion-alt-manual-y": "Completed: {{$a}}. Select to mark as not complete.", + "core.completion-alt-manual-y-override": "Completed: {{$a.modname}} (set by {{$a.overrideuser}}). Select to mark as not complete.", + "core.confirmcanceledit": "Are you sure you want to leave this page? All changes will be lost.", + "core.confirmdeletefile": "Are you sure you want to delete this file?", + "core.confirmgotabroot": "Are you sure you want to go back to {{name}}?", + "core.confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "core.confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have unsaved changes they will be lost.", + "core.confirmloss": "Are you sure? All changes will be lost.", + "core.confirmopeninbrowser": "Do you want to open it in a web browser?", + "core.considereddigitalminor": "You are too young to create an account on this site.", + "core.content": "Content", + "core.contenteditingsynced": "The content you are editing has been synced.", + "core.continue": "Continue", "core.copiedtoclipboard": "Text copied to clipboard", + "core.copytoclipboard": "Copy to clipboard", + "core.course": "Course", + "core.coursedetails": "Course details", + "core.coursenogroups": "You are not a member of any group of this course.", "core.courses.addtofavourites": "Star this course", "core.courses.allowguests": "This course allows guest users to enter", "core.courses.availablecourses": "Available courses", @@ -343,7 +396,90 @@ "core.courses.sendpaymentbutton": "Send payment via PayPal", "core.courses.show": "Restore to view", "core.courses.totalcoursesearchresults": "Total courses: {{$a}}", + "core.currentdevice": "Current device", + "core.datastoredoffline": "Data stored in the device because it couldn't be sent. It will be sent automatically later.", + "core.date": "Date", + "core.day": "day", + "core.days": "days", + "core.decsep": ".", + "core.defaultvalue": "Default ({{$a}})", + "core.delete": "Delete", + "core.deletedoffline": "Deleted offline", + "core.deleteduser": "Deleted user", + "core.deleting": "Deleting", + "core.description": "Description", + "core.desktop": "Desktop", + "core.dfdaymonthyear": "MM-DD-YYYY", + "core.dfdayweekmonth": "ddd, D MMM", + "core.dffulldate": "dddd, D MMMM YYYY h[:]mm A", + "core.dflastweekdate": "ddd", + "core.dfmediumdate": "LLL", + "core.dftimedate": "h[:]mm A", + "core.digitalminor": "Digital minor", + "core.digitalminor_desc": "Please ask your parent/guardian to contact:", + "core.discard": "Discard", + "core.dismiss": "Dismiss", + "core.displayoptions": "Display options", + "core.done": "Done", + "core.download": "Download", + "core.downloaded": "Downloaded", + "core.downloadfile": "Download file", + "core.downloading": "Downloading", + "core.edit": "Edit", + "core.emptysplit": "This page will appear blank if the left panel is empty or is loading.", + "core.error": "Error", + "core.errorchangecompletion": "An error occurred while changing the completion status. Please try again.", + "core.errordeletefile": "Error deleting the file. Please try again.", + "core.errordownloading": "Error downloading file.", + "core.errordownloadingsomefiles": "Error downloading files. Some files might be missing.", + "core.errorfileexistssamename": "A file with this name already exists.", + "core.errorinvalidform": "The form contains invalid data. Please check that all required fields are filled in and that the data is valid.", + "core.errorinvalidresponse": "Invalid response received. Please contact your site administrator if the error persists.", + "core.errorloadingcontent": "Error loading content.", + "core.errorofflinedisabled": "Offline browsing is disabled on your site. You need to be connected to the internet to use the app.", + "core.erroropenfilenoapp": "Error opening file: no app found to open this type of file.", + "core.erroropenfilenoextension": "Error opening file: the file doesn't have an extension.", + "core.erroropenpopup": "This activity is trying to open a popup. This is not supported in the app.", + "core.errorrenamefile": "Error renaming file. Please try again.", + "core.errorsomedatanotdownloaded": "If you downloaded this activity, please notice that some data isn't downloaded during the download process for performance and data usage reasons.", + "core.errorsync": "An error occurred while synchronising. Please try again.", + "core.errorsyncblocked": "This {{$a}} cannot be synchronised right now because of an ongoing process. Please try again later. If the problem persists, try restarting the app.", + "core.errorurlschemeinvalidscheme": "This URL is meant to be used in another app: {{$a}}.", + "core.errorurlschemeinvalidsite": "This site URL cannot be opened in this app.", + "core.explanationdigitalminor": "This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.", + "core.favourites": "Starred", + "core.filename": "Filename", + "core.filenameexist": "File name already exists: {{$a}}", + "core.filenotfound": "File not found, sorry.", + "core.filter": "Filter", + "core.folder": "Folder", + "core.forcepasswordchangenotice": "You must change your password to proceed.", + "core.fulllistofcourses": "All courses", + "core.fullnameandsitename": "{{fullname}} ({{sitename}})", + "core.group": "Group", + "core.groupsseparate": "Separate groups", + "core.groupsvisible": "Visible groups", + "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", + "core.help": "Help", + "core.hide": "Hide", + "core.hour": "hour", + "core.hours": "hours", + "core.humanreadablesize": "{{size}} {{unit}}", + "core.image": "Image", + "core.imageviewer": "Image viewer", + "core.info": "Information", + "core.invalidformdata": "Incorrect form data", + "core.labelsep": ":", + "core.lastaccess": "Last access", + "core.lastdownloaded": "Last downloaded", + "core.lastmodified": "Last modified", + "core.lastsync": "Last synchronisation", + "core.layoutgrid": "Grid", + "core.list": "List", + "core.listsep": ",", "core.loading": "Loading", + "core.loadmore": "Load more", + "core.location": "Location", "core.login.auth_email": "Email-based self-registration", "core.login.authenticating": "Authenticating", "core.login.cancel": "Cancel", @@ -381,7 +517,7 @@ "core.login.faqsetupsitequestion": "I want to set up my own Moodle site.", "core.login.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.", "core.login.faqtestappquestion": "I just want to test the app, what can I do?", - "core.login.faqwhatisurlanswer": "

    Every organisation has their own unique address or URL for their Moodle site. To find the address:

    1. Open a web browser and go to your Moodle site login page.
    2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
      {{$image}}
    3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
    4. Now you can log in to your site using your username and password.
    5. ", + "core.login.faqwhatisurlanswer": "

      Every organisation has their own unique address or URL for their Moodle site. To find the address:

      1. Open a web browser and go to your Moodle site login page.
      2. At the top of the page, in the address bar, you will see the URL of your Moodle site e.g. \"campus.example.edu\".
        {{$image}}
      3. Copy the address (do not copy the /login and what comes after), paste it into the Moodle app then click \"Connect to your site\"
      4. Now you can log in to your site using your username and password.
      ", "core.login.faqwhatisurlquestion": "What is my site address? How can I find my site URL?", "core.login.faqwhereisqrcode": "Where can I find the QR code?", "core.login.faqwhereisqrcodeanswer": "

      If your organisation has enabled it, you will find a QR code on the web site at the bottom of your user profile page.

      {{$image}}", @@ -466,17 +602,116 @@ "core.login.webservicesnotenabled": "Your host site may not have enabled Web services. Please contact your administrator for help.", "core.login.youcanstillconnectwithcredentials": "You can still connect to the site by entering your username and password.", "core.login.yourenteredsite": "Connect to your site", + "core.lostconnection": "Your authentication token is invalid or has expired. You will have to reconnect to the site.", "core.mainmenu.changesite": "Change site", "core.mainmenu.help": "Help", "core.mainmenu.home": "Home", "core.mainmenu.logout": "Log out", "core.mainmenu.website": "Website", + "core.maxsizeandattachments": "Maximum file size: {{$a.size}}, maximum number of files: {{$a.attachments}}", + "core.min": "min", + "core.mins": "mins", + "core.misc": "Miscellaneous", + "core.mod_assign": "Assignment", + "core.mod_assignment": "Assignment 2.2 (Disabled)", + "core.mod_book": "Book", + "core.mod_chat": "Chat", + "core.mod_choice": "Choice", + "core.mod_data": "Database", + "core.mod_database": "Database", + "core.mod_external-tool": "External tool", + "core.mod_feedback": "Feedback", + "core.mod_file": "File", + "core.mod_folder": "Folder", + "core.mod_forum": "Forum", + "core.mod_glossary": "Glossary", + "core.mod_h5pactivity": "H5P", + "core.mod_ims": "IMS content package", + "core.mod_imscp": "IMS content package", + "core.mod_label": "Label", + "core.mod_lesson": "Lesson", + "core.mod_lti": "External tool", + "core.mod_page": "Page", + "core.mod_quiz": "Quiz", + "core.mod_resource": "File", + "core.mod_scorm": "SCORM package", + "core.mod_survey": "Survey", + "core.mod_url": "URL", + "core.mod_wiki": "Wiki", + "core.mod_workshop": "Workshop", + "core.moduleintro": "Description", + "core.more": "more", + "core.mygroups": "My groups", + "core.name": "Name", "core.needhelp": "Need help?", + "core.networkerroriframemsg": "This content is not available offline. Please connect to the internet and try again.", "core.networkerrormsg": "There was a problem connecting to the site. Please check your connection and try again.", + "core.never": "Never", + "core.next": "Next", "core.no": "No", + "core.nocomments": "No comments", + "core.nograde": "No grade", + "core.none": "None", + "core.nooptionavailable": "No option available", + "core.nopasswordchangeforced": "You cannot proceed without changing your password.", + "core.nopermissionerror": "Sorry, but you do not currently have permissions to do that", + "core.nopermissions": "Sorry, but you do not currently have permissions to do that ({{$a}}).", + "core.noresults": "No results", + "core.noselection": "No selection", + "core.notapplicable": "n/a", + "core.notavailable": "Not available", + "core.notenrolledprofile": "This profile is not available because this user is not enrolled in this course.", + "core.notice": "Notice", + "core.notingroup": "Sorry, but you need to be part of a group to see this page.", + "core.notsent": "Not sent", + "core.now": "now", + "core.nummore": "{{$a}} more", + "core.numwords": "{{$a}} words", "core.offline": "Offline", "core.ok": "OK", "core.online": "Online", + "core.openfile": "Open file", + "core.openfullimage": "Click here to display the full size image", + "core.openinbrowser": "Open in browser", + "core.openmodinbrowser": "Open {{$a}} in browser", + "core.othergroups": "Other groups", + "core.pagea": "Page {{$a}}", + "core.parentlanguage": "", + "core.paymentinstant": "Use the button below to pay and be enrolled within minutes!", + "core.percentagenumber": "{{$a}}%", + "core.phone": "Phone", + "core.pictureof": "Picture of {{$a}}", + "core.previous": "Previous", + "core.proceed": "Proceed", + "core.pulltorefresh": "Pull to refresh", + "core.qrscanner": "QR scanner", + "core.quotausage": "You have currently used {{$a.used}} of your {{$a.total}} limit.", + "core.redirectingtosite": "You will be redirected to the site.", + "core.refresh": "Refresh", + "core.remove": "Remove", + "core.removefiles": "Remove files {{$a}}", + "core.required": "Required", + "core.requireduserdatamissing": "This user lacks some required profile data. Please enter the data in your site and try again.
      {{$a}}", + "core.resourcedisplayopen": "Open", + "core.resources": "Resources", + "core.restore": "Restore", + "core.restricted": "Restricted", + "core.retry": "Retry", + "core.save": "Save", + "core.savechanges": "Save changes", + "core.scanqr": "Scan QR code", + "core.search": "Search", + "core.searching": "Searching", + "core.searchresults": "Search results", + "core.sec": "sec", + "core.secs": "secs", + "core.seemoredetail": "Click here to see more detail", + "core.selectacategory": "Please select a category", + "core.selectacourse": "Select a course", + "core.selectagroup": "Select a group", + "core.send": "Send", + "core.sending": "Sending", + "core.serverconnection": "Error connecting to the server", "core.settings.about": "About", "core.settings.appsettings": "App settings", "core.settings.appversion": "App version", @@ -548,7 +783,79 @@ "core.settings.syncsettings": "Synchronisation settings", "core.settings.total": "Total", "core.settings.wificonnection": "Wi-Fi connection", + "core.show": "Show", + "core.showless": "Show less...", + "core.showmore": "Show more...", + "core.site": "Site", + "core.sitemaintenance": "The site is undergoing maintenance and is currently not available", + "core.sizeb": "bytes", + "core.sizegb": "GB", + "core.sizekb": "KB", + "core.sizemb": "MB", + "core.sizetb": "TB", + "core.skip": "Skip", + "core.sorry": "Sorry...", + "core.sort": "Sort", + "core.sortby": "Sort by", + "core.start": "Start", + "core.storingfiles": "Storing files", + "core.strftimedate": "%d %B %Y", + "core.strftimedatefullshort": "%d/%m/%y", + "core.strftimedateshort": "%d %B", + "core.strftimedatetime": "%d %B %Y, %I:%M %p", + "core.strftimedatetimeshort": "%d/%m/%y, %H:%M", + "core.strftimedaydate": "%A, %d %B %Y", + "core.strftimedaydatetime": "%A, %d %B %Y, %I:%M %p", + "core.strftimedayshort": "%A, %d %B", + "core.strftimedaytime": "%a, %H:%M", + "core.strftimemonthyear": "%B %Y", + "core.strftimerecent": "%d %b, %H:%M", + "core.strftimerecentfull": "%a, %d %b %Y, %I:%M %p", + "core.strftimetime": "%I:%M %p", + "core.strftimetime12": "%I:%M %p", + "core.strftimetime24": "%H:%M", + "core.submit": "Submit", + "core.success": "Success", + "core.tablet": "Tablet", + "core.teachers": "Teachers", + "core.thereisdatatosync": "There are offline {{$a}} to be synchronised.", + "core.thisdirection": "ltr", + "core.time": "Time", + "core.timesup": "Time is up!", + "core.today": "Today", "core.tryagain": "Try again", + "core.twoparagraphs": "{{p1}}

      {{p2}}", + "core.uhoh": "Uh oh!", + "core.unexpectederror": "Unexpected error. Please close and reopen the application then try again.", + "core.unicodenotsupported": "Some emojis are not supported on this site. Such characters will be removed when the message is sent.", + "core.unicodenotsupportedcleanerror": "Empty text was found when cleaning Unicode chars.", "core.unknown": "Unknown", - "core.yes": "Yes" + "core.unlimited": "Unlimited", + "core.unzipping": "Unzipping", + "core.updaterequired": "App update required", + "core.updaterequireddesc": "Please update your app to version {{$a}}", + "core.upgraderunning": "Site is being upgraded, please retry later.", + "core.user": "User", + "core.userdeleted": "This user account has been deleted", + "core.userdetails": "User details", + "core.usernotfullysetup": "User not fully set-up", + "core.users": "Users", + "core.view": "View", + "core.viewcode": "View code", + "core.vieweditor": "View editor", + "core.viewembeddedcontent": "View embedded content", + "core.viewprofile": "View profile", + "core.warningofflinedatadeleted": "Offline data from {{component}} '{{name}}' has been deleted. {{error}}", + "core.whatisyourage": "What is your age?", + "core.wheredoyoulive": "In which country do you live?", + "core.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.", + "core.whoops": "Oops!", + "core.whyisthishappening": "Why is this happening?", + "core.whyisthisrequired": "Why is this required?", + "core.wsfunctionnotavailable": "The web service function is not available.", + "core.year": "year", + "core.years": "years", + "core.yes": "Yes", + "core.youreoffline": "You are offline", + "core.youreonline": "You are back online" } \ No newline at end of file diff --git a/src/theme/app.scss b/src/theme/app.scss index 6a8b5c921..f5c6320b9 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -70,3 +70,16 @@ ion-item-divider { ion-list.list-md { padding-bottom: 0; } + +// Modals. +.core-modal-fullscreen .modal-wrapper { + position: absolute; + // @todo @include position(0 !important, null, null, 0 !important); + display: block; + width: 100% !important; + height: 100% !important; +} + +.core-modal-force-on-top { + z-index: 100000 !important; +} From 996f1c6ae34eb08fe6606cc94664dc6d23f3cc11 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 27 Oct 2020 13:16:25 +0100 Subject: [PATCH 02/12] MOBILE-3565 login: Implement forgotten password page --- src/app/core/login/login-routing.module.ts | 5 + .../pages/credentials/credentials.page.ts | 7 +- .../forgotten-password.html | 41 ++++++ .../forgotten-password.module.ts | 47 +++++++ .../forgotten-password.page.ts | 120 ++++++++++++++++++ src/app/core/login/services/helper.ts | 10 +- 6 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 src/app/core/login/pages/forgotten-password/forgotten-password.html create mode 100644 src/app/core/login/pages/forgotten-password/forgotten-password.module.ts create mode 100644 src/app/core/login/pages/forgotten-password/forgotten-password.page.ts diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 384546ba8..393e2f432 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -37,6 +37,11 @@ const routes: Routes = [ path: 'sites', loadChildren: () => import('./pages/sites/sites.page.module').then( m => m.CoreLoginSitesPageModule), }, + { + path: 'forgottenpassword', + loadChildren: () => import('./pages/forgotten-password/forgotten-password.module') + .then( m => m.CoreLoginForgottenPasswordPageModule), + }, ]; @NgModule({ diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index 8ff4ad4e2..c1a88c838 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -264,12 +264,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { * Forgotten password button clicked. */ forgottenPassword(): void { - CoreLoginHelper.instance.forgottenPasswordClicked( - this.navCtrl, - this.siteUrl, - this.credForm.value.username, - this.siteConfig, - ); + CoreLoginHelper.instance.forgottenPasswordClicked(this.siteUrl, this.credForm.value.username, this.siteConfig); } /** diff --git a/src/app/core/login/pages/forgotten-password/forgotten-password.html b/src/app/core/login/pages/forgotten-password/forgotten-password.html new file mode 100644 index 000000000..0806d2b59 --- /dev/null +++ b/src/app/core/login/pages/forgotten-password/forgotten-password.html @@ -0,0 +1,41 @@ + + + + + + + {{ 'core.login.passwordforgotten' | translate }} + + + + + + {{ 'core.login.passwordforgotteninstructions2' | translate }} + + + +
      + + {{ 'core.login.searchby' | translate }} + + + + {{ 'core.login.username' | translate }} + + + + {{ 'core.user.email' | translate }} + + + + + + + + + {{ 'core.courses.search' | translate }} + +
      +
      +
      diff --git a/src/app/core/login/pages/forgotten-password/forgotten-password.module.ts b/src/app/core/login/pages/forgotten-password/forgotten-password.module.ts new file mode 100644 index 000000000..360ab60b5 --- /dev/null +++ b/src/app/core/login/pages/forgotten-password/forgotten-password.module.ts @@ -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. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreLoginForgottenPasswordPage } from './forgotten-password.page'; + +const routes: Routes = [ + { + path: '', + component: CoreLoginForgottenPasswordPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreLoginForgottenPasswordPage, + ], + exports: [RouterModule], +}) +export class CoreLoginForgottenPasswordPageModule {} diff --git a/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts b/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts new file mode 100644 index 000000000..8e0c357a0 --- /dev/null +++ b/src/app/core/login/pages/forgotten-password/forgotten-password.page.ts @@ -0,0 +1,120 @@ +// (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, ViewChild, ElementRef, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NavController } from '@ionic/angular'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { Translate, Platform } from '@singletons/core.singletons'; +import { CoreWSExternalWarning } from '@services/ws'; + +/** + * Page to recover a forgotten password. + */ +@Component({ + selector: 'page-core-login-forgotten-password', + templateUrl: 'forgotten-password.html', +}) +export class CoreLoginForgottenPasswordPage implements OnInit { + + @ViewChild('resetPasswordForm') formElement?: ElementRef; + + myForm!: FormGroup; + siteUrl!: string; + autoFocus!: boolean; + + constructor( + protected navCtrl: NavController, + protected formBuilder: FormBuilder, + protected route: ActivatedRoute, + ) { + } + + /** + * Initialize the component. + */ + ngOnInit(): void { + const params = this.route.snapshot.queryParams; + + this.siteUrl = params['siteUrl']; + this.autoFocus = Platform.instance.is('tablet'); + this.myForm = this.formBuilder.group({ + field: ['username', Validators.required], + value: [params['username'] || '', Validators.required], + }); + } + + /** + * Request to reset the password. + * + * @param e Event. + */ + async resetPassword(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + const field = this.myForm.value.field; + const value = this.myForm.value.value; + + if (!value) { + CoreDomUtils.instance.showErrorModal('core.login.usernameoremail', true); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + const isMail = field == 'email'; + + try { + const response = await CoreLoginHelper.instance.requestPasswordReset( + this.siteUrl, + isMail ? '' : value, + isMail ? value : '', + ); + + if (response.status == 'dataerror') { + // Error in the data sent. + this.showError(isMail, response.warnings!); + } else if (response.status == 'emailpasswordconfirmnotsent' || response.status == 'emailpasswordconfirmnoemail') { + // Error, not found. + CoreDomUtils.instance.showErrorModal(response.notice); + } else { + // Success. + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); + + CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), response.notice); + this.navCtrl.pop(); + } + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } finally { + modal.dismiss(); + } + } + + // Show an error from the warnings. + protected showError(isMail: boolean, warnings: CoreWSExternalWarning[]): void { + for (let i = 0; i < warnings.length; i++) { + const warning = warnings[i]; + if ((warning.item == 'email' && isMail) || (warning.item == 'username' && !isMail)) { + CoreDomUtils.instance.showErrorModal(warning.message); + break; + } + } + } + +} diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index 1d48d43fd..5bc26dad5 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -163,12 +163,7 @@ export class CoreLoginHelperProvider { * @param username Username. * @param siteConfig Site config. */ - async forgottenPasswordClicked( - navCtrl: NavController, - siteUrl: string, - username: string, - siteConfig?: CoreSitePublicConfigResponse, - ): Promise { + async forgottenPasswordClicked(siteUrl: string, username: string, siteConfig?: CoreSitePublicConfigResponse): Promise { if (siteConfig && siteConfig.forgottenpasswordurl) { // URL set, open it. CoreUtils.instance.openInApp(siteConfig.forgottenpasswordurl); @@ -183,7 +178,7 @@ export class CoreLoginHelperProvider { const canReset = await this.canRequestPasswordReset(siteUrl); if (canReset) { - await navCtrl.navigateForward(['/login/forgottenpassword'], { + await this.navCtrl.navigateForward(['/login/forgottenpassword'], { queryParams: { siteUrl, username, @@ -445,7 +440,6 @@ export class CoreLoginHelperProvider { /** * Open a page that doesn't belong to any site. * - * @param navCtrl Nav Controller. * @param page Page to open. * @param params Params of the page. * @return Promise resolved when done. From 7e2436e7ca4ca1906d871b74288b9bdc120a88de Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 27 Oct 2020 15:05:11 +0100 Subject: [PATCH 03/12] MOBILE-3565 login: Fix some styles of credentials page --- src/app/core/login/login.scss | 4 ++ .../login/pages/credentials/credentials.html | 62 ++++++++++--------- .../pages/credentials/credentials.page.ts | 1 + src/app/core/login/pages/site/site.html | 22 +++---- src/app/core/login/pages/sites/sites.html | 2 +- src/app/core/mainmenu/services/mainmenu.ts | 2 +- src/theme/app.scss | 7 +++ 7 files changed, 56 insertions(+), 44 deletions(-) diff --git a/src/app/core/login/login.scss b/src/app/core/login/login.scss index 7bf780260..6afeeed42 100644 --- a/src/app/core/login/login.scss +++ b/src/app/core/login/login.scss @@ -21,4 +21,8 @@ max-width: 300px; margin: 5px auto; } + + .core-login-forgotten-password { + text-decoration: underline; + } } diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html index 35661eec5..b0c745f51 100644 --- a/src/app/core/login/pages/credentials/credentials.html +++ b/src/app/core/login/pages/credentials/credentials.html @@ -14,16 +14,16 @@ - + -
      +
      -

      +

      {{siteUrl}}

      @@ -31,48 +31,50 @@ - - + + -
      - - -
      + + - - +
      {{ 'core.login.or' | translate }}
      + + + {{ 'core.scanqr' | translate }} +
      - -
      - + @@ -88,26 +88,24 @@ - - +
      {{ 'core.login.or' | translate }}
      + + + {{ 'core.scanqr' | translate }} +
      -
      - - + + diff --git a/src/app/core/login/pages/sites/sites.html b/src/app/core/login/pages/sites/sites.html index 8f0cab8f1..77a9db11f 100644 --- a/src/app/core/login/pages/sites/sites.html +++ b/src/app/core/login/pages/sites/sites.html @@ -19,7 +19,7 @@ - + {{ 'core.pictureof' | translate:{$a: site.fullName} }} diff --git a/src/app/core/mainmenu/services/mainmenu.ts b/src/app/core/mainmenu/services/mainmenu.ts index 2385272d1..f54076df6 100644 --- a/src/app/core/mainmenu/services/mainmenu.ts +++ b/src/app/core/mainmenu/services/mainmenu.ts @@ -98,7 +98,7 @@ export class CoreMainMenuProvider { const id = url + '#' + type; if (!icon) { // Icon not defined, use default one. - icon = type == 'embedded' ? 'fa-square-o' : 'fa-link'; // @todo: Find a better icon for embedded. + icon = type == 'embedded' ? 'fa-expand' : 'fa-link'; // @todo: Find a better icon for embedded. } if (!map[id]) { diff --git a/src/theme/app.scss b/src/theme/app.scss index f5c6320b9..ec4d333c8 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -83,3 +83,10 @@ ion-list.list-md { .core-modal-force-on-top { z-index: 100000 !important; } + +// Hidden submit button. +.core-submit-hidden-enter { + position: absolute; + visibility: hidden; + left: -1000px; +} From 03f9723329b6c3dfdf2d0a01108c83655d7b0ab0 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 28 Oct 2020 08:10:40 +0100 Subject: [PATCH 04/12] MOBILE-3565 login: Implement change password page --- src/app/core/login/login-routing.module.ts | 5 ++ .../change-password/change-password.html | 48 ++++++++++++ .../change-password/change-password.module.ts | 44 +++++++++++ .../change-password/change-password.page.ts | 77 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 src/app/core/login/pages/change-password/change-password.html create mode 100644 src/app/core/login/pages/change-password/change-password.module.ts create mode 100644 src/app/core/login/pages/change-password/change-password.page.ts diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 393e2f432..0263baf61 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -42,6 +42,11 @@ const routes: Routes = [ loadChildren: () => import('./pages/forgotten-password/forgotten-password.module') .then( m => m.CoreLoginForgottenPasswordPageModule), }, + { + path: 'changepassword', + loadChildren: () => import('./pages/change-password/change-password.module') + .then( m => m.CoreLoginChangePasswordPageModule), + }, ]; @NgModule({ diff --git a/src/app/core/login/pages/change-password/change-password.html b/src/app/core/login/pages/change-password/change-password.html new file mode 100644 index 000000000..f40dde4be --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.html @@ -0,0 +1,48 @@ + + + + + + + {{ 'core.login.changepassword' | translate }} + + + + + + + + + + + + + +

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

      +

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

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

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

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

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

      +
      +
      + + {{ logoutLabel | translate }} + +
      +
      diff --git a/src/app/core/login/pages/change-password/change-password.module.ts b/src/app/core/login/pages/change-password/change-password.module.ts new file mode 100644 index 000000000..f983cadd2 --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.module.ts @@ -0,0 +1,44 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreLoginChangePasswordPage } from './change-password.page'; + +const routes: Routes = [ + { + path: '', + component: CoreLoginChangePasswordPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule, + ], + declarations: [ + CoreLoginChangePasswordPage, + ], + exports: [RouterModule], +}) +export class CoreLoginChangePasswordPageModule {} diff --git a/src/app/core/login/pages/change-password/change-password.page.ts b/src/app/core/login/pages/change-password/change-password.page.ts new file mode 100644 index 000000000..b3ae64d02 --- /dev/null +++ b/src/app/core/login/pages/change-password/change-password.page.ts @@ -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 { Component } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Page that shows instructions to change the password. + */ +@Component({ + selector: 'page-core-login-change-password', + templateUrl: 'change-password.html', +}) +export class CoreLoginChangePasswordPage { + + changingPassword = false; + logoutLabel: string; + + constructor() { + this.logoutLabel = CoreLoginHelper.instance.getLogoutLabel(); + } + + /** + * Show a help modal. + */ + showHelp(): void { + CoreDomUtils.instance.showAlert( + Translate.instance.instant('core.help'), + Translate.instance.instant('core.login.changepasswordhelp'), + ); + } + + /** + * Open the change password page in a browser. + */ + openChangePasswordPage(): void { + CoreLoginHelper.instance.openInAppForEdit( + CoreSites.instance.getCurrentSiteId(), + '/login/change_password.php', + undefined, + true, + ); + this.changingPassword = true; + } + + /** + * Login the user. + */ + login(): void { + CoreLoginHelper.instance.goToSiteInitialPage(); + this.changingPassword = false; + } + + /** + * Logout the user. + */ + logout(): void { + CoreSites.instance.logout(); + this.changingPassword = false; + } + +} From 6df1c2109de9ec37465f7640303f1ffc74ac06a2 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 28 Oct 2020 14:25:18 +0100 Subject: [PATCH 05/12] MOBILE-3565 services: Move some DB vars and init to new files --- src/app/app.module.ts | 16 + src/app/classes/site.ts | 2 +- src/app/services/app.db.ts | 41 ++ src/app/services/app.ts | 29 +- src/app/services/config.db.ts | 47 +++ src/app/services/config.ts | 40 +- src/app/services/cron.db.ts | 45 +++ src/app/services/cron.ts | 38 +- src/app/services/filepool.db.ts | 367 ++++++++++++++++++ src/app/services/filepool.ts | 427 +++------------------ src/app/services/local-notifications.db.ts | 80 ++++ src/app/services/local-notifications.ts | 91 +---- src/app/services/sites.db.ts | 233 +++++++++++ src/app/services/sites.ts | 355 +++-------------- src/app/services/sync.db.ts | 62 +++ src/app/services/sync.ts | 18 +- 16 files changed, 1034 insertions(+), 857 deletions(-) create mode 100644 src/app/services/app.db.ts create mode 100644 src/app/services/config.db.ts create mode 100644 src/app/services/cron.db.ts create mode 100644 src/app/services/filepool.db.ts create mode 100644 src/app/services/local-notifications.db.ts create mode 100644 src/app/services/sites.db.ts create mode 100644 src/app/services/sync.db.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 16208a998..daf0e9600 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -50,6 +50,11 @@ import { CoreTimeUtilsProvider } from '@services/utils/time'; import { CoreUrlUtilsProvider } from '@services/utils/url'; import { CoreUtilsProvider } from '@services/utils/utils'; +// Import init DB functions of core services. +import { initCoreFilepoolDB } from '@services/filepool.db'; +import { initCoreSitesDB } from '@services/sites.db'; +import { initCoreSyncDB } from '@services/sync.db'; + // Import core modules. import { CoreEmulatorModule } from '@core/emulator/emulator.module'; import { CoreLoginModule } from '@core/login/login.module'; @@ -121,6 +126,8 @@ export class AppModule { // Set the injector. setSingletonsInjector(injector); + this.initCoreServicesDB(); + // Register a handler for platform ready. CoreInit.instance.registerProcess({ name: 'CorePlatformReady', @@ -154,4 +161,13 @@ export class AppModule { CoreInit.instance.executeInitProcesses(); } + /** + * Init the DB of core services. + */ + protected initCoreServicesDB(): void { + initCoreFilepoolDB(); + initCoreSitesDB(); + initCoreSyncDB(); + } + } diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts index 53e66669e..539b3259d 100644 --- a/src/app/classes/site.ts +++ b/src/app/classes/site.ts @@ -36,7 +36,7 @@ import { CoreIonLoadingElement } from './ion-loading'; /** * Class that represents a site (combination of site + user). * It will have all the site data and provide utility functions regarding a site. - * To add tables to the site's database, please use CoreSitesProvider.registerSiteSchema. This will make sure that + * To add tables to the site's database, please use registerSiteSchema exported in @services/sites.ts. This will make sure that * the tables are created in all the sites, not just the current one. * * @todo: Refactor this class to improve "temporary" sites support (not fully authenticated). diff --git a/src/app/services/app.db.ts b/src/app/services/app.db.ts new file mode 100644 index 000000000..6621bafc6 --- /dev/null +++ b/src/app/services/app.db.ts @@ -0,0 +1,41 @@ +// (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 { SQLiteDBTableSchema } from '@classes/sqlitedb'; + +/** + * Database variables for CoreApp service. + */ +export const DBNAME = 'MoodleMobile'; +export const SCHEMA_VERSIONS_TABLE_NAME = 'schema_versions'; + +export const SCHEMA_VERSIONS_TABLE_SCHEMA: SQLiteDBTableSchema = { + name: SCHEMA_VERSIONS_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER', + }, + ], +}; + +export type SchemaVersionsDBEntry = { + name: string; + version: number; +}; diff --git a/src/app/services/app.ts b/src/app/services/app.ts index a22293047..db708eef2 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -24,9 +24,7 @@ import { CoreConstants } from '@core/constants'; import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; - -const DBNAME = 'MoodleMobile'; -const SCHEMA_VERSIONS_TABLE = 'schema_versions'; +import { DBNAME, SCHEMA_VERSIONS_TABLE_NAME, SCHEMA_VERSIONS_TABLE_SCHEMA, SchemaVersionsDBEntry } from '@services/app.db'; /** * Factory to provide some global functionalities, like access to the global app database. @@ -57,27 +55,13 @@ export class CoreAppProvider { // Variables for DB. protected createVersionsTableReady: Promise; - protected versionsTableSchema: SQLiteDBTableSchema = { - name: SCHEMA_VERSIONS_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'version', - type: 'INTEGER', - }, - ], - }; constructor(appRef: ApplicationRef, zone: NgZone) { this.logger = CoreLogger.getInstance('CoreAppProvider'); this.db = CoreDB.instance.getDB(DBNAME); // Create the schema versions table. - this.createVersionsTableReady = this.db.createTableFromSchema(this.versionsTableSchema); + this.createVersionsTableReady = this.db.createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA); Keyboard.instance.onKeyboardShow().subscribe((data) => { // Execute the callback in the Angular zone, so change detection doesn't stop working. @@ -175,7 +159,7 @@ export class CoreAppProvider { await this.createVersionsTableReady; // Fetch installed version of the schema. - const entry = await this.db.getRecord(SCHEMA_VERSIONS_TABLE, { name: schema.name }); + const entry = await this.db.getRecord(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name }); oldVersion = entry.version; } catch (error) { @@ -198,7 +182,7 @@ export class CoreAppProvider { } // Set installed version. - await this.db.insertRecord(SCHEMA_VERSIONS_TABLE, { name: schema.name, version: schema.version }); + await this.db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name, version: schema.version }); } /** @@ -741,8 +725,3 @@ export type WindowForAutomatedTests = Window & { appProvider?: CoreAppProvider; appRef?: ApplicationRef; }; - -type SchemaVersionsDBEntry = { - name: string; - version: number; -}; diff --git a/src/app/services/config.db.ts b/src/app/services/config.db.ts new file mode 100644 index 000000000..b441dc78e --- /dev/null +++ b/src/app/services/config.db.ts @@ -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. + +import { CoreAppSchema } from '@services/app'; + +/** + * Database variables for for CoreConfig service. + */ +export const CONFIG_TABLE_NAME = 'core_config'; + +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreConfigProvider', + version: 1, + tables: [ + { + name: CONFIG_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + }, + ], + }, + ], +}; + +export type ConfigDBEntry = { + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +}; diff --git a/src/app/services/config.ts b/src/app/services/config.ts index 5fb874158..9cdf15782 100644 --- a/src/app/services/config.ts +++ b/src/app/services/config.ts @@ -14,11 +14,10 @@ import { Injectable } from '@angular/core'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { SQLiteDB } from '@classes/sqlitedb'; import { makeSingleton } from '@singletons/core.singletons'; - -const TABLE_NAME = 'core_config'; +import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/config.db'; /** * Factory to provide access to dynamic and permanent config and settings. @@ -28,32 +27,11 @@ const TABLE_NAME = 'core_config'; export class CoreConfigProvider { protected appDB: SQLiteDB; - protected tableSchema: CoreAppSchema = { - name: 'CoreConfigProvider', - version: 1, - tables: [ - { - name: TABLE_NAME, - columns: [ - { - name: 'name', - type: 'TEXT', - unique: true, - notNull: true, - }, - { - name: 'value', - }, - ], - }, - ], - }; - protected dbReady: Promise; // Promise resolved when the app DB is initialized. constructor() { this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); } @@ -67,7 +45,7 @@ export class CoreConfigProvider { async delete(name: string): Promise { await this.dbReady; - await this.appDB.deleteRecords(TABLE_NAME, { name }); + await this.appDB.deleteRecords(CONFIG_TABLE_NAME, { name }); } /** @@ -81,7 +59,7 @@ export class CoreConfigProvider { await this.dbReady; try { - const entry = await this.appDB.getRecord(TABLE_NAME, { name }); + const entry = await this.appDB.getRecord(CONFIG_TABLE_NAME, { name }); return entry.value; } catch (error) { @@ -103,15 +81,9 @@ export class CoreConfigProvider { async set(name: string, value: number | string): Promise { await this.dbReady; - await this.appDB.insertRecord(TABLE_NAME, { name, value }); + await this.appDB.insertRecord(CONFIG_TABLE_NAME, { name, value }); } } export class CoreConfig extends makeSingleton(CoreConfigProvider) {} - -type ConfigDBEntry = { - name: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - value: any; -}; diff --git a/src/app/services/cron.db.ts b/src/app/services/cron.db.ts new file mode 100644 index 000000000..d96d2aba1 --- /dev/null +++ b/src/app/services/cron.db.ts @@ -0,0 +1,45 @@ +// (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 { CoreAppSchema } from '@services/app'; + +/** + * Database variables for CoreCron service. + */ +export const CRON_TABLE_NAME = 'cron'; +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreCronDelegate', + version: 1, + tables: [ + { + name: CRON_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'value', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type CronDBEntry = { + id: string; + value: number; +}; diff --git a/src/app/services/cron.ts b/src/app/services/cron.ts index fd13576ab..f542bfcbc 100644 --- a/src/app/services/cron.ts +++ b/src/app/services/cron.ts @@ -14,7 +14,7 @@ import { Injectable, NgZone } from '@angular/core'; -import { CoreApp, CoreAppProvider, CoreAppSchema } from '@services/app'; +import { CoreApp, CoreAppProvider } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreUtils } from '@services/utils/utils'; import { CoreConstants } from '@core/constants'; @@ -23,8 +23,7 @@ import { CoreError } from '@classes/errors/error'; import { makeSingleton, Network } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; - -const CRON_TABLE = 'cron'; +import { APP_SCHEMA, CRON_TABLE_NAME, CronDBEntry } from '@services/cron.db'; /* * Service to handle cron processes. The registered processes will be executed every certain time. @@ -37,28 +36,6 @@ export class CoreCronDelegate { static readonly MIN_INTERVAL = 300000; // Minimum interval is 5 minutes. static readonly MAX_TIME_PROCESS = 120000; // Max time a process can block the queue. Defaults to 2 minutes. - // Variables for database. - protected tableSchema: CoreAppSchema = { - name: 'CoreCronDelegate', - version: 1, - tables: [ - { - name: CRON_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'value', - type: 'INTEGER', - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -69,7 +46,7 @@ export class CoreCronDelegate { this.logger = CoreLogger.getInstance('CoreCronDelegate'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); @@ -268,7 +245,7 @@ export class CoreCronDelegate { const id = this.getHandlerLastExecutionId(name); try { - const entry = await this.appDB.getRecord(CRON_TABLE, { id }); + const entry = await this.appDB.getRecord(CRON_TABLE_NAME, { id }); const time = Number(entry.value); @@ -431,7 +408,7 @@ export class CoreCronDelegate { value: time, }; - await this.appDB.insertRecord(CRON_TABLE, entry); + await this.appDB.insertRecord(CRON_TABLE_NAME, entry); } /** @@ -562,8 +539,3 @@ export interface CoreCronHandler { export type WindowForAutomatedTests = Window & { cronProvider?: CoreCronDelegate; }; - -type CronDBEntry = { - id: string; - value: number; -}; diff --git a/src/app/services/filepool.db.ts b/src/app/services/filepool.db.ts new file mode 100644 index 000000000..42c8a15b0 --- /dev/null +++ b/src/app/services/filepool.db.ts @@ -0,0 +1,367 @@ +// (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 { CoreAppSchema } from '@services/app'; +import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreFilepool service. + */ +export const QUEUE_TABLE_NAME = 'filepool_files_queue'; // Queue of files to download. +export const FILES_TABLE_NAME = 'filepool_files'; // Downloaded files. +export const LINKS_TABLE_NAME = 'filepool_files_links'; // Links between downloaded files and components. +export const PACKAGES_TABLE_NAME = 'filepool_packages'; // Downloaded packages (sets of files). +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: QUEUE_TABLE_NAME, + columns: [ + { + name: 'siteId', + type: 'TEXT', + }, + { + name: 'fileId', + type: 'TEXT', + }, + { + name: 'added', + type: 'INTEGER', + }, + { + name: 'priority', + type: 'INTEGER', + }, + { + name: 'url', + type: 'TEXT', + }, + { + name: 'revision', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'isexternalfile', + type: 'INTEGER', + }, + { + name: 'repositorytype', + type: 'TEXT', + }, + { + name: 'path', + type: 'TEXT', + }, + { + name: 'links', + type: 'TEXT', + }, + ], + primaryKeys: ['siteId', 'fileId'], + }, + ], +}; + +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: FILES_TABLE_NAME, + columns: [ + { + name: 'fileId', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'url', + type: 'TEXT', + notNull: true, + }, + { + name: 'revision', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'stale', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'isexternalfile', + type: 'INTEGER', + }, + { + name: 'repositorytype', + type: 'TEXT', + }, + { + name: 'path', + type: 'TEXT', + }, + { + name: 'extension', + type: 'TEXT', + }, + ], + }, + { + name: LINKS_TABLE_NAME, + columns: [ + { + name: 'fileId', + type: 'TEXT', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'TEXT', + }, + ], + primaryKeys: ['fileId', 'component', 'componentId'], + }, + { + name: PACKAGES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'TEXT', + }, + { + name: 'status', + type: 'TEXT', + }, + { + name: 'previous', + type: 'TEXT', + }, + { + name: 'updated', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'previousDownloadTime', + type: 'INTEGER', + }, + { + name: 'extra', + type: 'TEXT', + }, + ], + }, + ], +}; + +/** + * File options. + */ +export type CoreFilepoolFileOptions = { + revision?: number; // File's revision. + timemodified?: number; // File's timemodified. + isexternalfile?: number; // 1 if it's a external file (from an external repository), 0 otherwise. + repositorytype?: string; // Type of the repository this file belongs to. +}; + +/** + * Entry from filepool. + */ +export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & { + /** + * The fileId to identify the file. + */ + fileId: string; + + /** + * File's URL. + */ + url: string; + + /** + * 1 if file is stale (needs to be updated), 0 otherwise. + */ + stale: number; + + /** + * Timestamp when this file was downloaded. + */ + downloadTime: number; + + /** + * File's path. + */ + path: string; + + /** + * File's extension. + */ + extension: string; +}; + +/** + * DB data for entry from file's queue. + */ +export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { + /** + * The site the file belongs to. + */ + siteId: string; + + /** + * The fileId to identify the file. + */ + fileId: string; + + /** + * Timestamp when the file was added to the queue. + */ + added: number; + + /** + * The priority of the file. + */ + priority: number; + + /** + * File's URL. + */ + url: string; + + /** + * File's path. + */ + path?: string; + + /** + * File links (to link the file to components and componentIds). Serialized to store on DB. + */ + links: string; +}; + +/** + * Entry from the file's queue. + */ +export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBEntry & { + /** + * File links (to link the file to components and componentIds). + */ + linksUnserialized?: CoreFilepoolComponentLink[]; +}; + +/** + * Entry from packages table. + */ +export type CoreFilepoolPackageEntry = { + /** + * Package id. + */ + id?: string; + + /** + * The component to link the files to. + */ + component?: string; + + /** + * An ID to use in conjunction with the component. + */ + componentId?: string | number; + + /** + * Package status. + */ + status?: string; + + /** + * Package previous status. + */ + previous?: string; + + /** + * Timestamp when this package was updated. + */ + updated?: number; + + /** + * Timestamp when this package was downloaded. + */ + downloadTime?: number; + + /** + * Previous download time. + */ + previousDownloadTime?: number; + + /** + * Extra data stored by the package. + */ + extra?: string; +}; + +/** + * A component link. + */ +export type CoreFilepoolComponentLink = { + /** + * Link's component. + */ + component: string; + + /** + * Link's componentId. + */ + componentId?: string | number; +}; + +/** + * Links table record type. + */ +export type CoreFilepoolLinksRecord = { + fileId: string; // File Id. + component: string; // Component name. + componentId: number | string; // Component Id. +}; + +export const initCoreFilepoolDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; diff --git a/src/app/services/filepool.ts b/src/app/services/filepool.ts index 6baddc81c..b9bdbed64 100644 --- a/src/app/services/filepool.ts +++ b/src/app/services/filepool.ts @@ -15,12 +15,12 @@ import { Injectable } from '@angular/core'; import { Md5 } from 'ts-md5/dist/md5'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreInit } from '@services/init'; import { CorePluginFile } from '@services/plugin-file-delegate'; -import { CoreSites, CoreSiteSchema } from '@services/sites'; +import { CoreSites } from '@services/sites'; import { CoreWS, CoreWSExternalFile } from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; @@ -33,6 +33,20 @@ import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; import { makeSingleton, Network, NgZone, Translate } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + FILES_TABLE_NAME, + QUEUE_TABLE_NAME, + PACKAGES_TABLE_NAME, + LINKS_TABLE_NAME, + CoreFilepoolFileEntry, + CoreFilepoolComponentLink, + CoreFilepoolFileOptions, + CoreFilepoolLinksRecord, + CoreFilepoolPackageEntry, + CoreFilepoolQueueEntry, + CoreFilepoolQueueDBEntry, +} from '@services/filepool.db'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -60,182 +74,6 @@ export class CoreFilepoolProvider { protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = 'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; - // Variables for database. - protected static readonly QUEUE_TABLE = 'filepool_files_queue'; // Queue of files to download. - protected static readonly FILES_TABLE = 'filepool_files'; // Downloaded files. - protected static readonly LINKS_TABLE = 'filepool_files_links'; // Links between downloaded files and components. - protected static readonly PACKAGES_TABLE = 'filepool_packages'; // Downloaded packages (sets of files). - protected appTablesSchema: CoreAppSchema = { - name: 'CoreFilepoolProvider', - version: 1, - tables: [ - { - name: CoreFilepoolProvider.QUEUE_TABLE, - columns: [ - { - name: 'siteId', - type: 'TEXT', - }, - { - name: 'fileId', - type: 'TEXT', - }, - { - name: 'added', - type: 'INTEGER', - }, - { - name: 'priority', - type: 'INTEGER', - }, - { - name: 'url', - type: 'TEXT', - }, - { - name: 'revision', - type: 'INTEGER', - }, - { - name: 'timemodified', - type: 'INTEGER', - }, - { - name: 'isexternalfile', - type: 'INTEGER', - }, - { - name: 'repositorytype', - type: 'TEXT', - }, - { - name: 'path', - type: 'TEXT', - }, - { - name: 'links', - type: 'TEXT', - }, - ], - primaryKeys: ['siteId', 'fileId'], - }, - ], - }; - - protected siteSchema: CoreSiteSchema = { - name: 'CoreFilepoolProvider', - version: 1, - tables: [ - { - name: CoreFilepoolProvider.FILES_TABLE, - columns: [ - { - name: 'fileId', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'url', - type: 'TEXT', - notNull: true, - }, - { - name: 'revision', - type: 'INTEGER', - }, - { - name: 'timemodified', - type: 'INTEGER', - }, - { - name: 'stale', - type: 'INTEGER', - }, - { - name: 'downloadTime', - type: 'INTEGER', - }, - { - name: 'isexternalfile', - type: 'INTEGER', - }, - { - name: 'repositorytype', - type: 'TEXT', - }, - { - name: 'path', - type: 'TEXT', - }, - { - name: 'extension', - type: 'TEXT', - }, - ], - }, - { - name: CoreFilepoolProvider.LINKS_TABLE, - columns: [ - { - name: 'fileId', - type: 'TEXT', - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'TEXT', - }, - ], - primaryKeys: ['fileId', 'component', 'componentId'], - }, - { - name: CoreFilepoolProvider.PACKAGES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'TEXT', - }, - { - name: 'status', - type: 'TEXT', - }, - { - name: 'previous', - type: 'TEXT', - }, - { - name: 'updated', - type: 'INTEGER', - }, - { - name: 'downloadTime', - type: 'INTEGER', - }, - { - name: 'previousDownloadTime', - type: 'INTEGER', - }, - { - name: 'extra', - type: 'TEXT', - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -258,12 +96,10 @@ export class CoreFilepoolProvider { this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); - CoreSites.instance.registerSiteSchema(this.siteSchema); - this.init(); } @@ -308,7 +144,7 @@ export class CoreFilepoolProvider { componentId: componentId || '', }; - await db.insertRecord(CoreFilepoolProvider.LINKS_TABLE, newEntry); + await db.insertRecord(LINKS_TABLE_NAME, newEntry); } /** @@ -373,7 +209,7 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); - await db.insertRecord(CoreFilepoolProvider.FILES_TABLE, record); + await db.insertRecord(FILES_TABLE_NAME, record); } /** @@ -433,7 +269,7 @@ export class CoreFilepoolProvider { this.logger.debug(`Adding ${fileId} to the queue`); - await this.appDB.insertRecord(CoreFilepoolProvider.QUEUE_TABLE, { + await this.appDB.insertRecord(QUEUE_TABLE_NAME, { siteId, fileId, url, @@ -563,7 +399,7 @@ export class CoreFilepoolProvider { // Update only when required. this.logger.debug(`Updating file ${fileId} which is already in queue`); - return this.appDB.updateRecords(CoreFilepoolProvider.QUEUE_TABLE, newData, primaryKey).then(() => + return this.appDB.updateRecords(QUEUE_TABLE_NAME, newData, primaryKey).then(() => this.getQueuePromise(siteId, fileId, true, onProgress)); } @@ -692,9 +528,9 @@ export class CoreFilepoolProvider { const site = await CoreSites.instance.getSite(siteId); // Get all the packages to be able to "notify" the change in the status. - const entries: CoreFilepoolPackageEntry[] = await site.getDb().getAllRecords(CoreFilepoolProvider.PACKAGES_TABLE); + const entries: CoreFilepoolPackageEntry[] = await site.getDb().getAllRecords(PACKAGES_TABLE_NAME); // Delete all the entries. - await site.getDb().deleteRecords(CoreFilepoolProvider.PACKAGES_TABLE); + await site.getDb().deleteRecords(PACKAGES_TABLE_NAME); entries.forEach((entry) => { // Trigger module status changed, setting it as not downloaded. @@ -712,8 +548,8 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); await Promise.all([ - db.deleteRecords(CoreFilepoolProvider.FILES_TABLE), - db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE), + db.deleteRecords(FILES_TABLE_NAME), + db.deleteRecords(LINKS_TABLE_NAME), ]); } @@ -732,7 +568,7 @@ export class CoreFilepoolProvider { componentId: this.fixComponentId(componentId), }; - const count = await db.countRecords(CoreFilepoolProvider.LINKS_TABLE, conditions); + const count = await db.countRecords(LINKS_TABLE_NAME, conditions); if (count <= 0) { throw new CoreError('Component doesn\'t have files'); } @@ -1257,7 +1093,7 @@ export class CoreFilepoolProvider { // Minor problem: file will remain in the filesystem once downloaded again. this.logger.debug('Staled file with no extension ' + entry.fileId); - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, { fileId: entry.fileId }); + await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId: entry.fileId }); return; } @@ -1267,7 +1103,7 @@ export class CoreFilepoolProvider { entry.fileId = CoreMimetypeUtils.instance.removeExtension(fileId); entry.extension = extension; - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, entry, { fileId }); + await db.updateRecords(FILES_TABLE_NAME, entry, { fileId }); if (entry.fileId == fileId) { // File ID hasn't changed, we're done. this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); @@ -1276,7 +1112,7 @@ export class CoreFilepoolProvider { } // Now update the links. - await db.updateRecords(CoreFilepoolProvider.LINKS_TABLE, { fileId: entry.fileId }, { fileId }); + await db.updateRecords(LINKS_TABLE_NAME, { fileId: entry.fileId }, { fileId }); } /** @@ -1339,7 +1175,7 @@ export class CoreFilepoolProvider { componentId: this.fixComponentId(componentId), }; - const items = await db.getRecords(CoreFilepoolProvider.LINKS_TABLE, conditions); + const items = await db.getRecords(LINKS_TABLE_NAME, conditions); items.forEach((item) => { item.componentId = this.fixComponentId(item.componentId); }); @@ -1449,7 +1285,7 @@ export class CoreFilepoolProvider { */ protected async getFileLinks(siteId: string, fileId: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); - const items = await db.getRecords(CoreFilepoolProvider.LINKS_TABLE, { fileId }); + const items = await db.getRecords(LINKS_TABLE_NAME, { fileId }); items.forEach((item) => { item.componentId = this.fixComponentId(item.componentId); @@ -1527,7 +1363,7 @@ export class CoreFilepoolProvider { await Promise.all(items.map(async (item) => { try { const fileEntry = await db.getRecord( - CoreFilepoolProvider.FILES_TABLE, + FILES_TABLE_NAME, { fileId: item.fileId }, ); @@ -1808,7 +1644,7 @@ export class CoreFilepoolProvider { const site = await CoreSites.instance.getSite(siteId); const packageId = this.getPackageId(component, componentId); - return site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + return site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); } /** @@ -2258,7 +2094,7 @@ export class CoreFilepoolProvider { */ protected async hasFileInPool(siteId: string, fileId: string): Promise { const db = await CoreSites.instance.getSiteDb(siteId); - const entry = await db.getRecord(CoreFilepoolProvider.FILES_TABLE, { fileId }); + const entry = await db.getRecord(FILES_TABLE_NAME, { fileId }); if (typeof entry === 'undefined') { throw new CoreError('File not found in filepool.'); @@ -2277,7 +2113,7 @@ export class CoreFilepoolProvider { protected async hasFileInQueue(siteId: string, fileId: string): Promise { await this.dbReady; - const entry = await this.appDB.getRecord(CoreFilepoolProvider.QUEUE_TABLE, { siteId, fileId }); + const entry = await this.appDB.getRecord(QUEUE_TABLE_NAME, { siteId, fileId }); if (typeof entry === 'undefined') { throw new CoreError('File not found in queue.'); @@ -2301,7 +2137,7 @@ export class CoreFilepoolProvider { const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; - await db.updateRecordsWhere(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, where); + await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, where); } /** @@ -2322,7 +2158,7 @@ export class CoreFilepoolProvider { const db = await CoreSites.instance.getSiteDb(siteId); - await db.updateRecords(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, { fileId }); + await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId }); } /** @@ -2359,7 +2195,7 @@ export class CoreFilepoolProvider { whereAndParams[0] += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')'; } - await db.updateRecordsWhere(CoreFilepoolProvider.FILES_TABLE, { stale: 1 }, whereAndParams[0], whereAndParams[1]); + await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams[0], whereAndParams[1]); } /** @@ -2615,7 +2451,7 @@ export class CoreFilepoolProvider { try { items = await this.appDB.getRecords( - CoreFilepoolProvider.QUEUE_TABLE, + QUEUE_TABLE_NAME, undefined, 'priority DESC, added ASC', undefined, @@ -2760,7 +2596,7 @@ export class CoreFilepoolProvider { protected async removeFromQueue(siteId: string, fileId: string): Promise { await this.dbReady; - await this.appDB.deleteRecords(CoreFilepoolProvider.QUEUE_TABLE, { siteId, fileId }); + await this.appDB.deleteRecords(QUEUE_TABLE_NAME, { siteId, fileId }); } /** @@ -2797,10 +2633,10 @@ export class CoreFilepoolProvider { const promises: Promise[] = []; // Remove entry from filepool store. - promises.push(db.deleteRecords(CoreFilepoolProvider.FILES_TABLE, conditions)); + promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions)); // Remove links. - promises.push(db.deleteRecords(CoreFilepoolProvider.LINKS_TABLE, conditions)); + promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); // Remove the file. if (CoreFile.instance.isAvailable()) { @@ -2885,7 +2721,7 @@ export class CoreFilepoolProvider { const packageId = this.getPackageId(component, componentId); // Get current stored data, we'll only update 'status' and 'updated' fields. - const entry = site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + const entry = site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); const newData: CoreFilepoolPackageEntry = {}; if (entry.status == CoreConstants.DOWNLOADING) { // Going back from downloading to previous status, restore previous download time. @@ -2895,7 +2731,7 @@ export class CoreFilepoolProvider { newData.updated = Date.now(); this.logger.debug(`Set previous status '${entry.status}' for package ${component} ${componentId}`); - await site.getDb().updateRecords(CoreFilepoolProvider.PACKAGES_TABLE, newData, { id: packageId }); + await site.getDb().updateRecords(PACKAGES_TABLE_NAME, newData, { id: packageId }); // Success updating, trigger event. this.triggerPackageStatusChanged(site.id!, newData.status, component, componentId); @@ -2973,7 +2809,7 @@ export class CoreFilepoolProvider { let previousStatus: string | undefined; // Search current status to set it as previous status. try { - const entry = site.getDb().getRecord(CoreFilepoolProvider.PACKAGES_TABLE, { id: packageId }); + const entry = site.getDb().getRecord(PACKAGES_TABLE_NAME, { id: packageId }); if (typeof extra == 'undefined' || extra === null) { extra = entry.extra; } @@ -3008,7 +2844,7 @@ export class CoreFilepoolProvider { return; } - await site.getDb().insertRecord(CoreFilepoolProvider.PACKAGES_TABLE, packageEntry); + await site.getDb().insertRecord(PACKAGES_TABLE_NAME, packageEntry); // Success inserting, trigger event. this.triggerPackageStatusChanged(siteId, status, component, componentId); @@ -3132,7 +2968,7 @@ export class CoreFilepoolProvider { const packageId = this.getPackageId(component, componentId); await site.getDb().updateRecords( - CoreFilepoolProvider.PACKAGES_TABLE, + PACKAGES_TABLE_NAME, { downloadTime: CoreTimeUtils.instance.timestamp() }, { id: packageId }, ); @@ -3142,166 +2978,6 @@ export class CoreFilepoolProvider { export class CoreFilepool extends makeSingleton(CoreFilepoolProvider) {} -/** - * File options. - */ -type CoreFilepoolFileOptions = { - revision?: number; // File's revision. - timemodified?: number; // File's timemodified. - isexternalfile?: number; // 1 if it's a external file (from an external repository), 0 otherwise. - repositorytype?: string; // Type of the repository this file belongs to. -}; - -/** - * Entry from filepool. - */ -export type CoreFilepoolFileEntry = CoreFilepoolFileOptions & { - /** - * The fileId to identify the file. - */ - fileId: string; - - /** - * File's URL. - */ - url: string; - - /** - * 1 if file is stale (needs to be updated), 0 otherwise. - */ - stale: number; - - /** - * Timestamp when this file was downloaded. - */ - downloadTime: number; - - /** - * File's path. - */ - path: string; - - /** - * File's extension. - */ - extension: string; -}; - -/** - * DB data for entry from file's queue. - */ -export type CoreFilepoolQueueDBEntry = CoreFilepoolFileOptions & { - /** - * The site the file belongs to. - */ - siteId: string; - - /** - * The fileId to identify the file. - */ - fileId: string; - - /** - * Timestamp when the file was added to the queue. - */ - added: number; - - /** - * The priority of the file. - */ - priority: number; - - /** - * File's URL. - */ - url: string; - - /** - * File's path. - */ - path?: string; - - /** - * File links (to link the file to components and componentIds). Serialized to store on DB. - */ - links: string; -}; - -/** - * Entry from the file's queue. - */ -export type CoreFilepoolQueueEntry = CoreFilepoolQueueDBEntry & { - /** - * File links (to link the file to components and componentIds). - */ - linksUnserialized?: CoreFilepoolComponentLink[]; -}; - -/** - * Entry from packages table. - */ -export type CoreFilepoolPackageEntry = { - /** - * Package id. - */ - id?: string; - - /** - * The component to link the files to. - */ - component?: string; - - /** - * An ID to use in conjunction with the component. - */ - componentId?: string | number; - - /** - * Package status. - */ - status?: string; - - /** - * Package previous status. - */ - previous?: string; - - /** - * Timestamp when this package was updated. - */ - updated?: number; - - /** - * Timestamp when this package was downloaded. - */ - downloadTime?: number; - - /** - * Previous download time. - */ - previousDownloadTime?: number; - - /** - * Extra data stored by the package. - */ - extra?: string; -}; - -/** - * A component link. - */ -export type CoreFilepoolComponentLink = { - /** - * Link's component. - */ - component: string; - - /** - * Link's componentId. - */ - componentId?: string | number; -}; - /** * File actions. */ @@ -3359,14 +3035,5 @@ type CoreFilepoolPromiseDefer = PromiseDefer & { onProgress?: CoreFilepoolOnProgressCallback; // On Progress function. }; -/** - * Links table record type. - */ -type CoreFilepoolLinksRecord = { - fileId: string; // File Id. - component: string; // Component name. - componentId: number | string; // Component Id. -}; - type AnchorOrMediaElement = HTMLAnchorElement | HTMLImageElement | HTMLAudioElement | HTMLVideoElement | HTMLSourceElement | HTMLTrackElement; diff --git a/src/app/services/local-notifications.db.ts b/src/app/services/local-notifications.db.ts new file mode 100644 index 000000000..4eb333077 --- /dev/null +++ b/src/app/services/local-notifications.db.ts @@ -0,0 +1,80 @@ +// (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 { CoreAppSchema } from '@services/app'; +import { PromiseDefer } from '@services/utils/utils'; + +/** + * Database variables for CoreLocalNotifications service. + */ +export const SITES_TABLE_NAME = 'notification_sites'; // Store to asigne unique codes to each site. +export const COMPONENTS_TABLE_NAME = 'notification_components'; // Store to asigne unique codes to each component. +export const TRIGGERED_TABLE_NAME = 'notifications_triggered'; // Store to prevent re-triggering notifications. +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreLocalNotificationsProvider', + version: 1, + tables: [ + { + name: SITES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'code', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: COMPONENTS_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'code', + type: 'INTEGER', + notNull: true, + }, + ], + }, + { + name: TRIGGERED_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'at', + type: 'INTEGER', + notNull: true, + }, + ], + }, + ], +}; + +export type CodeRequestsQueueItem = { + table: string; + id: string; + deferreds: PromiseDefer[]; +}; diff --git a/src/app/services/local-notifications.ts b/src/app/services/local-notifications.ts index 5f1c7e3d7..def7f011c 100644 --- a/src/app/services/local-notifications.ts +++ b/src/app/services/local-notifications.ts @@ -16,11 +16,11 @@ import { Injectable } from '@angular/core'; import { Subject, Subscription } from 'rxjs'; import { ILocalNotification } from '@ionic-native/local-notifications'; -import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreTextUtils } from '@services/utils/text'; -import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreUtils } from '@services/utils/utils'; import { SQLiteDB } from '@classes/sqlitedb'; import { CoreSite } from '@classes/site'; import { CoreQueueRunner } from '@classes/queue-runner'; @@ -28,6 +28,13 @@ import { CoreError } from '@classes/errors/error'; import { CoreConstants } from '@core/constants'; import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + TRIGGERED_TABLE_NAME, + COMPONENTS_TABLE_NAME, + SITES_TABLE_NAME, + CodeRequestsQueueItem, +} from '@services/local-notifications.db'; /** * Service to handle local notifications. @@ -35,62 +42,6 @@ import { CoreLogger } from '@singletons/logger'; @Injectable() export class CoreLocalNotificationsProvider { - // Variables for the database. - protected static readonly SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site. - protected static readonly COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component. - protected static readonly TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications. - protected tablesSchema: CoreAppSchema = { - name: 'CoreLocalNotificationsProvider', - version: 1, - tables: [ - { - name: CoreLocalNotificationsProvider.SITES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'code', - type: 'INTEGER', - notNull: true, - }, - ], - }, - { - name: CoreLocalNotificationsProvider.COMPONENTS_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'code', - type: 'INTEGER', - notNull: true, - }, - ], - }, - { - name: CoreLocalNotificationsProvider.TRIGGERED_TABLE, - columns: [ - { - name: 'id', - type: 'INTEGER', - primaryKey: true, - }, - { - name: 'at', - type: 'INTEGER', - notNull: true, - }, - ], - }, - ], - }; - protected logger: CoreLogger; protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. @@ -111,7 +62,7 @@ export class CoreLocalNotificationsProvider { this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); this.queueRunner = new CoreQueueRunner(10); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); @@ -301,7 +252,7 @@ export class CoreLocalNotificationsProvider { * @return Promise resolved when the component code is retrieved. */ protected getComponentCode(component: string): Promise { - return this.requestCode(CoreLocalNotificationsProvider.COMPONENTS_TABLE, component); + return this.requestCode(COMPONENTS_TABLE_NAME, component); } /** @@ -312,7 +263,7 @@ export class CoreLocalNotificationsProvider { * @return Promise resolved when the site code is retrieved. */ protected getSiteCode(siteId: string): Promise { - return this.requestCode(CoreLocalNotificationsProvider.SITES_TABLE, siteId); + return this.requestCode(SITES_TABLE_NAME, siteId); } /** @@ -377,7 +328,7 @@ export class CoreLocalNotificationsProvider { try { const stored = await this.appDB.getRecord<{ id: number; at: number }>( - CoreLocalNotificationsProvider.TRIGGERED_TABLE, + TRIGGERED_TABLE_NAME, { id: notification.id }, ); @@ -532,7 +483,7 @@ export class CoreLocalNotificationsProvider { async removeTriggered(id: number): Promise { await this.dbReady; - await this.appDB.deleteRecords(CoreLocalNotificationsProvider.TRIGGERED_TABLE, { id: id }); + await this.appDB.deleteRecords(TRIGGERED_TABLE_NAME, { id: id }); } /** @@ -695,7 +646,7 @@ export class CoreLocalNotificationsProvider { at: notification.trigger && notification.trigger.at ? notification.trigger.at.getTime() : Date.now(), }; - return this.appDB.insertRecord(CoreLocalNotificationsProvider.TRIGGERED_TABLE, entry); + return this.appDB.insertRecord(TRIGGERED_TABLE_NAME, entry); } /** @@ -708,10 +659,10 @@ export class CoreLocalNotificationsProvider { async updateComponentName(oldName: string, newName: string): Promise { await this.dbReady; - const oldId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + oldName; - const newId = CoreLocalNotificationsProvider.COMPONENTS_TABLE + '#' + newName; + const oldId = COMPONENTS_TABLE_NAME + '#' + oldName; + const newId = COMPONENTS_TABLE_NAME + '#' + newName; - await this.appDB.updateRecords(CoreLocalNotificationsProvider.COMPONENTS_TABLE, { id: newId }, { id: oldId }); + await this.appDB.updateRecords(COMPONENTS_TABLE_NAME, { id: newId }, { id: oldId }); } } @@ -719,9 +670,3 @@ export class CoreLocalNotificationsProvider { export class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {} export type CoreLocalNotificationsClickCallback = (value: T) => void; - -type CodeRequestsQueueItem = { - table: string; - id: string; - deferreds: PromiseDefer[]; -}; diff --git a/src/app/services/sites.db.ts b/src/app/services/sites.db.ts new file mode 100644 index 000000000..83c6e8027 --- /dev/null +++ b/src/app/services/sites.db.ts @@ -0,0 +1,233 @@ +// (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 { CoreAppSchema } from '@services/app'; +import { CoreSiteSchema, registerSiteSchema } from '@services/sites'; +import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import { CoreSite } from '@classes/site'; + +/** + * Database variables for CoreSites service. + */ +export const SITES_TABLE_NAME = 'sites_2'; +export const CURRENT_SITE_TABLE_NAME = 'current_site'; +export const SCHEMA_VERSIONS_TABLE_NAME = 'schema_versions'; + +// Schema to register in App DB. +export const APP_SCHEMA: CoreAppSchema = { + name: 'CoreSitesProvider', + version: 2, + tables: [ + { + name: SITES_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'siteUrl', + type: 'TEXT', + notNull: true, + }, + { + name: 'token', + type: 'TEXT', + }, + { + name: 'info', + type: 'TEXT', + }, + { + name: 'privateToken', + type: 'TEXT', + }, + { + name: 'config', + type: 'TEXT', + }, + { + name: 'loggedOut', + type: 'INTEGER', + }, + { + name: 'oauthId', + type: 'INTEGER', + }, + ], + }, + { + name: CURRENT_SITE_TABLE_NAME, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'siteId', + type: 'TEXT', + notNull: true, + unique: true, + }, + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion < 2) { + const newTable = SITES_TABLE_NAME; + const oldTable = 'sites'; + + try { + // Check if V1 table exists. + await db.tableExists(oldTable); + + // Move the records from the old table. + const sites = await db.getAllRecords(oldTable); + const promises: Promise[] = []; + + sites.forEach((site) => { + promises.push(db.insertRecord(newTable, site)); + }); + + await Promise.all(promises); + + // Data moved, drop the old table. + await db.dropTable(oldTable); + } catch (error) { + // Old table does not exist, ignore. + } + } + }, +}; + +// Schema to register for Site DB. +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreSitesProvider', + version: 2, + canBeCleared: [CoreSite.WS_CACHE_TABLE], + tables: [ + { + name: CoreSite.WS_CACHE_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'key', + type: 'TEXT', + }, + { + name: 'expirationTime', + type: 'INTEGER', + }, + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentId', + type: 'INTEGER', + }, + ], + }, + { + name: CoreSite.CONFIG_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true, + }, + { + name: 'value', + }, + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion && oldVersion < 2) { + const newTable = CoreSite.WS_CACHE_TABLE; + const oldTable = 'wscache'; + + try { + await db.tableExists(oldTable); + } catch (error) { + // Old table does not exist, ignore. + return; + } + // Cannot use insertRecordsFrom because there are extra fields, so manually code INSERT INTO. + await db.execute( + 'INSERT INTO ' + newTable + ' ' + + 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + + 'FROM ' + oldTable, + ); + + try { + await db.dropTable(oldTable); + } catch (error) { + // Error deleting old table, ignore. + } + } + }, +}; + +// Table for site DB to include the schema versions. It's not part of SITE_SCHEMA because it needs to be created first. +export const SCHEMA_VERSIONS_TABLE_SCHEMA: SQLiteDBTableSchema = { + name: SCHEMA_VERSIONS_TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER', + }, + ], +}; + +export type SiteDBEntry = { + id: string; + siteUrl: string; + token: string; + info: string; + privateToken: string; + config: string; + loggedOut: number; + oauthId: number; +}; + +export type CurrentSiteDBEntry = { + id: number; + siteId: string; +}; + +export type SchemaVersionsDBEntry = { + name: string; + version: number; +}; + +export const initCoreSitesDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts index 71604d276..7bcccfc8b 100644 --- a/src/app/services/sites.ts +++ b/src/app/services/sites.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { Md5 } from 'ts-md5/dist/md5'; import { timeout } from 'rxjs/operators'; -import { CoreApp, CoreAppSchema, CoreStoreConfig } from '@services/app'; +import { CoreApp, CoreStoreConfig } from '@services/app'; import { CoreEvents } from '@singletons/events'; import { CoreWS } from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; @@ -38,114 +38,36 @@ import { CoreError } from '@classes/errors/error'; import { CoreSiteError } from '@classes/errors/siteerror'; import { makeSingleton, Translate, Http } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; +import { + APP_SCHEMA, + SCHEMA_VERSIONS_TABLE_SCHEMA, + SITES_TABLE_NAME, + CURRENT_SITE_TABLE_NAME, + SCHEMA_VERSIONS_TABLE_NAME, + SiteDBEntry, + CurrentSiteDBEntry, + SchemaVersionsDBEntry, +} from '@services/sites.db'; -const SITES_TABLE = 'sites_2'; -const CURRENT_SITE_TABLE = 'current_site'; -const SCHEMA_VERSIONS_TABLE = 'schema_versions'; + +// Schemas for site tables. Other providers can add schemas in here using the registerSiteSchema function. +const siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; +export const registerSiteSchema = (schema: CoreSiteSchema): void => { + siteSchemas[schema.name] = schema; +}; /* * Service to manage and interact with sites. * It allows creating tables in the databases of all sites. Each service or component should be responsible of creating * their own database tables. Example: * - * constructor(sitesProvider: CoreSitesProvider) { - * this.sitesProvider.registerSiteSchema(this.tableSchema); + * import { registerSiteSchema } from '@services/sites'; * - * This provider will automatically create the tables in the databases of all the instantiated sites, and also to the - * databases of sites instantiated from now on. + * registerSiteSchema(tableSchema); */ @Injectable() export class CoreSitesProvider { - // Variables for the database. - protected appTablesSchema: CoreAppSchema = { - name: 'CoreSitesProvider', - version: 2, - tables: [ - { - name: SITES_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'siteUrl', - type: 'TEXT', - notNull: true, - }, - { - name: 'token', - type: 'TEXT', - }, - { - name: 'info', - type: 'TEXT', - }, - { - name: 'privateToken', - type: 'TEXT', - }, - { - name: 'config', - type: 'TEXT', - }, - { - name: 'loggedOut', - type: 'INTEGER', - }, - { - name: 'oauthId', - type: 'INTEGER', - }, - ], - }, - { - name: CURRENT_SITE_TABLE, - columns: [ - { - name: 'id', - type: 'INTEGER', - primaryKey: true, - }, - { - name: 'siteId', - type: 'TEXT', - notNull: true, - unique: true, - }, - ], - }, - ], - async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion < 2) { - const newTable = SITES_TABLE; - const oldTable = 'sites'; - - try { - // Check if V1 table exists. - await db.tableExists(oldTable); - - // Move the records from the old table. - const sites = await db.getAllRecords(oldTable); - const promises: Promise[] = []; - - sites.forEach((site) => { - promises.push(db.insertRecord(newTable, site)); - }); - - await Promise.all(promises); - - // Data moved, drop the old table. - await db.dropTable(oldTable); - } catch (error) { - // Old table does not exist, ignore. - } - } - }, - }; - // Constants to validate a site version. protected readonly WORKPLACE_APP = 3; protected readonly MOODLE_APP = 2; @@ -162,112 +84,15 @@ export class CoreSitesProvider { protected appDB: SQLiteDB; protected dbReady: Promise; // Promise resolved when the app DB is initialized. protected siteSchemasMigration: { [siteId: string]: Promise } = {}; - - // Schemas for site tables. Other providers can add schemas in here. - protected siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; - protected siteTablesSchemas: SQLiteDBTableSchema[] = [ - { - name: SCHEMA_VERSIONS_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'version', - type: 'INTEGER', - }, - ], - }, - ]; - - // Site schema for this provider. - protected siteSchema: CoreSiteSchema = { - name: 'CoreSitesProvider', - version: 2, - canBeCleared: [CoreSite.WS_CACHE_TABLE], - tables: [ - { - name: CoreSite.WS_CACHE_TABLE, - columns: [ - { - name: 'id', - type: 'TEXT', - primaryKey: true, - }, - { - name: 'data', - type: 'TEXT', - }, - { - name: 'key', - type: 'TEXT', - }, - { - name: 'expirationTime', - type: 'INTEGER', - }, - { - name: 'component', - type: 'TEXT', - }, - { - name: 'componentId', - type: 'INTEGER', - }, - ], - }, - { - name: CoreSite.CONFIG_TABLE, - columns: [ - { - name: 'name', - type: 'TEXT', - unique: true, - notNull: true, - }, - { - name: 'value', - }, - ], - }, - ], - async migrate(db: SQLiteDB, oldVersion: number): Promise { - if (oldVersion && oldVersion < 2) { - const newTable = CoreSite.WS_CACHE_TABLE; - const oldTable = 'wscache'; - - try { - await db.tableExists(oldTable); - } catch (error) { - // Old table does not exist, ignore. - return; - } - // Cannot use insertRecordsFrom because there are extra fields, so manually code INSERT INTO. - await db.execute( - 'INSERT INTO ' + newTable + ' ' + - 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + - 'FROM ' + oldTable, - ); - - try { - await db.dropTable(oldTable); - } catch (error) { - // Error deleting old table, ignore. - } - } - }, - }; + protected pluginsSiteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; constructor() { this.logger = CoreLogger.getInstance('CoreSitesProvider'); this.appDB = CoreApp.instance.getDB(); - this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + this.dbReady = CoreApp.instance.createTablesFromSchema(APP_SCHEMA).catch(() => { // Ignore errors. }); - this.registerSiteSchema(this.siteSchema); } /** @@ -857,7 +682,7 @@ export class CoreSitesProvider { oauthId, }; - await this.appDB.insertRecord(SITES_TABLE, entry); + await this.appDB.insertRecord(SITES_TABLE_NAME, entry); } /** @@ -1084,7 +909,7 @@ export class CoreSitesProvider { delete this.sites[siteId]; try { - await this.appDB.deleteRecords(SITES_TABLE, { id: siteId }); + await this.appDB.deleteRecords(SITES_TABLE_NAME, { id: siteId }); } catch (err) { // DB remove shouldn't fail, but we'll go ahead even if it does. } @@ -1103,7 +928,7 @@ export class CoreSitesProvider { async hasSites(): Promise { await this.dbReady; - const count = await this.appDB.countRecords(SITES_TABLE); + const count = await this.appDB.countRecords(SITES_TABLE_NAME); return count > 0; } @@ -1129,7 +954,7 @@ export class CoreSitesProvider { return this.sites[siteId]; } else { // Retrieve and create the site. - const data = await this.appDB.getRecord(SITES_TABLE, { id: siteId }); + const data = await this.appDB.getRecord(SITES_TABLE_NAME, { id: siteId }); return this.makeSiteFromSiteListEntry(data); } @@ -1202,7 +1027,7 @@ export class CoreSitesProvider { async getSites(ids?: string[]): Promise { await this.dbReady; - const sites = await this.appDB.getAllRecords(SITES_TABLE); + const sites = await this.appDB.getAllRecords(SITES_TABLE_NAME); const formattedSites: CoreSiteBasicInfo[] = []; sites.forEach((site) => { @@ -1266,7 +1091,7 @@ export class CoreSitesProvider { async getLoggedInSitesIds(): Promise { await this.dbReady; - const sites = await this.appDB.getRecords(SITES_TABLE, { loggedOut : 0 }); + const sites = await this.appDB.getRecords(SITES_TABLE_NAME, { loggedOut : 0 }); return sites.map((site) => site.id); } @@ -1279,7 +1104,7 @@ export class CoreSitesProvider { async getSitesIds(): Promise { await this.dbReady; - const sites = await this.appDB.getAllRecords(SITES_TABLE); + const sites = await this.appDB.getAllRecords(SITES_TABLE_NAME); return sites.map((site) => site.id); } @@ -1298,7 +1123,7 @@ export class CoreSitesProvider { siteId, }; - await this.appDB.insertRecord(CURRENT_SITE_TABLE, entry); + await this.appDB.insertRecord(CURRENT_SITE_TABLE_NAME, entry); CoreEvents.trigger(CoreEvents.LOGIN, {}, siteId); } @@ -1324,7 +1149,7 @@ export class CoreSitesProvider { promises.push(this.setSiteLoggedOut(siteId, true)); } - promises.push(this.appDB.deleteRecords(CURRENT_SITE_TABLE, { id: 1 })); + promises.push(this.appDB.deleteRecords(CURRENT_SITE_TABLE_NAME, { id: 1 })); } try { @@ -1349,7 +1174,7 @@ export class CoreSitesProvider { this.sessionRestored = true; try { - const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE, { id: 1 }); + const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE_NAME, { id: 1 }); const siteId = currentSite.siteId; this.logger.debug(`Restore session in site ${siteId}`); @@ -1377,7 +1202,7 @@ export class CoreSitesProvider { site.setLoggedOut(loggedOut); - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } /** @@ -1426,7 +1251,7 @@ export class CoreSitesProvider { site.privateToken = privateToken; site.setLoggedOut(false); // Token updated means the user authenticated again, not logged out anymore. - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } /** @@ -1470,7 +1295,7 @@ export class CoreSitesProvider { } try { - await this.appDB.updateRecords(SITES_TABLE, newValues, { id: siteId }); + await this.appDB.updateRecords(SITES_TABLE_NAME, newValues, { id: siteId }); } finally { CoreEvents.trigger(CoreEvents.SITE_UPDATED, info, siteId); } @@ -1529,7 +1354,7 @@ export class CoreSitesProvider { } try { - const siteEntries = await this.appDB.getAllRecords(SITES_TABLE); + const siteEntries = await this.appDB.getAllRecords(SITES_TABLE_NAME); const ids: string[] = []; const promises: Promise[] = []; @@ -1562,7 +1387,7 @@ export class CoreSitesProvider { async getStoredCurrentSiteId(): Promise { await this.dbReady; - const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE, { id: 1 }); + const currentSite = await this.appDB.getRecord(CURRENT_SITE_TABLE_NAME, { id: 1 }); return currentSite.siteId; } @@ -1605,32 +1430,6 @@ export class CoreSitesProvider { return this.getSite(siteId).then((site) => site.isFeatureDisabled(name)); } - /** - * Create a table in all the sites databases. - * - * @param table Table schema. - * @deprecated. Please use registerSiteSchema instead. - */ - createTableFromSchema(table: SQLiteDBTableSchema): void { - this.createTablesFromSchema([table]); - } - - /** - * Create several tables in all the sites databases. - * - * @param tables List of tables schema. - * @deprecated. Please use registerSiteSchema instead. - */ - createTablesFromSchema(tables: SQLiteDBTableSchema[]): void { - // Add the tables to the list of schemas. This list is to create all the tables in new sites. - this.siteTablesSchemas = this.siteTablesSchemas.concat(tables); - - // Now create these tables in current sites. - for (const id in this.sites) { - this.sites[id].getDb().createTablesFromSchema(tables); - } - } - /** * Check if a WS is available in the current site, if any. * @@ -1645,40 +1444,29 @@ export class CoreSitesProvider { } /** - * Register a site schema. + * Register a site schema in current site. + * This function is meant for site plugins to create DB tables in current site. Tables created from within the app + * whould use the registerSiteSchema function exported in this same file. * * @param schema The schema to register. * @return Promise resolved when done. */ async registerSiteSchema(schema: CoreSiteSchema): Promise { - if (this.currentSite) { - try { - // Site has already been created, apply the schema directly. - const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; - schemas[schema.name] = schema; + if (!this.currentSite) { + return; + } - if (!schema.onlyCurrentSite) { - // Apply it to all sites. - const siteIds = await this.getSitesIds(); + try { + // Site has already been created, apply the schema directly. + const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; + schemas[schema.name] = schema; - await Promise.all(siteIds.map(async (siteId) => { - const site = await this.getSite(siteId); + // Apply it to the specified site only. + (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); - return this.applySiteSchemas(site, schemas); - })); - } else { - // Apply it to the specified site only. - (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); - - await this.applySiteSchemas(this.currentSite, schemas); - } - } finally { - // Add the schema to the list. It's done in the end to prevent a schema being applied twice. - this.siteSchemas[schema.name] = schema; - } - } else if (!schema.onlyCurrentSite) { - // Add the schema to the list, it will be applied when the sites are created. - this.siteSchemas[schema.name] = schema; + await this.applySiteSchemas(this.currentSite, schemas); + } finally { + this.pluginsSiteSchemas[schema.name] = schema; } } @@ -1700,8 +1488,8 @@ export class CoreSitesProvider { this.logger.debug(`Migrating all schemas of ${site.id}`); // First create tables not registerd with name/version. - const promise = site.getDb().createTablesFromSchema(this.siteTablesSchemas) - .then(() => this.applySiteSchemas(site, this.siteSchemas)); + const promise = site.getDb().createTableFromSchema(SCHEMA_VERSIONS_TABLE_SCHEMA) + .then(() => this.applySiteSchemas(site, siteSchemas)); this.siteSchemasMigration[site.id] = promise; @@ -1721,7 +1509,7 @@ export class CoreSitesProvider { const db = site.getDb(); // Fetch installed versions of the schema. - const records = await db.getAllRecords(SCHEMA_VERSIONS_TABLE); + const records = await db.getAllRecords(SCHEMA_VERSIONS_TABLE_NAME); const versions: {[name: string]: number} = {}; records.forEach((record) => { @@ -1768,7 +1556,7 @@ export class CoreSitesProvider { } // Set installed version. - await db.insertRecord(SCHEMA_VERSIONS_TABLE, { name, version: schema.version }); + await db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name, version: schema.version }); } /** @@ -1814,13 +1602,13 @@ export class CoreSitesProvider { */ getSiteTableSchemasToClear(site: CoreSite): string[] { let reset: string[] = []; - for (const name in this.siteSchemas) { - const schema = this.siteSchemas[name]; + const schemas = Object.values(siteSchemas).concat(Object.values(this.pluginsSiteSchemas)); + schemas.forEach((schema) => { if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { reset = reset.concat(schema.canBeCleared); } - } + }); return reset; } @@ -1980,12 +1768,6 @@ export type CoreSiteSchema = { */ canBeCleared?: string[]; - /** - * If true, the schema will only be applied to the current site. Otherwise it will be applied to all sites. - * If you're implementing a site plugin, please set it to true. - */ - onlyCurrentSite?: boolean; - /** * Tables to create when installing or upgrading the schema. */ @@ -2088,24 +1870,3 @@ export type CoreSitesLoginTokenResponse = { debuginfo?: string; reproductionlink?: string; }; - -type SiteDBEntry = { - id: string; - siteUrl: string; - token: string; - info: string; - privateToken: string; - config: string; - loggedOut: number; - oauthId: number; -}; - -type CurrentSiteDBEntry = { - id: number; - siteId: string; -}; - -type SchemaVersionsDBEntry = { - name: string; - version: number; -}; diff --git a/src/app/services/sync.db.ts b/src/app/services/sync.db.ts new file mode 100644 index 000000000..0f30c0a12 --- /dev/null +++ b/src/app/services/sync.db.ts @@ -0,0 +1,62 @@ +// (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 { CoreSiteSchema, registerSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreSync service. + */ +export const SYNC_TABLE_NAME = 'sync'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreSyncProvider', + version: 1, + tables: [ + { + name: SYNC_TABLE_NAME, + columns: [ + { + name: 'component', + type: 'TEXT', + notNull: true, + }, + { + name: 'id', + type: 'TEXT', + notNull: true, + }, + { + name: 'time', + type: 'INTEGER', + }, + { + name: 'warnings', + type: 'TEXT', + }, + ], + primaryKeys: ['component', 'id'], + }, + ], +}; + +export type CoreSyncRecord = { + component: string; + id: string; + time: number; + warnings: string; +}; + +export const initCoreSyncDB = (): void => { + registerSiteSchema(SITE_SCHEMA); +}; + diff --git a/src/app/services/sync.ts b/src/app/services/sync.ts index 2246f5cbe..efbe00ab4 100644 --- a/src/app/services/sync.ts +++ b/src/app/services/sync.ts @@ -16,8 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreEvents } from '@singletons/events'; import { CoreSites, CoreSiteSchema } from '@services/sites'; import { makeSingleton } from '@singletons/core.singletons'; - -const SYNC_TABLE = 'sync'; +import { SYNC_TABLE_NAME, CoreSyncRecord } from '@services/sync.db'; /* * Service that provides some features regarding synchronization. @@ -31,7 +30,7 @@ export class CoreSyncProvider { version: 1, tables: [ { - name: SYNC_TABLE, + name: SYNC_TABLE_NAME, columns: [ { name: 'component', @@ -61,8 +60,6 @@ export class CoreSyncProvider { protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {}; constructor() { - CoreSites.instance.registerSiteSchema(this.siteSchema); - // Unblock all blocks on logout. CoreEvents.on(CoreEvents.LOGOUT, (data: {siteId: string}) => { this.clearAllBlocks(data.siteId); @@ -133,7 +130,7 @@ export class CoreSyncProvider { * @return Record if found or reject. */ getSyncRecord(component: string, id: string | number, siteId?: string): Promise { - return CoreSites.instance.getSiteDb(siteId).then((db) => db.getRecord(SYNC_TABLE, { component: component, id: id })); + return CoreSites.instance.getSiteDb(siteId).then((db) => db.getRecord(SYNC_TABLE_NAME, { component: component, id: id })); } /** @@ -151,7 +148,7 @@ export class CoreSyncProvider { data.component = component; data.id = id; - await db.insertRecord(SYNC_TABLE, data); + await db.insertRecord(SYNC_TABLE_NAME, data); } /** @@ -211,10 +208,3 @@ export class CoreSyncProvider { } export class CoreSync extends makeSingleton(CoreSyncProvider) {} - -export type CoreSyncRecord = { - component: string; - id: string; - time: number; - warnings: string; -}; From a39c65801b9b58664a8f9cb2d4b72272626006e3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 28 Oct 2020 15:46:53 +0100 Subject: [PATCH 06/12] MOBILE-3565 login: Implement site policy page --- src/app/components/components.module.ts | 3 + src/app/components/iframe/core-iframe.html | 11 ++ src/app/components/iframe/iframe.scss | 31 ++++ src/app/components/iframe/iframe.ts | 117 +++++++++++++++ src/app/core/login/login-routing.module.ts | 4 + .../login/pages/site-policy/site-policy.html | 32 ++++ .../pages/site-policy/site-policy.module.ts | 46 ++++++ .../pages/site-policy/site-policy.page.ts | 139 ++++++++++++++++++ src/app/services/utils/iframe.ts | 31 ++++ 9 files changed, 414 insertions(+) create mode 100644 src/app/components/iframe/core-iframe.html create mode 100644 src/app/components/iframe/iframe.scss create mode 100644 src/app/components/iframe/iframe.ts create mode 100644 src/app/core/login/pages/site-policy/site-policy.html create mode 100644 src/app/core/login/pages/site-policy/site-policy.module.ts create mode 100644 src/app/core/login/pages/site-policy/site-policy.page.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index b16efbbcb..a6bf120a8 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -18,6 +18,7 @@ import { IonicModule } from '@ionic/angular'; import { TranslateModule } from '@ngx-translate/core'; import { CoreIconComponent } from './icon/icon'; +import { CoreIframeComponent } from './iframe/iframe'; import { CoreLoadingComponent } from './loading/loading'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; @@ -27,6 +28,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; @NgModule({ declarations: [ CoreIconComponent, + CoreIframeComponent, CoreLoadingComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, @@ -40,6 +42,7 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; ], exports: [ CoreIconComponent, + CoreIframeComponent, CoreLoadingComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, diff --git a/src/app/components/iframe/core-iframe.html b/src/app/components/iframe/core-iframe.html new file mode 100644 index 000000000..1c05ee6e4 --- /dev/null +++ b/src/app/components/iframe/core-iframe.html @@ -0,0 +1,11 @@ +
      + + + + + + +
      \ No newline at end of file diff --git a/src/app/components/iframe/iframe.scss b/src/app/components/iframe/iframe.scss new file mode 100644 index 000000000..d99e54aae --- /dev/null +++ b/src/app/components/iframe/iframe.scss @@ -0,0 +1,31 @@ +ion-app.app-root core-iframe { + + > div { + max-width: 100%; + max-height: 100%; + } + iframe { + border: 0; + display: block; + max-width: 100%; + background-color: $gray-light; + } + + .core-loading-container { + position: absolute; + @include position(0, 0, 0, 0); + display: table; + height: 100%; + width: 100%; + z-index: 1; + margin: 0; + padding: 0; + clear: both; + + .core-loading-spinner { + display: table-cell; + text-align: center; + vertical-align: middle; + } + } +} diff --git a/src/app/components/iframe/iframe.ts b/src/app/components/iframe/iframe.ts new file mode 100644 index 000000000..c3fa0e8b2 --- /dev/null +++ b/src/app/components/iframe/iframe.ts @@ -0,0 +1,117 @@ +// (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, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, +} from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { NavController } from '@ionic/angular'; + +import { CoreFile } from '@services/file'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreIframeUtils } from '@services/utils/iframe'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLogger } from '@singletons/logger'; + +@Component({ + selector: 'core-iframe', + templateUrl: 'core-iframe.html', +}) +export class CoreIframeComponent implements OnChanges { + + @ViewChild('iframe') iframe?: ElementRef; + @Input() src?: string; + @Input() iframeWidth?: string; + @Input() iframeHeight?: string; + @Input() allowFullscreen?: boolean | string; + @Output() loaded: EventEmitter = new EventEmitter(); + + loading?: boolean; + safeUrl?: SafeResourceUrl; + + protected readonly IFRAME_TIMEOUT = 15000; + protected logger: CoreLogger; + protected initialized = false; + + constructor( + protected sanitizer: DomSanitizer, + protected navCtrl: NavController, + ) { + + this.logger = CoreLogger.getInstance('CoreIframe'); + this.loaded = new EventEmitter(); + } + + /** + * Init the data. + */ + protected init(): void { + if (this.initialized) { + return; + } + + const iframe: HTMLIFrameElement | undefined = this.iframe?.nativeElement; + if (!iframe) { + return; + } + + this.initialized = true; + + this.iframeWidth = (this.iframeWidth && CoreDomUtils.instance.formatPixelsSize(this.iframeWidth)) || '100%'; + this.iframeHeight = (this.iframeHeight && CoreDomUtils.instance.formatPixelsSize(this.iframeHeight)) || '100%'; + this.allowFullscreen = CoreUtils.instance.isTrueOrOne(this.allowFullscreen); + + // Show loading only with external URLs. + this.loading = !this.src || !CoreUrlUtils.instance.isLocalFileUrl(this.src); + + // @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl); + + iframe.addEventListener('load', () => { + this.loading = false; + this.loaded.emit(iframe); // Notify iframe was loaded. + }); + + iframe.addEventListener('error', () => { + this.loading = false; + CoreDomUtils.instance.showErrorModal('core.errorloadingcontent', true); + }); + + if (this.loading) { + setTimeout(() => { + this.loading = false; + }, this.IFRAME_TIMEOUT); + } + } + + /** + * Detect changes on input properties. + */ + async ngOnChanges(changes: {[name: string]: SimpleChange }): Promise { + if (changes.src) { + const url = CoreUrlUtils.instance.getYoutubeEmbedUrl(changes.src.currentValue) || changes.src.currentValue; + + await CoreIframeUtils.instance.fixIframeCookies(url); + + this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(CoreFile.instance.convertFileSrc(url)); + + // Now that the URL has been set, initialize the iframe. Wait for the iframe to the added to the DOM. + setTimeout(() => { + this.init(); + }); + } + } + +} diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index 0263baf61..dd76dd2b0 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -47,6 +47,10 @@ const routes: Routes = [ loadChildren: () => import('./pages/change-password/change-password.module') .then( m => m.CoreLoginChangePasswordPageModule), }, + { + path: 'sitepolicy', + loadChildren: () => import('./pages/site-policy/site-policy.module').then( m => m.CoreLoginSitePolicyPageModule), + }, ]; @NgModule({ diff --git a/src/app/core/login/pages/site-policy/site-policy.html b/src/app/core/login/pages/site-policy/site-policy.html new file mode 100644 index 000000000..81e3537fc --- /dev/null +++ b/src/app/core/login/pages/site-policy/site-policy.html @@ -0,0 +1,32 @@ + + + + + + + {{ 'core.login.policyagreement' | translate }} + + + + + + +

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

      +
      + +

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

      +
      + + + + + {{ 'core.login.policyaccept' | translate }} + + + {{ 'core.login.cancel' | translate }} + +
      +
      +
      diff --git a/src/app/core/login/pages/site-policy/site-policy.module.ts b/src/app/core/login/pages/site-policy/site-policy.module.ts new file mode 100644 index 000000000..3635639c2 --- /dev/null +++ b/src/app/core/login/pages/site-policy/site-policy.module.ts @@ -0,0 +1,46 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreLoginSitePolicyPage } from './site-policy.page'; + +const routes: Routes = [ + { + path: '', + component: CoreLoginSitePolicyPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreLoginSitePolicyPage, + ], + exports: [RouterModule], +}) +export class CoreLoginSitePolicyPageModule {} diff --git a/src/app/core/login/pages/site-policy/site-policy.page.ts b/src/app/core/login/pages/site-policy/site-policy.page.ts new file mode 100644 index 000000000..ca4858feb --- /dev/null +++ b/src/app/core/login/pages/site-policy/site-policy.page.ts @@ -0,0 +1,139 @@ +// (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, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NavController } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreSite } from '@classes/site'; + +/** + * Page to accept a site policy. + */ +@Component({ + selector: 'page-core-login-site-policy', + templateUrl: 'site-policy.html', +}) +export class CoreLoginSitePolicyPage implements OnInit { + + sitePolicy?: string; + showInline?: boolean; + policyLoaded?: boolean; + protected siteId?: string; + protected currentSite?: CoreSite; + + constructor( + protected navCtrl: NavController, + protected route: ActivatedRoute, + ) { + } + + /** + * Component initialized. + */ + ngOnInit(): void { + const params = this.route.snapshot.queryParams; + + this.siteId = params['siteId']; + this.currentSite = CoreSites.instance.getCurrentSite(); + + if (!this.currentSite) { + // Not logged in, stop. + this.cancel(); + + return; + } + + const currentSiteId = this.currentSite.id; + this.siteId = this.siteId || currentSiteId; + + if (this.siteId != currentSiteId || !this.currentSite.wsAvailable('core_user_agree_site_policy')) { + // Not current site or WS not available, stop. + this.cancel(); + + return; + } + + this.fetchSitePolicy(); + } + + /** + * Fetch the site policy URL. + * + * @return Promise resolved when done. + */ + protected async fetchSitePolicy(): Promise { + try { + this.sitePolicy = await CoreLoginHelper.instance.getSitePolicy(this.siteId); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting site policy.'); + this.cancel(); + + return; + } + + // Try to get the mime type. + try { + const mimeType = await CoreUtils.instance.getMimeTypeFromUrl(this.sitePolicy); + + const extension = CoreMimetypeUtils.instance.getExtension(mimeType, this.sitePolicy); + this.showInline = extension == 'html' || extension == 'htm'; + } catch (error) { + // Unable to get mime type, assume it's not supported. + this.showInline = false; + } finally { + this.policyLoaded = true; + } + } + + /** + * Cancel. + * + * @return Promise resolved when done. + */ + async cancel(): Promise { + await CoreUtils.instance.ignoreErrors(CoreSites.instance.logout()); + + await this.navCtrl.navigateRoot('/login/sites'); + } + + /** + * Accept the site policy. + * + * @return Promise resolved when done. + */ + async accept(): Promise { + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + try { + await CoreLoginHelper.instance.acceptSitePolicy(this.siteId); + + // Success accepting, go to site initial page. + // Invalidate cache since some WS don't return error if site policy is not accepted. + await CoreUtils.instance.ignoreErrors(this.currentSite!.invalidateWsCache()); + + await CoreLoginHelper.instance.goToSiteInitialPage(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error accepting site policy.'); + } finally { + modal.dismiss(); + } + } + +} diff --git a/src/app/services/utils/iframe.ts b/src/app/services/utils/iframe.ts index eae0e490b..f419f21f1 100644 --- a/src/app/services/utils/iframe.ts +++ b/src/app/services/utils/iframe.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { NavController } from '@ionic/angular'; import { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript'; +import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies'; import { CoreApp } from '@services/app'; import { CoreFile } from '@services/file'; @@ -476,6 +477,36 @@ export class CoreIframeUtilsProvider { window.addEventListener('message', this.handleIframeMessage.bind(this)); } + /** + * Fix cookies for an iframe URL. + * + * @param url URL of the iframe. + * @return Promise resolved when done. + */ + async fixIframeCookies(url: string): Promise { + if (!CoreApp.instance.isIOS() || !url || CoreUrlUtils.instance.isLocalFileUrl(url)) { + // No need to fix cookies. + return; + } + + // Save a "fake" cookie for the iframe's domain to fix a bug in WKWebView. + try { + const win = window; + const urlParts = CoreUrl.parse(url); + + if (urlParts?.domain && win.WKWebViewCookies) { + await win.WKWebViewCookies.setCookie({ + name: 'MoodleAppCookieForWKWebView', + value: '1', + domain: urlParts.domain, + }); + } + } catch (err) { + // Ignore errors. + this.logger.error('Error setting cookie', err); + } + } + } export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {} From 1d105b72990088505b94aa4598c327cee65de182 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 28 Oct 2020 15:47:37 +0100 Subject: [PATCH 07/12] MOBILE-3565 login: Implement some todo functions of login helper --- src/app/core/login/pages/init/init.page.ts | 2 +- src/app/core/login/services/helper.ts | 146 ++++++++++++++++++--- src/app/services/app.ts | 18 ++- 3 files changed, 144 insertions(+), 22 deletions(-) diff --git a/src/app/core/login/pages/init/init.page.ts b/src/app/core/login/pages/init/init.page.ts index 4b702488a..51b7dd141 100644 --- a/src/app/core/login/pages/init/init.page.ts +++ b/src/app/core/login/pages/init/init.page.ts @@ -88,7 +88,7 @@ export class CoreLoginInitPage implements OnInit { // Site doesn't exist. return this.loadPage(); } - } else { + } else if (redirectData.page) { // No site to load, open the page. return CoreLoginHelper.instance.goToNoSitePage(redirectData.page, redirectData.params); } diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index 5bc26dad5..c704d5d35 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -52,7 +52,7 @@ export class CoreLoginHelperProvider { protected logger: CoreLogger; protected isSSOConfirmShown = false; protected isOpenEditAlertShown = false; - protected pageToLoad?: {page: string; params: Params; time: number}; // Page to load once main menu is opened. + protected pageToLoad?: {page: string; params?: Params; time: number}; // Page to load once main menu is opened. protected isOpeningReconnect = false; waitingForBrowser = false; @@ -123,7 +123,13 @@ export class CoreLoginHelperProvider { * Function called when an SSO InAppBrowser is closed or the app is resumed. Check if user needs to be logged out. */ checkLogout(): void { - // @todo + const currentSite = CoreSites.instance.getCurrentSite(); + const currentPage = CoreApp.instance.getCurrentPage(); + + if (!CoreApp.instance.isSSOAuthenticationOngoing() && currentSite?.isLoggedOut() && currentPage == 'login/reconnect') { + // User must reauthenticate but he closed the InAppBrowser without doing so, logout him. + CoreSites.instance.logout(); + } } /** @@ -444,10 +450,38 @@ export class CoreLoginHelperProvider { * @param params Params of the page. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - goToNoSitePage(page?: string, params?: Params): Promise { - // @todo - return Promise.resolve(); + async goToNoSitePage(page: string, params?: Params): Promise { + const currentPage = CoreApp.instance.getCurrentPage(); + + if (currentPage == page) { + // Already at page, nothing to do. + } else if (page == '/login/sites') { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + } else if (page == '/login/credentials' && currentPage == '/login/site') { + // Just open the new page to keep the navigation history. + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + // Check if there is any site stored. + const hasSites = await CoreSites.instance.hasSites(); + + if (!hasSites) { + // There are sites stored, open sites page first to be able to go back. + await this.navCtrl.navigateRoot('/login/sites'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + if (page != '/login/site') { + // Open the new site page to be able to go back. + await this.navCtrl.navigateRoot('/login/site'); + + await this.navCtrl.navigateForward(page, { queryParams: params }); + } else { + // Just open the page as root. + await this.navCtrl.navigateRoot(page, { queryParams: params }); + } + } + } } /** @@ -611,15 +645,38 @@ export class CoreLoginHelperProvider { /** * Load a site and load a certain page in that site. * + * @param siteId Site to load. * @param page Name of the page to load. * @param params Params to pass to the page. - * @param siteId Site to load. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected loadSiteAndPage(page: string, params: Params, siteId: string): Promise { - // @todo - return Promise.resolve(); + protected async loadSiteAndPage(siteId: string, page: string, params?: Params): Promise { + if (siteId == CoreConstants.NO_SITE_ID) { + // Page doesn't belong to a site, just load the page. + await this.navCtrl.navigateRoot(page, params); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading(); + + try { + const loggedIn = await CoreSites.instance.loadSite(siteId, page, params); + + if (!loggedIn) { + return; + } + + await this.openMainMenu({ + redirectPage: page, + redirectParams: params, + }); + } catch (error) { + // Site doesn't exist. + await this.navCtrl.navigateRoot('/login/sites'); + } finally { + modal.dismiss(); + } } /** @@ -628,7 +685,7 @@ export class CoreLoginHelperProvider { * @param page Name of the page to load. * @param params Params to pass to the page. */ - loadPageInMainMenu(page: string, params: Params): void { + loadPageInMainMenu(page: string, params?: Params): void { if (!CoreApp.instance.isMainMenuOpen()) { // Main menu not open. Store the page to be loaded later. this.pageToLoad = { @@ -827,9 +884,20 @@ export class CoreLoginHelperProvider { * * @param siteId The site ID. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - passwordChangeForced(siteId: string): void { - // @todo + async passwordChangeForced(siteId: string): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + if (!currentSite || siteId !== currentSite.getId()) { + return; // Site that triggered the event is not current site. + } + + const currentPage = CoreApp.instance.getCurrentPage(); + + // If current page is already change password, stop. + if (currentPage == '/login/changepassword') { + return; + } + + await this.navCtrl.navigateRoot('/login/changepassword', { queryParams: { siteId } }); } /** @@ -886,9 +954,26 @@ export class CoreLoginHelperProvider { * @param siteId Site to load. If not defined, current site. * @return Promise resolved when done. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars async redirect(page: string, params?: Params, siteId?: string): Promise { - // @todo + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (CoreSites.instance.isLoggedIn()) { + if (siteId && siteId != CoreSites.instance.getCurrentSiteId()) { + // Target page belongs to a different site. Change site. + // @todo: Check site plugins. + await CoreSites.instance.logout(); + + await this.loadSiteAndPage(siteId, page, params); + } else { + this.loadPageInMainMenu(page, params); + } + } else { + if (siteId) { + await this.loadSiteAndPage(siteId, page, params); + } else { + await this.navCtrl.navigateRoot('/login/sites'); + } + } } /** @@ -1013,7 +1098,25 @@ export class CoreLoginHelperProvider { const info = currentSite.getInfo(); if (typeof info != 'undefined' && typeof info.username != 'undefined' && !this.isOpeningReconnect) { - // @todo + // If current page is already reconnect, stop. + if (CoreApp.instance.getCurrentPage() == '/login/reconnect') { + return; + } + + this.isOpeningReconnect = true; + + await CoreUtils.instance.ignoreErrors(this.navCtrl.navigateRoot('/login/reconnect', { + queryParams: { + infoSiteUrl: info.siteurl, + siteUrl: result.siteUrl, + siteId: siteId, + pageName: data.pageName, + pageParams: data.params, + siteConfig: result.config, + }, + })); + + this.isOpeningReconnect = false; } } } catch (error) { @@ -1166,7 +1269,12 @@ export class CoreLoginHelperProvider { return; } - // @todo Navigate to site policy page. + // If current page is already site policy, stop. + if (CoreApp.instance.getCurrentPage() == '/login/sitepolicy') { + return; + } + + this.navCtrl.navigateRoot('/login/sitepolicy', { queryParams: { siteId: siteId } }); } /** diff --git a/src/app/services/app.ts b/src/app/services/app.ts index db708eef2..489cb067f 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -13,12 +13,13 @@ // limitations under the License. import { Injectable, NgZone, ApplicationRef } from '@angular/core'; -import { Params } from '@angular/router'; +import { Params, Router } from '@angular/router'; import { Connection } from '@ionic-native/network/ngx'; import { CoreDB } from '@services/db'; import { CoreEvents } from '@singletons/events'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreUrlUtils } from '@services/utils/url'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreConstants } from '@core/constants'; @@ -56,7 +57,11 @@ export class CoreAppProvider { // Variables for DB. protected createVersionsTableReady: Promise; - constructor(appRef: ApplicationRef, zone: NgZone) { + constructor( + appRef: ApplicationRef, + zone: NgZone, + protected router: Router, + ) { this.logger = CoreLogger.getInstance('CoreAppProvider'); this.db = CoreDB.instance.getDB(DBNAME); @@ -185,6 +190,15 @@ export class CoreAppProvider { await this.db.insertRecord(SCHEMA_VERSIONS_TABLE_NAME, { name: schema.name, version: schema.version }); } + /** + * Get current page route without params. + * + * @return Current page route. + */ + getCurrentPage(): string { + return CoreUrlUtils.instance.removeUrlParams(this.router.url); + } + /** * Get the application global database. * From 51e220f497c86ca25dcd665f24025d9313e77265 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 08:35:01 +0100 Subject: [PATCH 08/12] MOBILE-3565 tests: Add some unit tests for URL utils --- src/app/components/tests/icon.test.ts | 5 +- .../core/settings/pages/about/about.page.ts | 1 - src/app/services/tests/utils/url.test.ts | 79 +++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 src/app/services/tests/utils/url.test.ts diff --git a/src/app/components/tests/icon.test.ts b/src/app/components/tests/icon.test.ts index e7157feda..531bbc9fc 100644 --- a/src/app/components/tests/icon.test.ts +++ b/src/app/components/tests/icon.test.ts @@ -26,9 +26,10 @@ describe('CoreIconComponent', () => { expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0); const icon = fixture.nativeElement.querySelector('ion-icon'); + const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || ''; + expect(icon).not.toBeNull(); - expect(icon.classList.contains('fa')).toBe(true); - expect(icon.classList.contains('fa-thumbs-up')).toBe(true); + expect(name).toEqual('fa-thumbs-up'); expect(icon.getAttribute('role')).toEqual('presentation'); }); diff --git a/src/app/core/settings/pages/about/about.page.ts b/src/app/core/settings/pages/about/about.page.ts index bc35c3244..73eedee61 100644 --- a/src/app/core/settings/pages/about/about.page.ts +++ b/src/app/core/settings/pages/about/about.page.ts @@ -16,7 +16,6 @@ import { CoreSites } from '@services/sites'; import { Component } from '@angular/core'; import { Router } from '@angular/router'; import { CoreConstants } from '@core/constants'; -import { CoreApp } from '@services/app'; @Component({ selector: 'settings-about', diff --git a/src/app/services/tests/utils/url.test.ts b/src/app/services/tests/utils/url.test.ts new file mode 100644 index 000000000..50b3fea57 --- /dev/null +++ b/src/app/services/tests/utils/url.test.ts @@ -0,0 +1,79 @@ +// (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 { CoreUrlUtilsProvider } from '@services/utils/url'; + +describe('CoreUrlUtilsProvider', () => { + + let urlUtils: CoreUrlUtilsProvider; + + beforeEach(() => { + urlUtils = new CoreUrlUtilsProvider(); + }); + + it('adds www if missing', () => { + const originalUrl = 'https://moodle.org'; + const url = urlUtils.addOrRemoveWWW(originalUrl); + + expect(url).toEqual('https://www.moodle.org'); + }); + + it('removes www if present', () => { + const originalUrl = 'https://www.moodle.org'; + const url = urlUtils.addOrRemoveWWW(originalUrl); + + expect(url).toEqual('https://moodle.org'); + }); + + it('adds params to URL without params', () => { + const originalUrl = 'https://moodle.org'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params); + + expect(url).toEqual('https://moodle.org?first=1&second=2'); + }); + + it('adds params to URL with existing params', () => { + const originalUrl = 'https://moodle.org?existing=1'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params); + + expect(url).toEqual('https://moodle.org?existing=1&first=1&second=2'); + }); + + it('doesn\'t change URL if no params supplied', () => { + const originalUrl = 'https://moodle.org'; + const url = urlUtils.addParamsToUrl(originalUrl); + + expect(url).toEqual(originalUrl); + }); + + it('adds anchor to URL', () => { + const originalUrl = 'https://moodle.org'; + const params = { + first: '1', + second: '2', + }; + const url = urlUtils.addParamsToUrl(originalUrl, params, 'myanchor'); + + expect(url).toEqual('https://moodle.org?first=1&second=2#myanchor'); + }); + +}); From 232669855a668997745c421891a8d4d3227b0868 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 13:44:26 +0100 Subject: [PATCH 09/12] MOBILE-3565 components: Create input-errors, mark-required and recaptcha --- src/app/components/components.module.ts | 12 ++ .../input-errors/core-input-errors.html | 16 +++ .../components/input-errors/input-errors.scss | 16 +++ .../components/input-errors/input-errors.ts | 86 +++++++++++++ .../mark-required/core-mark-required.html | 3 + .../mark-required/mark-required.scss | 8 ++ .../components/mark-required/mark-required.ts | 78 +++++++++++ .../components/recaptcha/core-recaptcha.html | 13 ++ .../recaptcha/core-recaptchamodal.html | 14 ++ src/app/components/recaptcha/recaptcha.ts | 85 ++++++++++++ .../components/recaptcha/recaptchamodal.ts | 121 ++++++++++++++++++ 11 files changed, 452 insertions(+) create mode 100644 src/app/components/input-errors/core-input-errors.html create mode 100644 src/app/components/input-errors/input-errors.scss create mode 100644 src/app/components/input-errors/input-errors.ts create mode 100644 src/app/components/mark-required/core-mark-required.html create mode 100644 src/app/components/mark-required/mark-required.scss create mode 100644 src/app/components/mark-required/mark-required.ts create mode 100644 src/app/components/recaptcha/core-recaptcha.html create mode 100644 src/app/components/recaptcha/core-recaptchamodal.html create mode 100644 src/app/components/recaptcha/recaptcha.ts create mode 100644 src/app/components/recaptcha/recaptchamodal.ts diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts index a6bf120a8..53d44a614 100644 --- a/src/app/components/components.module.ts +++ b/src/app/components/components.module.ts @@ -19,7 +19,11 @@ import { TranslateModule } from '@ngx-translate/core'; import { CoreIconComponent } from './icon/icon'; import { CoreIframeComponent } from './iframe/iframe'; +import { CoreInputErrorsComponent } from './input-errors/input-errors'; import { CoreLoadingComponent } from './loading/loading'; +import { CoreMarkRequiredComponent } from './mark-required/mark-required'; +import { CoreRecaptchaComponent } from './recaptcha/recaptcha'; +import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal'; import { CoreShowPasswordComponent } from './show-password/show-password'; import { CoreEmptyBoxComponent } from './empty-box/empty-box'; import { CoreDirectivesModule } from '@app/directives/directives.module'; @@ -29,7 +33,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; declarations: [ CoreIconComponent, CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], @@ -43,7 +51,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module'; exports: [ CoreIconComponent, CoreIframeComponent, + CoreInputErrorsComponent, CoreLoadingComponent, + CoreMarkRequiredComponent, + CoreRecaptchaComponent, + CoreRecaptchaModalComponent, CoreShowPasswordComponent, CoreEmptyBoxComponent, ], diff --git a/src/app/components/input-errors/core-input-errors.html b/src/app/components/input-errors/core-input-errors.html new file mode 100644 index 000000000..9f216a964 --- /dev/null +++ b/src/app/components/input-errors/core-input-errors.html @@ -0,0 +1,16 @@ + diff --git a/src/app/components/input-errors/input-errors.scss b/src/app/components/input-errors/input-errors.scss new file mode 100644 index 000000000..de3f96567 --- /dev/null +++ b/src/app/components/input-errors/input-errors.scss @@ -0,0 +1,16 @@ +:host { + width: 100%; + + .core-input-error-container { + .core-input-error { + padding: 4px; + color: var(--ion-color-danger); + font-size: 12px; + display: none; + + &:first-child { + display: block; + } + } + } +} diff --git a/src/app/components/input-errors/input-errors.ts b/src/app/components/input-errors/input-errors.ts new file mode 100644 index 000000000..1bad391ab --- /dev/null +++ b/src/app/components/input-errors/input-errors.ts @@ -0,0 +1,86 @@ +// (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, OnChanges, SimpleChange } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Component to show errors if an input isn't valid. + * + * @description + * The purpose of this component is to make easier and consistent the validation of forms. + * + * It should be applied next to the input element (ion-input, ion-select, ...). In case of ion-checkbox, it should be in another + * item, placing it in the same item as the checkbox will cause problems. + * + * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component. + * + * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content", + * otherwise Ionic will remove it. + * + * Example usage: + * + * + * {{ 'core.login.username' | translate }} + * + * + * + */ +@Component({ + selector: 'core-input-errors', + templateUrl: 'core-input-errors.html', + styleUrls: ['input-errors.scss'], +}) +export class CoreInputErrorsComponent implements OnChanges { + + @Input() control?: FormControl; + @Input() errorMessages?: Record; + @Input() errorText?: string; // Set other non automatic errors. + errorKeys: string[] = []; + + /** + * Initialize some common errors if they aren't set. + */ + protected initErrorMessages(): void { + this.errorMessages = this.errorMessages || {}; + + this.errorMessages.required = this.errorMessages.required || Translate.instance.instant('core.required'); + this.errorMessages.email = this.errorMessages.email || Translate.instance.instant('core.login.invalidemail'); + this.errorMessages.date = this.errorMessages.date || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetime = this.errorMessages.datetime || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.datetimelocal = this.errorMessages.datetimelocal || Translate.instance.instant('core.login.invaliddate'); + this.errorMessages.time = this.errorMessages.time || Translate.instance.instant('core.login.invalidtime'); + this.errorMessages.url = this.errorMessages.url || Translate.instance.instant('core.login.invalidurl'); + + // Set empty values by default, the default error messages will be built in the template when needed. + this.errorMessages.max = this.errorMessages.max || ''; + this.errorMessages.min = this.errorMessages.min || ''; + } + + /** + * Component being changed. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if ((changes.control || changes.errorMessages) && this.control) { + this.initErrorMessages(); + + this.errorKeys = this.errorMessages ? Object.keys(this.errorMessages) : []; + } + if (changes.errorText) { + this.errorText = changes.errorText.currentValue; + } + } + +} diff --git a/src/app/components/mark-required/core-mark-required.html b/src/app/components/mark-required/core-mark-required.html new file mode 100644 index 000000000..bf5cd4897 --- /dev/null +++ b/src/app/components/mark-required/core-mark-required.html @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/mark-required/mark-required.scss b/src/app/components/mark-required/mark-required.scss new file mode 100644 index 000000000..def9d3ecb --- /dev/null +++ b/src/app/components/mark-required/mark-required.scss @@ -0,0 +1,8 @@ +:host { + .core-input-required-asterisk { + font-size: 8px; + --padding-start: 4px; + line-height: 100%; + vertical-align: top; + } +} diff --git a/src/app/components/mark-required/mark-required.ts b/src/app/components/mark-required/mark-required.ts new file mode 100644 index 000000000..c62e30bd6 --- /dev/null +++ b/src/app/components/mark-required/mark-required.ts @@ -0,0 +1,78 @@ +// (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, AfterViewInit, ElementRef } from '@angular/core'; + +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Directive to add a red asterisk for required input fields. + * + * @description + * For forms with required and not required fields, it is recommended to use this directive to mark the required ones. + * + * This directive should be applied in the label. Example: + * + * {{ 'core.login.username' | translate }} + */ +@Component({ + selector: '[core-mark-required]', + templateUrl: 'core-mark-required.html', + styleUrls: ['mark-required.scss'], +}) +export class CoreMarkRequiredComponent implements OnInit, AfterViewInit { + + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('core-mark-required') coreMarkRequired: boolean | string = true; + + protected element: HTMLElement; + requiredLabel?: string; + + constructor( + element: ElementRef, + ) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.requiredLabel = Translate.instance.instant('core.required'); + this.coreMarkRequired = CoreUtils.instance.isTrueOrOne(this.coreMarkRequired); + } + + /** + * Called after the view is initialized. + */ + ngAfterViewInit(): void { + if (this.coreMarkRequired) { + // Add the "required" to the aria-label. + const ariaLabel = this.element.getAttribute('aria-label') || + CoreTextUtils.instance.cleanTags(this.element.innerHTML, true); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel + ' ' + this.requiredLabel); + } + } else { + // Remove the "required" from the aria-label. + const ariaLabel = this.element.getAttribute('aria-label'); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel.replace(' ' + this.requiredLabel, '')); + } + } + } + +} diff --git a/src/app/components/recaptcha/core-recaptcha.html b/src/app/components/recaptcha/core-recaptcha.html new file mode 100644 index 000000000..d094e17dc --- /dev/null +++ b/src/app/components/recaptcha/core-recaptcha.html @@ -0,0 +1,13 @@ + +
      + + + {{ 'core.resourcedisplayopen' | translate }} + + + {{ 'core.answered' | translate }} + + + {{ 'core.login.recaptchaexpired' | translate }} + +
      diff --git a/src/app/components/recaptcha/core-recaptchamodal.html b/src/app/components/recaptcha/core-recaptchamodal.html new file mode 100644 index 000000000..e49d8989b --- /dev/null +++ b/src/app/components/recaptcha/core-recaptchamodal.html @@ -0,0 +1,14 @@ + + + {{ 'core.login.security_question' | translate }} + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/components/recaptcha/recaptcha.ts b/src/app/components/recaptcha/recaptcha.ts new file mode 100644 index 000000000..8edc8b2b0 --- /dev/null +++ b/src/app/components/recaptcha/recaptcha.ts @@ -0,0 +1,85 @@ +// (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 { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { ModalController } from '@singletons/core.singletons'; +import { CoreRecaptchaModalComponent } from './recaptchamodal'; + +/** + * Component that allows answering a recaptcha. + */ +@Component({ + selector: 'core-recaptcha', + templateUrl: 'core-recaptcha.html', +}) +export class CoreRecaptchaComponent implements OnInit { + + @Input() model?: Record; // The model where to store the recaptcha response. + @Input() publicKey?: string; // The site public key. + @Input() modelValueName = 'recaptcharesponse'; // Name of the model property where to store the response. + @Input() siteUrl?: string; // The site URL. If not defined, current site. + + expired = false; + + protected lang?: string; + + constructor() { + this.initLang(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.siteUrl = this.siteUrl || CoreSites.instance.getCurrentSite()?.getURL(); + } + + /** + * Initialize the lang property. + */ + protected async initLang(): Promise { + this.lang = await CoreLang.instance.getCurrentLanguage(); + } + + /** + * Open the recaptcha modal. + */ + async answerRecaptcha(): Promise { + // Set the iframe src. We use an iframe because reCaptcha V2 doesn't work with file:// protocol. + const src = CoreTextUtils.instance.concatenatePaths(this.siteUrl!, 'webservice/recaptcha.php?lang=' + this.lang); + + // Modal to answer the recaptcha. + // This is because the size of the recaptcha is dynamic, so it could cause problems if it was displayed inline. + + const modal = await ModalController.instance.create({ + component: CoreRecaptchaModalComponent, + cssClass: 'core-modal-fullscreen', + componentProps: { + recaptchaUrl: src, + }, + }); + + await modal.present(); + + const result = await modal.onWillDismiss(); + + this.expired = result.data.expired; + this.model![this.modelValueName] = result.data.value; + } + +} diff --git a/src/app/components/recaptcha/recaptchamodal.ts b/src/app/components/recaptcha/recaptchamodal.ts new file mode 100644 index 000000000..5a56bc2a0 --- /dev/null +++ b/src/app/components/recaptcha/recaptchamodal.ts @@ -0,0 +1,121 @@ +// (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, OnDestroy } from '@angular/core'; + +import { ModalController } from '@singletons/core.singletons'; + +/** + * Component to display a the recaptcha in a modal. + */ +@Component({ + selector: 'core-recaptcha-modal', + templateUrl: 'core-recaptchamodal.html', +}) +export class CoreRecaptchaModalComponent implements OnDestroy { + + @Input() recaptchaUrl?: string; + + expired = false; + value = ''; + + protected messageListenerFunction: (event: MessageEvent) => Promise; + + constructor() { + // Listen for messages from the iframe. + this.messageListenerFunction = this.onIframeMessage.bind(this); + window.addEventListener('message', this.messageListenerFunction); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.instance.dismiss({ + expired: this.expired, + value: this.value, + }); + } + + /** + * The iframe with the recaptcha was loaded. + * + * @param iframe Iframe element. + */ + loaded(iframe: HTMLIFrameElement): void { + // Search the iframe content. + const contentWindow = iframe?.contentWindow; + + if (contentWindow) { + try { + // Set the callbacks we're interested in. + contentWindow['recaptchacallback'] = this.onRecaptchaCallback.bind(this); + contentWindow['recaptchaexpiredcallback'] = this.onRecaptchaExpiredCallback.bind(this); + } catch (error) { + // Cannot access the window. + } + } + } + + /** + * Treat an iframe message event. + * + * @param event Event. + * @return Promise resolved when done. + */ + protected async onIframeMessage(event: MessageEvent): Promise { + if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'recaptcha') { + return; + } + + switch (event.data.action) { + case 'callback': + this.onRecaptchaCallback(event.data.value); + break; + case 'expired': + this.onRecaptchaExpiredCallback(); + break; + + default: + break; + } + } + + /** + * Recapcha callback called. + * + * @param value Value received. + */ + protected onRecaptchaCallback(value: string): void { + this.expired = false; + this.value = value; + this.closeModal(); + } + + /** + * Recapcha expired callback called. + */ + protected onRecaptchaExpiredCallback(): void { + this.expired = true; + this.value = ''; + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + window.removeEventListener('message', this.messageListenerFunction); + } + +} From a6467f50730d09f75a04cb95e12f02832e553767 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 13:45:55 +0100 Subject: [PATCH 10/12] MOBILE-3565 login: Implement signup page --- src/app/core/login/login-routing.module.ts | 4 + .../login/pages/credentials/credentials.html | 7 +- .../pages/credentials/credentials.page.ts | 7 - .../pages/email-signup/email-signup.html | 238 ++++++++++ .../pages/email-signup/email-signup.module.ts | 50 +++ .../pages/email-signup/email-signup.page.ts | 419 ++++++++++++++++++ src/app/core/login/services/helper.ts | 6 +- src/app/services/utils/dom.ts | 10 +- src/app/services/utils/utils.ts | 10 +- src/theme/app.scss | 9 + 10 files changed, 743 insertions(+), 17 deletions(-) create mode 100644 src/app/core/login/pages/email-signup/email-signup.html create mode 100644 src/app/core/login/pages/email-signup/email-signup.module.ts create mode 100644 src/app/core/login/pages/email-signup/email-signup.page.ts diff --git a/src/app/core/login/login-routing.module.ts b/src/app/core/login/login-routing.module.ts index dd76dd2b0..b123f3b93 100644 --- a/src/app/core/login/login-routing.module.ts +++ b/src/app/core/login/login-routing.module.ts @@ -51,6 +51,10 @@ const routes: Routes = [ path: 'sitepolicy', loadChildren: () => import('./pages/site-policy/site-policy.module').then( m => m.CoreLoginSitePolicyPageModule), }, + { + path: 'emailsignup', + loadChildren: () => import('./pages/email-signup/email-signup.module').then( m => m.CoreLoginEmailSignupPageModule), + }, ]; @NgModule({ diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html index b0c745f51..3abc3c495 100644 --- a/src/app/core/login/pages/credentials/credentials.html +++ b/src/app/core/login/pages/credentials/credentials.html @@ -33,7 +33,10 @@
      - + + @@ -72,7 +75,7 @@

      - + {{ 'core.login.startsignup' | translate }}
      diff --git a/src/app/core/login/pages/credentials/credentials.page.ts b/src/app/core/login/pages/credentials/credentials.page.ts index 00df73ea0..fd8bfeceb 100644 --- a/src/app/core/login/pages/credentials/credentials.page.ts +++ b/src/app/core/login/pages/credentials/credentials.page.ts @@ -279,13 +279,6 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { } } - /** - * Signup button was clicked. - */ - signup(): void { - // @todo Go to signup. - } - /** * Show instructions and scan QR code. */ diff --git a/src/app/core/login/pages/email-signup/email-signup.html b/src/app/core/login/pages/email-signup/email-signup.html new file mode 100644 index 000000000..71287fb01 --- /dev/null +++ b/src/app/core/login/pages/email-signup/email-signup.html @@ -0,0 +1,238 @@ + + + + + + + {{ 'core.login.newaccount' | translate }} + + + + + + + + + + + + + + + + + + + + + {{ 'core.login.signuprequiredfieldnotsupported' | translate }} + + + + {{ 'core.openinbrowser' | translate }} + + + + +
      + + +

      {{ 'core.agelocationverification' | translate }}

      +
      + + + + {{ 'core.whatisyourage' | translate }} + + + + + + + + {{ 'core.wheredoyoulive' | translate }} + + + {{ 'core.login.selectacountry' | translate }} + {{country.name}} + + + + + + {{ 'core.proceed' | translate }} + + + + +

      {{ 'core.whyisthisrequired' | translate }}

      +

      {{ 'core.explanationdigitalminor' | translate }}

      +
      +
      +
      + + +
      + + + + +

      {{siteUrl}}

      + +

      + +

      +

      {{siteUrl}}

      +
      +
      + + + + {{ 'core.login.createuserandpass' | translate }} + + + + {{ 'core.login.username' | translate }} + + + + + + + + {{ 'core.login.password' | translate }} + + + + + +

      + {{settings.passwordpolicy}} +

      + +
      + + + + + {{ 'core.login.supplyinfo' | translate }} + + + + + {{ 'core.user.email' | translate }} + + + + + + + + {{ 'core.user.emailagain' | translate }} + + + + + + + + {{ 'core.user.' + nameField | translate }} + + + + + + + + {{ 'core.user.city' | translate }} + + + + + {{ 'core.user.country' | translate }} + + + {{ 'core.login.selectacountry' | translate }} + {{country.name}} + + + + + + + {{ category.name }} + + + + + + + + + {{ 'core.login.security_question' | translate }} + + + + + + + + + {{ 'core.login.policyagreement' | translate }} + + + + + {{ 'core.login.policyagreementclick' | translate }} + + + + + + {{ 'core.login.policyaccept' | translate }} + + + + + + + + + {{ 'core.login.createaccount' | translate }} + + +
      +
      + + + + +

      + +

      +
      +
      + + +

      {{ 'core.considereddigitalminor' | translate }}

      +

      {{ 'core.digitalminor_desc' | translate }}

      +

      {{ supportName }}

      +

      {{ supportEmail }}

      +
      +
      + + {{ 'core.openinbrowser' | translate }} + +
      +
      diff --git a/src/app/core/login/pages/email-signup/email-signup.module.ts b/src/app/core/login/pages/email-signup/email-signup.module.ts new file mode 100644 index 000000000..d5de916e7 --- /dev/null +++ b/src/app/core/login/pages/email-signup/email-signup.module.ts @@ -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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreLoginEmailSignupPage } from './email-signup.page'; + +const routes: Routes = [ + { + path: '', + component: CoreLoginEmailSignupPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + FormsModule, + ReactiveFormsModule, + CoreComponentsModule, + CoreDirectivesModule, + ], + declarations: [ + CoreLoginEmailSignupPage, + ], + exports: [RouterModule], +}) +export class CoreLoginEmailSignupPageModule {} diff --git a/src/app/core/login/pages/email-signup/email-signup.page.ts b/src/app/core/login/pages/email-signup/email-signup.page.ts new file mode 100644 index 000000000..0b5eb1103 --- /dev/null +++ b/src/app/core/login/pages/email-signup/email-signup.page.ts @@ -0,0 +1,419 @@ +// (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, ViewChild, ElementRef, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { NavController, IonContent, IonRefresher } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreCountry, CoreUtils } from '@services/utils/utils'; +import { CoreWS, CoreWSExternalWarning } from '@services/ws'; +import { AuthEmailSignupProfileFieldsCategory, AuthEmailSignupSettings, CoreLoginHelper } from '@core/login/services/helper'; +import { CoreConstants } from '@core/constants'; +import { Translate } from '@singletons/core.singletons'; +import { CoreSitePublicConfigResponse } from '@classes/site'; + +/** + * Page to signup using email. + */ +@Component({ + selector: 'page-core-login-email-signup', + templateUrl: 'email-signup.html', + styleUrls: ['../../login.scss'], +}) +export class CoreLoginEmailSignupPage implements OnInit { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild('ageForm') ageFormElement?: ElementRef; + @ViewChild('signupFormEl') signupFormElement?: ElementRef; + + signupForm: FormGroup; + siteUrl!: string; + siteConfig?: CoreSitePublicConfigResponse; + siteName?: string; + authInstructions?: string; + settings?: AuthEmailSignupSettings; + countries?: CoreCountry[]; + categories?: AuthEmailSignupProfileFieldsCategory[]; + settingsLoaded = false; + allRequiredSupported = true; + signupUrl?: string; + captcha = { + recaptcharesponse: '', + }; + + // Data for age verification. + ageVerificationForm: FormGroup; + countryControl: FormControl; + signUpCountryControl?: FormControl; + isMinor = false; // Whether the user is minor age. + ageDigitalConsentVerification?: boolean; // Whether the age verification is enabled. + supportName?: string; + supportEmail?: string; + + // Validation errors. + usernameErrors: Record; + passwordErrors: Record; + emailErrors: Record; + email2Errors: Record; + policyErrors: Record; + namefieldsErrors?: Record>; + + constructor( + protected navCtrl: NavController, + protected fb: FormBuilder, + protected route: ActivatedRoute, + ) { + // Create the ageVerificationForm. + this.ageVerificationForm = this.fb.group({ + age: ['', Validators.required], + }); + this.countryControl = this.fb.control('', Validators.required); + this.ageVerificationForm.addControl('country', this.countryControl); + + // Create the signupForm with the basic controls. More controls will be added later. + this.signupForm = this.fb.group({ + username: ['', Validators.required], + password: ['', Validators.required], + email: ['', Validators.compose([Validators.required, Validators.email])], + email2: ['', Validators.compose([Validators.required, Validators.email])], + }); + + // Setup validation errors. + this.usernameErrors = CoreLoginHelper.instance.getErrorMessages('core.login.usernamerequired'); + this.passwordErrors = CoreLoginHelper.instance.getErrorMessages('core.login.passwordrequired'); + this.emailErrors = CoreLoginHelper.instance.getErrorMessages('core.login.missingemail'); + this.policyErrors = CoreLoginHelper.instance.getErrorMessages('core.login.policyagree'); + this.email2Errors = CoreLoginHelper.instance.getErrorMessages( + 'core.login.missingemail', + undefined, + 'core.login.emailnotmatch', + ); + } + + /** + * Component initialized. + */ + ngOnInit(): void { + this.siteUrl = this.route.snapshot.queryParams['siteUrl']; + + // Fetch the data. + this.fetchData().finally(() => { + this.settingsLoaded = true; + }); + } + + /** + * Complete the FormGroup using the settings received from server. + */ + protected completeFormGroup(): void { + this.signupForm.addControl('city', this.fb.control(this.settings?.defaultcity || '')); + this.signUpCountryControl = this.fb.control(this.settings?.country || ''); + this.signupForm.addControl('country', this.signUpCountryControl); + + // Add the name fields. + for (const i in this.settings?.namefields) { + this.signupForm.addControl(this.settings?.namefields[i], this.fb.control('', Validators.required)); + } + + if (this.settings?.sitepolicy) { + this.signupForm.addControl('policyagreed', this.fb.control(false, Validators.requiredTrue)); + } + } + + /** + * Fetch the required data from the server. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + try { + // Get site config. + this.siteConfig = await CoreSites.instance.getSitePublicConfig(this.siteUrl); + this.signupUrl = CoreTextUtils.instance.concatenatePaths(this.siteConfig.httpswwwroot, 'login/signup.php'); + + if (this.treatSiteConfig()) { + // Check content verification. + if (typeof this.ageDigitalConsentVerification == 'undefined') { + + const result = await CoreUtils.instance.ignoreErrors( + CoreWS.instance.callAjax( + 'core_auth_is_age_digital_consent_verification_enabled', + {}, + { siteUrl: this.siteUrl }, + ), + ); + + this.ageDigitalConsentVerification = !!result?.status; + } + + await this.getSignupSettings(); + } + + this.completeFormGroup(); + } catch (error) { + if (this.allRequiredSupported) { + CoreDomUtils.instance.showErrorModal(error); + } + } + } + + /** + * Get signup settings from server. + * + * @return Promise resolved when done. + */ + protected async getSignupSettings(): Promise { + this.settings = await CoreWS.instance.callAjax( + 'auth_email_get_signup_settings', + {}, + { siteUrl: this.siteUrl }, + ); + + // @todo userProfileFieldDelegate + + this.categories = CoreLoginHelper.instance.formatProfileFieldsForSignup(this.settings.profilefields); + + if (this.settings.recaptchapublickey) { + this.captcha.recaptcharesponse = ''; // Reset captcha. + } + + if (!this.countryControl.value) { + this.countryControl.setValue(this.settings.country || ''); + } + + this.namefieldsErrors = {}; + if (this.settings.namefields) { + this.settings.namefields.forEach((field) => { + this.namefieldsErrors![field] = CoreLoginHelper.instance.getErrorMessages('core.login.missing' + field); + }); + } + + this.countries = await CoreUtils.instance.getCountryListSorted(); + } + + /** + * Treat the site config, checking if it's valid and extracting the data we're interested in. + * + * @return True if success. + */ + protected treatSiteConfig(): boolean { + if (this.siteConfig?.registerauth == 'email' && !CoreLoginHelper.instance.isEmailSignupDisabled(this.siteConfig)) { + this.siteName = CoreConstants.CONFIG.sitename ? CoreConstants.CONFIG.sitename : this.siteConfig.sitename; + this.authInstructions = this.siteConfig.authinstructions; + this.ageDigitalConsentVerification = this.siteConfig.agedigitalconsentverification; + this.supportName = this.siteConfig.supportname; + this.supportEmail = this.siteConfig.supportemail; + this.countryControl.setValue(this.siteConfig.country || ''); + + return true; + } else { + CoreDomUtils.instance.showErrorModal( + Translate.instance.instant( + 'core.login.signupplugindisabled', + { $a: Translate.instance.instant('core.login.auth_email') }, + ), + ); + this.navCtrl.pop(); + + return false; + } + } + + /** + * Pull to refresh. + * + * @param event Event. + */ + refreshSettings(event?: CustomEvent): void { + this.fetchData().finally(() => { + event?.detail.complete(); + }); + } + + /** + * Create account. + * + * @param e Event. + * @return Promise resolved when done. + */ + async create(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + if (!this.signupForm.valid || (this.settings?.recaptchapublickey && !this.captcha.recaptcharesponse)) { + // Form not valid. Scroll to the first element with errors. + const errorFound = await CoreDomUtils.instance.scrollToInputError(this.content); + + if (!errorFound) { + // Input not found, show an error modal. + CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); + } + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + const params: Record = { + username: this.signupForm.value.username.trim().toLowerCase(), + password: this.signupForm.value.password, + firstname: CoreTextUtils.instance.cleanTags(this.signupForm.value.firstname), + lastname: CoreTextUtils.instance.cleanTags(this.signupForm.value.lastname), + email: this.signupForm.value.email.trim(), + city: CoreTextUtils.instance.cleanTags(this.signupForm.value.city), + country: this.signupForm.value.country, + }; + + if (this.siteConfig?.launchurl) { + const service = CoreSites.instance.determineService(this.siteUrl); + params.redirect = CoreLoginHelper.instance.prepareForSSOLogin(this.siteUrl, service, this.siteConfig.launchurl); + } + + // Get the recaptcha response (if needed). + if (this.settings?.recaptchapublickey && this.captcha.recaptcharesponse) { + params.recaptcharesponse = this.captcha.recaptcharesponse; + } + + try { + // @todo Get the data for the custom profile fields. + const result = await CoreWS.instance.callAjax( + 'auth_email_signup_user', + params, + { siteUrl: this.siteUrl }, + ); + + if (result.success) { + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.signupFormElement, true); + + // Show alert and ho back. + const message = Translate.instance.instant('core.login.emailconfirmsent', { $a: params.email }); + CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), message); + this.navCtrl.pop(); + } else { + if (result.warnings && result.warnings.length) { + let error = result.warnings[0].message; + if (error == 'incorrect-captcha-sol') { + error = Translate.instance.instant('core.login.recaptchaincorrect'); + } + + CoreDomUtils.instance.showErrorModal(error); + } else { + CoreDomUtils.instance.showErrorModal('core.login.usernotaddederror', true); + } + } + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.login.usernotaddederror', true); + } finally { + modal.dismiss(); + } + } + + /** + * Escape mail to avoid special characters to be treated as a RegExp. + * + * @param text Initial mail. + * @return Escaped mail. + */ + escapeMail(text: string): string { + return CoreTextUtils.instance.escapeForRegex(text); + } + + /** + * Show authentication instructions. + */ + protected showAuthInstructions(): void { + CoreTextUtils.instance.viewText(Translate.instance.instant('core.login.instructions'), this.authInstructions!); + } + + /** + * Show contact information on site (we have to display again the age verification form). + */ + showContactOnSite(): void { + CoreUtils.instance.openInBrowser(CoreTextUtils.instance.concatenatePaths(this.siteUrl, '/login/verify_age_location.php')); + } + + /** + * Verify Age. + * + * @param e Event. + * @return Promise resolved when done. + */ + async verifyAge(e: Event): Promise { + e.preventDefault(); + e.stopPropagation(); + + if (!this.ageVerificationForm.valid) { + CoreDomUtils.instance.showErrorModal('core.errorinvalidform', true); + + return; + } + + const modal = await CoreDomUtils.instance.showModalLoading('core.sending', true); + + const params = this.ageVerificationForm.value; + + params.age = parseInt(params.age, 10); // Use just the integer part. + + try { + const result = await CoreWS.instance.callAjax('core_auth_is_minor', params, { siteUrl: this.siteUrl }); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.ageFormElement, true); + + if (!result.status) { + if (this.countryControl.value) { + this.signUpCountryControl!.setValue(this.countryControl.value); + } + + // Not a minor, go ahead. + this.ageDigitalConsentVerification = false; + } else { + // Is a minor. + this.isMinor = true; + } + } catch (error) { + // Something wrong, redirect to the site. + CoreDomUtils.instance.showErrorModal('There was an error verifying your age, please try again using the browser.'); + } finally { + modal.dismiss(); + } + } + +} + +/** + * Result of WS core_auth_is_age_digital_consent_verification_enabled. + */ +export type IsAgeVerificationEnabledResponse = { + status: boolean; // True if digital consent verification is enabled, false otherwise. +}; + +/** + * Result of WS auth_email_signup_user. + */ +export type SignupUserResult = { + success: boolean; // True if the user was created false otherwise. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Result of WS core_auth_is_minor. + */ +export type IsMinorResult = { + status: boolean; // True if the user is considered to be a digital minor, false if not. +}; diff --git a/src/app/core/login/services/helper.ts b/src/app/core/login/services/helper.ts index c704d5d35..de0026857 100644 --- a/src/app/core/login/services/helper.ts +++ b/src/app/core/login/services/helper.ts @@ -204,7 +204,7 @@ export class CoreLoginHelperProvider { * @param profileFields Profile fields to format. * @return Categories with the fields to show in each one. */ - formatProfileFieldsForSignup(profileFields: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { + formatProfileFieldsForSignup(profileFields?: AuthEmailSignupProfileField[]): AuthEmailSignupProfileFieldsCategory[] { if (!profileFields) { return []; } @@ -269,8 +269,8 @@ export class CoreLoginHelperProvider { maxlengthMsg?: string, minMsg?: string, maxMsg?: string, - ): any { - const errors: any = {}; + ): Record { + const errors: Record = {}; if (requiredMsg) { errors.required = errors.requiredTrue = Translate.instance.instant(requiredMsg); diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts index 86b2ccf07..a8ae50b82 100644 --- a/src/app/services/utils/dom.ts +++ b/src/app/services/utils/dom.ts @@ -1014,7 +1014,7 @@ export class CoreDomUtilsProvider { * @deprecated since 3.9.5. Use directly the IonContent class. */ scrollTo(content: IonContent, x: number, y: number, duration?: number): Promise { - return content?.scrollByPoint(x, y, duration || 0); + return content?.scrollToPoint(x, y, duration || 0); } /** @@ -1104,7 +1104,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration || 0); + content?.scrollToPoint(position[0], position[1], duration || 0); return true; } @@ -1124,6 +1124,8 @@ export class CoreDomUtilsProvider { scrollParentClass?: string, duration?: number, ): Promise { + // @todo: This function is broken. Scroll element cannot be used because it uses shadow DOM so querySelector returns null. + // Also, traversing using parentElement doesn't work either, offsetParent isn't part of the parentElement tree. try { const scrollElement = await content.getScrollElement(); @@ -1132,7 +1134,7 @@ export class CoreDomUtilsProvider { return false; } - content?.scrollByPoint(position[0], position[1], duration || 0); + content?.scrollToPoint(position[0], position[1], duration || 0); return true; } catch (error) { @@ -1147,7 +1149,7 @@ export class CoreDomUtilsProvider { * @param scrollParentClass Parent class where to stop calculating the position. Default inner-scroll. * @return True if the element is found, false otherwise. */ - async scrollToInputError(content: IonContent, scrollParentClass?: string): Promise { + async scrollToInputError(content?: IonContent, scrollParentClass?: string): Promise { if (!content) { return false; } diff --git a/src/app/services/utils/utils.ts b/src/app/services/utils/utils.ts index e8eedbb87..676f9855a 100644 --- a/src/app/services/utils/utils.ts +++ b/src/app/services/utils/utils.ts @@ -618,7 +618,7 @@ export class CoreUtilsProvider { * * @return Promise resolved with the list of countries. */ - getCountryListSorted(): Promise<{ code: string; name: string }[]> { + getCountryListSorted(): Promise { // Get the keys of the countries. return this.getCountryList().then((countries) => { // Sort translations. @@ -1659,3 +1659,11 @@ export type OrderedPromiseData = { */ blocking?: boolean; }; + +/** + * Data about a country. + */ +export type CoreCountry = { + code: string; + name: string; +}; diff --git a/src/theme/app.scss b/src/theme/app.scss index ec4d333c8..abf6df18c 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -90,3 +90,12 @@ ion-list.list-md { visibility: hidden; left: -1000px; } + +// Note on foot of ion-input. +.item .core-input-footnote { + width: 100%; + font-style: italic; + margin-top: 0; + margin-bottom: 10px; + font-size: 14px; +} From 5149e7465a09e88134e9f8de8d5199dd925b444b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 14:32:48 +0100 Subject: [PATCH 11/12] MOBILE-3565 router: Use corrected relativeLinkResolution --- src/app/app-routing.module.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index ed4cf9987..388b3be63 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -37,7 +37,10 @@ const routes: Routes = [ @NgModule({ imports: [ - RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules }), + RouterModule.forRoot(routes, { + preloadingStrategy: PreloadAllModules, + relativeLinkResolution: 'corrected', + }), ], exports: [RouterModule], }) From 893bb3c3a811a5fa1ede79bdd757ad81b41ae8bb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 29 Oct 2020 15:57:15 +0100 Subject: [PATCH 12/12] MOBILE-3565 core: Fix some template warnings --- .../login/pages/credentials/credentials.html | 21 +++++++++++++------ src/app/core/login/pages/site/site.html | 18 +++++++++++----- src/app/core/login/pages/sites/sites.html | 3 ++- src/app/core/mainmenu/pages/more/more.html | 8 +++++-- 4 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/app/core/login/pages/credentials/credentials.html b/src/app/core/login/pages/credentials/credentials.html index 3abc3c495..bf8dfa698 100644 --- a/src/app/core/login/pages/credentials/credentials.html +++ b/src/app/core/login/pages/credentials/credentials.html @@ -23,13 +23,16 @@ -

      +

      + +

      {{siteUrl}}