diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index aa88e95578e..b0135037c02 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -84,6 +84,7 @@ jobs: "@core_comments" "@core_course" "@core_courses" + "@core_dataprivacy" "@core_grades" "@core_login" "@core_mainmenu" diff --git a/scripts/langindex.json b/scripts/langindex.json index 79f8273a47c..3140fadfa2c 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1677,6 +1677,40 @@ "core.courses.totalcoursesearchresults": "local_moodlemobileapp", "core.currentdevice": "local_moodlemobileapp", "core.custom": "form", + "core.dataprivacy.cancelrequest": "tool_dataprivacy", + "core.dataprivacy.cancelrequestconfirmation": "tool_dataprivacy", + "core.dataprivacy.contactdataprotectionofficer": "tool_dataprivacy", + "core.dataprivacy.createnewdatarequest": "tool_dataprivacy", + "core.dataprivacy.datarequests": "tool_dataprivacy", + "core.dataprivacy.daterequested": "tool_dataprivacy", + "core.dataprivacy.deletemyaccount": "tool_dataprivacy", + "core.dataprivacy.message": "tool_dataprivacy", + "core.dataprivacy.newrequest": "tool_dataprivacy", + "core.dataprivacy.nodatarequests": "tool_dataprivacy", + "core.dataprivacy.pluginname": "tool_dataprivacy", + "core.dataprivacy.replyto": "tool_dataprivacy", + "core.dataprivacy.requestactions": "tool_dataprivacy", + "core.dataprivacy.requestby": "tool_dataprivacy", + "core.dataprivacy.requestcomments": "tool_dataprivacy", + "core.dataprivacy.requeststatus": "tool_dataprivacy", + "core.dataprivacy.requestsubmitted": "tool_dataprivacy", + "core.dataprivacy.requesttype": "tool_dataprivacy", + "core.dataprivacy.requesttype_help": "tool_dataprivacy", + "core.dataprivacy.requesttypedelete": "tool_dataprivacy", + "core.dataprivacy.requesttypeexport": "tool_dataprivacy", + "core.dataprivacy.requesttypeothers": "tool_dataprivacy", + "core.dataprivacy.send": "tool_dataprivacy", + "core.dataprivacy.statusapproved": "tool_dataprivacy", + "core.dataprivacy.statusawaitingapproval": "tool_dataprivacy", + "core.dataprivacy.statuscancelled": "tool_dataprivacy", + "core.dataprivacy.statuscomplete": "tool_dataprivacy", + "core.dataprivacy.statusdeleted": "tool_dataprivacy", + "core.dataprivacy.statusexpired": "tool_dataprivacy", + "core.dataprivacy.statuspending": "tool_dataprivacy", + "core.dataprivacy.statuspreprocessing": "tool_dataprivacy", + "core.dataprivacy.statusprocessing": "tool_dataprivacy", + "core.dataprivacy.statusready": "tool_dataprivacy", + "core.dataprivacy.statusrejected": "tool_dataprivacy", "core.datastoredoffline": "local_moodlemobileapp", "core.date": "moodle", "core.datecreated": "repository", diff --git a/src/addons/badges/services/handlers/user.ts b/src/addons/badges/services/handlers/user.ts index f9f357ce25b..54cf3e55226 100644 --- a/src/addons/badges/services/handlers/user.ts +++ b/src/addons/badges/services/handlers/user.ts @@ -16,9 +16,9 @@ import { Injectable } from '@angular/core'; import { CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; import { CoreUserDelegateContext, - CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData, + CoreUserProfileHandlerType, } from '@features/user/services/user-delegate'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; @@ -33,7 +33,7 @@ export class AddonBadgesUserHandlerService implements CoreUserProfileHandler { name = 'AddonBadges:fakename'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext. priority = 300; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; /** * @inheritdoc diff --git a/src/addons/blog/services/handlers/user.ts b/src/addons/blog/services/handlers/user.ts index f2b5e75deb5..85b81f942e8 100644 --- a/src/addons/blog/services/handlers/user.ts +++ b/src/addons/blog/services/handlers/user.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreUserProfileHandler, CoreUserProfileHandlerData, - CoreUserDelegateService, + CoreUserProfileHandlerType, CoreUserDelegateContext, } from '@features/user/services/user-delegate'; import { CoreNavigator } from '@services/navigator'; @@ -32,7 +32,7 @@ export class AddonBlogUserHandlerService implements CoreUserProfileHandler { name = 'AddonBlog'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext. priority = 200; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; /** * @inheritdoc diff --git a/src/addons/competency/services/handlers/user.ts b/src/addons/competency/services/handlers/user.ts index dbae1b0a826..9cd7df793fe 100644 --- a/src/addons/competency/services/handlers/user.ts +++ b/src/addons/competency/services/handlers/user.ts @@ -18,7 +18,7 @@ import { COURSE_PAGE_NAME } from '@features/course/course.module'; import { CoreUserProfile } from '@features/user/services/user'; import { CoreUserProfileHandler, - CoreUserDelegateService, + CoreUserProfileHandlerType, CoreUserProfileHandlerData, CoreUserDelegateContext, } from '@features/user/services/user-delegate'; @@ -36,7 +36,7 @@ export class AddonCompetencyUserHandlerService implements CoreUserProfileHandler name = 'AddonCompetency'; // This name doesn't match any disabled feature, they'll be checked in isEnabledForContext. priority = 100; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; cacheEnabled = true; /** diff --git a/src/addons/coursecompletion/services/handlers/user.ts b/src/addons/coursecompletion/services/handlers/user.ts index a31b5dc44bf..32227448288 100644 --- a/src/addons/coursecompletion/services/handlers/user.ts +++ b/src/addons/coursecompletion/services/handlers/user.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreUserProfile } from '@features/user/services/user'; import { CoreUserProfileHandler, - CoreUserDelegateService, + CoreUserProfileHandlerType, CoreUserProfileHandlerData, CoreUserDelegateContext, } from '@features/user/services/user-delegate'; @@ -31,7 +31,7 @@ import { AddonCourseCompletion } from '../coursecompletion'; export class AddonCourseCompletionUserHandlerService implements CoreUserProfileHandler { name = 'AddonCourseCompletion:viewCompletion'; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; priority = 350; cacheEnabled = true; diff --git a/src/addons/messages/services/handlers/user-send-message.ts b/src/addons/messages/services/handlers/user-send-message.ts index b7a677645a2..d217fe58092 100644 --- a/src/addons/messages/services/handlers/user-send-message.ts +++ b/src/addons/messages/services/handlers/user-send-message.ts @@ -15,7 +15,11 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; import { CoreUserProfile } from '@features/user/services/user'; -import { CoreUserDelegateService, CoreUserProfileHandler, CoreUserProfileHandlerData } from '@features/user/services/user-delegate'; +import { + CoreUserProfileHandlerType, + CoreUserProfileHandler, + CoreUserProfileHandlerData, +} from '@features/user/services/user-delegate'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; import { makeSingleton } from '@singletons'; @@ -29,7 +33,7 @@ export class AddonMessagesSendMessageUserHandlerService implements CoreUserProfi name = 'AddonMessages:sendMessage'; priority = 1000; - type = CoreUserDelegateService.TYPE_COMMUNICATION; + type = CoreUserProfileHandlerType.BUTTON; /** * @inheritdoc diff --git a/src/addons/notes/services/handlers/user.ts b/src/addons/notes/services/handlers/user.ts index 6626ba29bb1..399e3731032 100644 --- a/src/addons/notes/services/handlers/user.ts +++ b/src/addons/notes/services/handlers/user.ts @@ -16,7 +16,7 @@ import { Injectable } from '@angular/core'; import { CoreUserProfile } from '@features/user/services/user'; import { CoreUserProfileHandler, - CoreUserDelegateService, + CoreUserProfileHandlerType, CoreUserProfileHandlerData, CoreUserDelegateContext, } from '@features/user/services/user-delegate'; @@ -33,7 +33,7 @@ export class AddonNotesUserHandlerService implements CoreUserProfileHandler { name = 'AddonNotes:notes'; priority = 250; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; cacheEnabled = true; /** diff --git a/src/addons/privatefiles/services/handlers/user.ts b/src/addons/privatefiles/services/handlers/user.ts index 5aad0dd5446..81387cdeac8 100644 --- a/src/addons/privatefiles/services/handlers/user.ts +++ b/src/addons/privatefiles/services/handlers/user.ts @@ -18,7 +18,7 @@ import { AddonPrivateFiles } from '@addons/privatefiles/services/privatefiles'; import { makeSingleton } from '@singletons'; import { CoreUserDelegateContext, - CoreUserDelegateService, + CoreUserProfileHandlerType, CoreUserProfileHandler, CoreUserProfileHandlerData, } from '@features/user/services/user-delegate'; @@ -36,7 +36,7 @@ export class AddonPrivateFilesUserHandlerService implements CoreUserProfileHandl name = 'AddonPrivateFiles'; priority = 400; - type = CoreUserDelegateService.TYPE_NEW_PAGE; + type = CoreUserProfileHandlerType.LIST_ITEM; cacheEnabled = true; /** diff --git a/src/core/components/empty-box/empty-box.scss b/src/core/components/empty-box/empty-box.scss index 7c57385ea22..db32084725b 100644 --- a/src/core/components/empty-box/empty-box.scss +++ b/src/core/components/empty-box/empty-box.scss @@ -2,7 +2,7 @@ :host { --image-size: 120px; - --icon-color: var(--text-color); + --icon-color: var(--subdued-text-color); display: flex; flex-direction: column; diff --git a/src/core/features/dataprivacy/components/components.module.ts b/src/core/features/dataprivacy/components/components.module.ts new file mode 100644 index 00000000000..9b857a50a79 --- /dev/null +++ b/src/core/features/dataprivacy/components/components.module.ts @@ -0,0 +1,33 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreDataPrivacyContactDPOComponent } from './contactdpo/contactdpo'; +import { CoreDataPrivacyNewRequestComponent } from './newrequest/newrequest'; + +@NgModule({ + declarations: [ + CoreDataPrivacyContactDPOComponent, + CoreDataPrivacyNewRequestComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CoreDataPrivacyContactDPOComponent, + CoreDataPrivacyNewRequestComponent, + ], +}) +export class CoreDataPrivacyComponentsModule {} diff --git a/src/core/features/dataprivacy/components/contactdpo/contactdpo.html b/src/core/features/dataprivacy/components/contactdpo/contactdpo.html new file mode 100644 index 00000000000..9192b905123 --- /dev/null +++ b/src/core/features/dataprivacy/components/contactdpo/contactdpo.html @@ -0,0 +1,37 @@ + + + +

{{ 'core.dataprivacy.contactdataprotectionofficer' | translate }}

+
+ + + + +
+
+ +
+ + +

+ {{ 'core.dataprivacy.replyto' | translate }} +

+

{{ email }}

+
+
+ + +
+ {{ 'core.dataprivacy.message' | translate }} +
+
+
+
+
+ + + {{ 'core.dataprivacy.send' | translate }} + + diff --git a/src/core/features/dataprivacy/components/contactdpo/contactdpo.ts b/src/core/features/dataprivacy/components/contactdpo/contactdpo.ts new file mode 100644 index 00000000000..2e531b4445e --- /dev/null +++ b/src/core/features/dataprivacy/components/contactdpo/contactdpo.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, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { CoreDataPrivacy } from '@features/dataprivacy/services/dataprivacy'; +import { CoreUser } from '@features/user/services/user'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; + +import { ModalController } from '@singletons'; + +/** + * Component that displays the contact DPO page. + */ +@Component({ + selector: 'core-data-privacy-contact-dpo', + templateUrl: 'contactdpo.html', +}) +export class CoreDataPrivacyContactDPOComponent implements OnInit { + + message = ''; + email = ''; + + // Form variables. + form: FormGroup; + + constructor( + protected fb: FormBuilder, + ) { + this.form = new FormGroup({}); + + // Initialize form variables. + this.form.addControl('message', this.fb.control('', Validators.required)); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + const modal = await CoreDomUtils.showModalLoading(); + + // Get current user email. + const userId = CoreSites.getCurrentSiteUserId(); + const user = await CoreUtils.ignoreErrors(CoreUser.getProfile(userId)); + + this.email = user?.email || ''; + + modal.dismiss(); + } + + /** + * Sends the message. + */ + async send(event: Event): Promise { + event.preventDefault(); + event.stopPropagation(); + + const modal = await CoreDomUtils.showModalLoading(); + + try { + // Send the message. + const succeed = await CoreDataPrivacy.contactDPO(this.message); + if (succeed) { + CoreDomUtils.showToast('core.dataprivacy.requestsubmitted', true, ToastDuration.LONG); + ModalController.dismiss(true); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error sending data privacy request'); + } finally { + modal.dismiss(); + } + } + + /** + * Close modal. + */ + close(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/dataprivacy/components/newrequest/newrequest.html b/src/core/features/dataprivacy/components/newrequest/newrequest.html new file mode 100644 index 00000000000..8b2ee40769f --- /dev/null +++ b/src/core/features/dataprivacy/components/newrequest/newrequest.html @@ -0,0 +1,50 @@ + + + +

{{ 'core.dataprivacy.createnewdatarequest' | translate }}

+
+ + + + +
+
+ +
+ + +

+ {{ 'core.dataprivacy.requesttype_help' | translate }} +

+

+ {{ 'core.dataprivacy.requesttype' | translate }} +

+
+
+ + + + {{ 'core.dataprivacy.requesttypeexport' | translate }} + + + {{ 'core.dataprivacy.requesttypedelete' | translate }} + + + + + +
+ {{ 'core.dataprivacy.requestcomments' | translate }} +
+
+
+ +
+
+ + + {{ 'core.dataprivacy.send' | translate }} + + diff --git a/src/core/features/dataprivacy/components/newrequest/newrequest.ts b/src/core/features/dataprivacy/components/newrequest/newrequest.ts new file mode 100644 index 00000000000..7e75e4e6b98 --- /dev/null +++ b/src/core/features/dataprivacy/components/newrequest/newrequest.ts @@ -0,0 +1,104 @@ +// (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 { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { + CoreDataPrivacy, + CoreDataPrivacyDataRequestType, + CoreDataPrivacyGetAccessInformationWSResponse, +} from '@features/dataprivacy/services/dataprivacy'; +import { CoreDomUtils, ToastDuration } from '@services/utils/dom'; + +import { ModalController } from '@singletons'; + +/** + * Component that displays the new request page. + */ +@Component({ + selector: 'core-data-privacy-new-request', + templateUrl: 'newrequest.html', +}) +export class CoreDataPrivacyNewRequestComponent implements OnInit { + + @Input() accessInfo?: CoreDataPrivacyGetAccessInformationWSResponse; + + message = ''; + + // Form variables. + form: FormGroup; + typeControl: FormControl; + + constructor( + protected fb: FormBuilder, + ) { + this.form = new FormGroup({}); + + // Initialize form variables. + this.typeControl = this.fb.control( + CoreDataPrivacyDataRequestType.DATAREQUEST_TYPE_EXPORT, + { validators: Validators.required, nonNullable: true }, + ); + this.form.addControl('type', this.typeControl); + this.form.addControl('message', this.fb.control('')); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + // It should not happen. If there's no access info, close the modal. + if (!this.accessInfo) { + ModalController.dismiss(); + + return; + } + + // Just in case only deleting is allowed, change the default type. + if (!this.accessInfo.cancreatedatadownloadrequest && this.accessInfo.cancreatedatadeletionrequest){ + this.typeControl.setValue(CoreDataPrivacyDataRequestType.DATAREQUEST_TYPE_DELETE); + } + } + + /** + * Sends the request. + */ + async send(event: Event): Promise { + event.preventDefault(); + event.stopPropagation(); + + const modal = await CoreDomUtils.showModalLoading(); + + try { + // Send the message. + const requestId = await CoreDataPrivacy.createDataRequest(this.typeControl.value, this.message); + if (requestId) { + CoreDomUtils.showToast('core.dataprivacy.requestsubmitted', true, ToastDuration.LONG); + ModalController.dismiss(true); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error sending data privacy request'); + } finally { + modal.dismiss(); + } + } + + /** + * Close modal. + */ + close(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/dataprivacy/constants.ts b/src/core/features/dataprivacy/constants.ts new file mode 100644 index 00000000000..73043ed8558 --- /dev/null +++ b/src/core/features/dataprivacy/constants.ts @@ -0,0 +1,16 @@ +// (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. + +// Routing. +export const CORE_DATAPRIVACY_PAGE_NAME = 'dataprivacy'; diff --git a/src/core/features/dataprivacy/dataprivacy-lazy.module.ts b/src/core/features/dataprivacy/dataprivacy-lazy.module.ts new file mode 100644 index 00000000000..b0431a131b3 --- /dev/null +++ b/src/core/features/dataprivacy/dataprivacy-lazy.module.ts @@ -0,0 +1,38 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreDataPrivacyMainPage } from './pages/main/main'; + +const routes: Routes = [ + { + path: '', + pathMatch: 'full', + component: CoreDataPrivacyMainPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + ], + declarations: [ + CoreDataPrivacyMainPage, + ], +}) +export class CoreDataPrivacyLazyModule {} diff --git a/src/core/features/dataprivacy/dataprivacy.module.ts b/src/core/features/dataprivacy/dataprivacy.module.ts new file mode 100644 index 00000000000..6891562d470 --- /dev/null +++ b/src/core/features/dataprivacy/dataprivacy.module.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 { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CoreDataPrivacyUserHandler } from './services/handlers/user'; +import { Routes } from '@angular/router'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreDataPrivacyComponentsModule } from './components/components.module'; +import { CORE_DATAPRIVACY_PAGE_NAME } from './constants'; + +const routes: Routes = [ + { + path: CORE_DATAPRIVACY_PAGE_NAME, + loadChildren: () => import('./dataprivacy-lazy.module').then(m => m.CoreDataPrivacyLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + CoreDataPrivacyComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: () => { + CoreUserDelegate.registerHandler(CoreDataPrivacyUserHandler.instance); + }, + }, + ], +}) +export class CoreDataPrivacyModule {} diff --git a/src/core/features/dataprivacy/lang.json b/src/core/features/dataprivacy/lang.json new file mode 100644 index 00000000000..13f0290bd50 --- /dev/null +++ b/src/core/features/dataprivacy/lang.json @@ -0,0 +1,36 @@ +{ + "contactdataprotectionofficer": "Contact the privacy officer", + "cancelrequest": "Cancel request", + "cancelrequestconfirmation": "Do you really want cancel this data request?", + "createnewdatarequest": "Create a new data request", + "datarequests": "Data requests", + "daterequested": "Date requested", + "deletemyaccount": "Delete my account", + "message": "Message", + "newrequest": "New request", + "nodatarequests": "There are no data requests", + "pluginname": "Data privacy", + "replyto": "Reply to", + "requestactions": "Actions", + "requestby": "Requested by", + "requestcomments": "Comments", + "requeststatus": "Status", + "requestsubmitted": "Your request has been submitted to the privacy officer", + "requesttype_help": "Select the reason for contacting the privacy officer. Be aware that deletion of all personal data will result in you no longer being able to log in to the site.", + "requesttype": "Type", + "requesttypedelete": "Delete all of my personal data", + "requesttypeexport": "Export all of my personal data", + "requesttypeothers": "General enquiry", + "send": "Send", + "statusapproved": "Approved", + "statusawaitingapproval": "Awaiting approval", + "statuscancelled": "Cancelled", + "statuscomplete": "Complete", + "statusdeleted": "Deleted", + "statusexpired": "Expired", + "statuspending": "Pending", + "statuspreprocessing": "Pre-processing", + "statusprocessing": "Processing", + "statusready": "Download ready", + "statusrejected": "Rejected" +} diff --git a/src/core/features/dataprivacy/pages/main/main.html b/src/core/features/dataprivacy/pages/main/main.html new file mode 100644 index 00000000000..5e42746b8e7 --- /dev/null +++ b/src/core/features/dataprivacy/pages/main/main.html @@ -0,0 +1,160 @@ + + + + + + +

{{ 'core.dataprivacy.pluginname' | translate }}

+
+
+
+ + + + + + + +

{{ 'core.dataprivacy.datarequests' | translate }}

+
+
+ + + + +

+ +

+ + +

{{request.timecreated * 1000 | coreFormatDate }}

+
+ + + +
+
+
+ + +

{{ 'core.dataprivacy.requestby' | translate }}

+

{{ request.requestedbyuser.fullname }}

+
+
+ + +

{{ 'core.dataprivacy.message' | translate }}

+

+
+
+ + + + {{ 'core.dataprivacy.cancelrequest' | translate }} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
{{ 'core.dataprivacy.requesttype' | translate }}{{ 'core.dataprivacy.daterequested' | translate }}{{ 'core.dataprivacy.requestby' | translate }}{{ 'core.dataprivacy.requeststatus' | translate }}{{ 'core.dataprivacy.message' | translate }}{{ 'core.dataprivacy.requestactions' | translate }}
+

+
+

{{request.timecreated * 1000 | coreFormatDate }}

+
+

{{ request.requestedbyuser.fullname }}

+
+ + +

+
+ + {{ 'core.dataprivacy.cancelrequest' | translate }} + +
+ + + +
+
+ + + {{ 'core.dataprivacy.contactdataprotectionofficer' | translate }} + + + + {{ 'core.dataprivacy.newrequest' | translate }} + +
+
+ +
+
+ + + @switch (request.status) { + @case (0) { + {{'core.dataprivacy.statuspending' | translate }} + } @case (1) { + {{'core.dataprivacy.statuspreprocessing' | translate }} + } @case (2) { + {{'core.dataprivacy.statusawaitingapproval' | translate }} + } @case (3) { + {{'core.dataprivacy.statusapproved' | translate }} + } @case (4) { + {{'core.dataprivacy.statusprocessing' | translate }} + } @case (5) { + {{'core.dataprivacy.statuscomplete' | translate }} + } @case (6) { + {{'core.dataprivacy.statuscancelled' | translate }} + } @case (7) { + {{'core.dataprivacy.statusrejected' | translate }} + } @case (8) { + {{'core.dataprivacy.statusready' | translate }} + } @case (9) { + {{'core.dataprivacy.statusexpired' | translate }} + } @case (10) { + {{'core.dataprivacy.statusdeleted' | translate }} + } @default { + {{request.statuslabel}} + } + } + + + + @switch (request.type) { + @case (1) { + {{ 'core.dataprivacy.requesttypeexport' | translate }} + } @case (2) { + {{ 'core.dataprivacy.requesttypedelete' | translate }} + } @case (3) { + {{ 'core.dataprivacy.requesttypeothers' | translate }} + } @default { + {{request.typename}} + } + } + diff --git a/src/core/features/dataprivacy/pages/main/main.scss b/src/core/features/dataprivacy/pages/main/main.scss new file mode 100644 index 00000000000..b5e585f203c --- /dev/null +++ b/src/core/features/dataprivacy/pages/main/main.scss @@ -0,0 +1,10 @@ + +table { + th { + width: 20%; + } + + th.shrink { + width: 1%; + } +} diff --git a/src/core/features/dataprivacy/pages/main/main.ts b/src/core/features/dataprivacy/pages/main/main.ts new file mode 100644 index 00000000000..dfbc8a23f83 --- /dev/null +++ b/src/core/features/dataprivacy/pages/main/main.ts @@ -0,0 +1,167 @@ +// (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 { CoreDataPrivacyContactDPOComponent } from '@features/dataprivacy/components/contactdpo/contactdpo'; +import { CoreDataPrivacyNewRequestComponent } from '@features/dataprivacy/components/newrequest/newrequest'; +import { + CoreDataPrivacy, + CoreDataPrivacyGetAccessInformationWSResponse, + CoreDataPrivacyRequest, +} from '@features/dataprivacy/services/dataprivacy'; +import { CoreScreen } from '@services/screen'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { Subscription } from 'rxjs'; + +/** + * Page to display the main data privacy page. + */ +@Component({ + selector: 'page-core-data-privacy-main', + templateUrl: 'main.html', + styleUrl: 'main.scss', +}) +export class CoreDataPrivacyMainPage implements OnInit { + + accessInfo?: CoreDataPrivacyGetAccessInformationWSResponse; + requests: CoreDataPrivacyRequestToDisplay[] = []; + loaded = false; + isTablet = false; + layoutSubscription?: Subscription; + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.fetchContent(); + + this.isTablet = CoreScreen.isTablet; + + this.layoutSubscription = CoreScreen.layoutObservable.subscribe(() => { + this.isTablet = CoreScreen.isTablet; + }); + } + + /** + * Fetch page content. + */ + async fetchContent(): Promise { + try { + this.accessInfo = await CoreDataPrivacy.getAccessInformation(); + + this.requests = await CoreDataPrivacy.getDataRequests(); + + this.requests.forEach((request) => { + request.canCancel = CoreDataPrivacy.canCancelRequest(request); + }); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error fetching data privacy information', true); + } finally { + this.loaded = true; + } + } + + /** + * Refresh the page content. + * + * @param refresher Refresher. + */ + async refreshContent(refresher?: HTMLIonRefresherElement): Promise { + await CoreUtils.ignoreErrors( + CoreDataPrivacy.invalidateAll(), + ); + + await CoreUtils.ignoreErrors(this.fetchContent()); + + refresher?.complete(); + } + + /** + * Open the contact DPO modal. + */ + async contactDPO(): Promise { + // Create and show the modal. + const succeed = await CoreDomUtils.openModal({ + component: CoreDataPrivacyContactDPOComponent, + }); + + if (succeed) { + const modal = await CoreDomUtils.showModalLoading(); + try { + await this.refreshContent(); + } finally { + modal.dismiss(); + } + } + } + + /** + * Open the new request modal. + */ + async newRequest(): Promise { + // Create and show the modal. + const succeed = await CoreDomUtils.openModal({ + component: CoreDataPrivacyNewRequestComponent, + componentProps: { + accessInfo: this.accessInfo, + }, + }); + + if (succeed) { + const modal = await CoreDomUtils.showModalLoading(); + try { + await this.refreshContent(); + } finally { + modal.dismiss(); + } + } + } + + /** + * Cancel a request. + * + * @param requestId Request ID. + */ + async cancelRequest(requestId: number): Promise { + + try { + await CoreDomUtils.showConfirm( + Translate.instant('core.dataprivacy.cancelrequestconfirmation'), + Translate.instant('core.dataprivacy.cancelrequest'), + Translate.instant('core.dataprivacy.cancelrequest'), + ); + } catch { + return; + } + + const modal = await CoreDomUtils.showModalLoading(); + + try { + await CoreDataPrivacy.cancelDataRequest(requestId); + + await this.refreshContent(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error cancelling data privacy request'); + } finally { + modal.dismiss(); + } + } + +} + +type CoreDataPrivacyRequestToDisplay = CoreDataPrivacyRequest & { + canCancel?: boolean; +}; diff --git a/src/core/features/dataprivacy/services/dataprivacy.ts b/src/core/features/dataprivacy/services/dataprivacy.ts new file mode 100644 index 00000000000..21feed9c64e --- /dev/null +++ b/src/core/features/dataprivacy/services/dataprivacy.ts @@ -0,0 +1,378 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreSiteWSPreSets } from '@classes/sites/authenticated-site'; +import { CoreUserSummary } from '@features/user/services/user'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; + +/** + * Service to handle data privacy. + */ +@Injectable({ providedIn: 'root' }) +export class CoreDataPrivacyService { + + static readonly ROOT_CACHE_KEY = 'CoreDataPrivacy:'; + + /** + * Check if data privacy is enabled on current site. + * + * @returns Whether data privacy is enabled. + */ + async isEnabled(): Promise { + const site = CoreSites.getCurrentSite(); + + // Check if the privacy data WS are available in the site. + if (!site?.wsAvailable('tool_dataprivacy_get_data_requests')) { + return false; + } + + // If the user can contact the DPO, then data privacy is enabled. + const accessInformation = await this.getAccessInformation(); + + return accessInformation.cancontactdpo; + } + + /** + * Get cache key for data privacy access information WS calls. + * + * @returns Cache key. + */ + protected getAccessInformationCacheKey(): string { + return CoreDataPrivacyService.ROOT_CACHE_KEY + 'accessInformation'; + } + + /** + * Retrieving privacy API access (permissions) information for the current user. + * + * @param options Request options. + * @returns Promise resolved with object with access information. + * @since 4.4 + */ + async getAccessInformation( + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(), + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('tool_dataprivacy_get_access_information', undefined, preSets); + } + + /** + * Invalidates access information. + * + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved when the data is invalidated. + */ + protected async invalidateAccessInformation(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey()); + } + + /** + * Contact the site Data Protection Officer(s). + * + * @param message Message to send to the DPO. + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved with boolean: whether the message was sent. + * @since 4.4 + */ + async contactDPO(message: string, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: CoreDataPrivacyContactDPOWSParams = { message }; + + const response = await site.write('tool_dataprivacy_contact_dpo', params); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } + + return response.result; + } + + /** + * Get cache key for data requests WS calls. + * + * @returns Cache key. + */ + protected getDataRequestsCacheKey(): string { + return CoreDataPrivacyService.ROOT_CACHE_KEY + 'datarequests'; + } + + /** + * Fetch the details of a user's data request. + * + * @param options Request options. + * @returns Promise resolved with the data requests. + * @since 4.4 + */ + async getDataRequests( + options: CoreSitesCommonWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getDataRequestsCacheKey(), + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const params: CoreDataPrivacyGetDataRequestsWSParams = { + userid: site.getUserId(), + }; + + const response = + await site.read('tool_dataprivacy_get_data_requests', params, preSets); + + return response.requests; + } + + /** + * Invalidate data requests. + * + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved when the data is invalidated. + */ + async invalidateDataRequests(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getDataRequestsCacheKey()); + } + + /** + * Creates a data request. + * + * @param type Type of the request. + * @param comments Comments for the data request. + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved when the request is created. + * @since 4.4 + */ + async createDataRequest(type: CoreDataPrivacyDataRequestType, comments: string, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: CoreDataPrivacyCreateDataRequestWSParams = { + type, + comments, + }; + + const response = + await site.write('tool_dataprivacy_create_data_request', params); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } + + return response.datarequestid; + } + + /** + * Cancel the data request made by the user. + * + * @param requestid ID of the request to cancel. + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved with boolean: whether the request was canceled. + * @since 4.4 + */ + async cancelDataRequest(requestid: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const params: CoreDataPrivacyCancelDataRequestWSParams = { requestid }; + + const response = + await site.write('tool_dataprivacy_cancel_data_request', params); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } + + return response.result; + } + + /** + * Invalidate all the data related to data privacy. + */ + async invalidateAll(): Promise { + await Promise.all([ + this.invalidateAccessInformation(), + this.invalidateDataRequests(), + ]); + } + + /** + * Check if the user can cancel a request. + * + * @param request The request to check. + * @returns Whether the user can cancel the request. + */ + canCancelRequest(request: CoreDataPrivacyRequest): boolean { + const cannotCancelStatuses = [ + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_COMPLETE, + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_DOWNLOAD_READY, + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_DELETED, + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_EXPIRED, + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_CANCELLED, + CoreDataPrivacyDataRequestStatus.DATAREQUEST_STATUS_REJECTED, + ]; + + return !cannotCancelStatuses.includes(request.status); + } + +} + +export const CoreDataPrivacy = makeSingleton(CoreDataPrivacyService); + +export enum CoreDataPrivacyDataRequestType { + DATAREQUEST_TYPE_EXPORT = 1, // Data export request type. + DATAREQUEST_TYPE_DELETE = 2, // Data deletion request type. + DATAREQUEST_TYPE_OTHERS = 3, // Other request type. Usually of enquiries to the DPO. +} + +export enum CoreDataPrivacyDataRequestStatus { + DATAREQUEST_STATUS_PENDING = 0, // Newly submitted and we haven't yet started finding out where they have data. + DATAREQUEST_STATUS_PREPROCESSING = 1, // Newly submitted and we have started to find the location of data. + DATAREQUEST_STATUS_AWAITING_APPROVAL = 2, // Metadata ready and awaiting review and approval by the Data Protection officer. + DATAREQUEST_STATUS_APPROVED = 3, // Request approved and will be processed soon. + DATAREQUEST_STATUS_PROCESSING = 4, // The request is now being processed. + DATAREQUEST_STATUS_COMPLETE = 5, // Information/other request completed. + DATAREQUEST_STATUS_CANCELLED = 6, // Data request cancelled by the user. + DATAREQUEST_STATUS_REJECTED = 7, // Data request rejected by the DPO. + DATAREQUEST_STATUS_DOWNLOAD_READY = 8, // Data request download ready. + DATAREQUEST_STATUS_EXPIRED = 9, // Data request expired. + DATAREQUEST_STATUS_DELETED = 10, // Data delete request completed, account is removed. +} + +/** + * Data returned by tool_dataprivacy_get_access_information WS. + */ +export type CoreDataPrivacyGetAccessInformationWSResponse = { + cancontactdpo: boolean; // Can contact dpo. + canmanagedatarequests: boolean; // Can manage data requests. + cancreatedatadownloadrequest: boolean; // Can create data download request for self. + cancreatedatadeletionrequest: boolean; // Can create data deletion request for self. + hasongoingdatadownloadrequest: boolean; // Has ongoing data download request. + hasongoingdatadeletionrequest: boolean; // Has ongoing data deletion request. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of tool_dataprivacy_contact_dpo WS. + */ +type CoreDataPrivacyContactDPOWSParams = { + message: string; // The user's message to the Data Protection Officer(s). +}; + +/** + * Data returned by tool_dataprivacy_contact_dpo WS. + */ +type CoreDataPrivacyContactDPOWSResponse = { + result: boolean; // The processing result + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of tool_dataprivacy_create_data_request WS. + */ +type CoreDataPrivacyCreateDataRequestWSParams = { + type: CoreDataPrivacyDataRequestType; // The type of data request to create. 1 for export, 2 for data deletion. + comments?: string; // Comments for the data request. + foruserid?: number; // The id of the user to create the data request for. Empty for current user. +}; + +/** + * Data returned by tool_dataprivacy_create_data_request WS. + */ +type CoreDataPrivacyCreateDataRequestWSResponse = { + datarequestid: number; // The id of the created data request. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of tool_dataprivacy_cancel_data_request WS. + */ +type CoreDataPrivacyCancelDataRequestWSParams = { + requestid: number; // The request ID +}; + +/** + * Data returned by tool_dataprivacy_cancel_data_request WS. + */ +type CoreDataPrivacyCancelDataRequestWSResponse = { + result: boolean; // The processing result + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of tool_dataprivacy_get_data_requests WS. + */ +type CoreDataPrivacyGetDataRequestsWSParams = { + userid?: number; // The id of the user to get the data requests for. Empty for all users. + statuses?: CoreDataPrivacyDataRequestStatus[]; // The statuses of the data requests to get. + // 0 for pending 1 preprocessing, 2 awaiting approval, 3 approved, + // 4 processed, 5 completed, 6 cancelled, 7 rejected. + types?: number[]; // The types of the data requests to get. 1 for export, 2 for data deletion. + creationmethods?: number[]; // The creation methods of the data requests to get. 0 for manual, 1 for automatic. + sort?: string; // The field to sort the data requests by. + limitfrom?: number; // The number to start getting the data requests from. + limitnum?: number; // The number of data requests to get. +}; + +/** + * Data returned by tool_dataprivacy_get_data_requests WS. + */ +type CoreDataPrivacyGetDataRequestsWSResponse = { + requests: CoreDataPrivacyRequest[]; // The data requests. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Data for the dataprivacy request. + */ +export type CoreDataPrivacyRequest = { + type: CoreDataPrivacyDataRequestType; // Type. + comments: string; // Comments. + commentsformat: number; // Commentsformat. + userid: number; // Userid. + requestedby: number; // Requestedby. + status: CoreDataPrivacyDataRequestStatus; // Status. + dpo: number; // Dpo. + dpocomment: string; // Dpocomment. + dpocommentformat: number; // Dpocommentformat. + systemapproved: boolean; // Systemapproved. + creationmethod: number; // Creationmethod. + id: number; // Id. + timecreated: number; // Timecreated. + timemodified: number; // Timemodified. + usermodified: number; // Usermodified. + foruser: CoreUserSummary; // The user the request is for. + requestedbyuser: CoreUserSummary; // The user who requested the data. + dpouser?: CoreUserSummary; // The user who processed the request. + messagehtml?: string; // Messagehtml. + typename: string; // Typename. + typenameshort: string; // Typenameshort. + statuslabel: string; // Statuslabel. + statuslabelclass: string; // Statuslabelclass. + canreview?: boolean; // Canreview. + approvedeny?: boolean; // Approvedeny. + allowfiltering?: boolean; // Allowfiltering. + canmarkcomplete?: boolean; // Canmarkcomplete. +}; diff --git a/src/core/features/dataprivacy/services/handlers/user.ts b/src/core/features/dataprivacy/services/handlers/user.ts new file mode 100644 index 00000000000..60698c03d1d --- /dev/null +++ b/src/core/features/dataprivacy/services/handlers/user.ts @@ -0,0 +1,63 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { + CoreUserProfileHandlerType, + CoreUserProfileHandler, + CoreUserProfileHandlerData, +} from '@features/user/services/user-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { CoreDataPrivacy } from '../dataprivacy'; +import { CORE_DATAPRIVACY_PAGE_NAME } from '@features/dataprivacy/constants'; + +/** + * Handler to visualize custom reports. + */ +@Injectable({ providedIn: 'root' }) +export class CoreDataPrivacyUserHandlerService implements CoreUserProfileHandler { + + protected pageName = CORE_DATAPRIVACY_PAGE_NAME; + + type = CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM; + name = 'CoreDataPrivacyDelegate'; + priority = 100; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return await CoreDataPrivacy.isEnabled(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + class: 'core-data-privacy', + icon: 'fas-user-shield', + title: 'core.dataprivacy.pluginname', + action: async (event): Promise => { + event.preventDefault(); + event.stopPropagation(); + await CoreNavigator.navigateToSitePath(this.pageName); + }, + }; + } + +} + +export const CoreDataPrivacyUserHandler = makeSingleton(CoreDataPrivacyUserHandlerService); diff --git a/src/core/features/dataprivacy/tests/behat/contact_privacy_officer.feature b/src/core/features/dataprivacy/tests/behat/contact_privacy_officer.feature new file mode 100644 index 00000000000..b02fd7853f6 --- /dev/null +++ b/src/core/features/dataprivacy/tests/behat/contact_privacy_officer.feature @@ -0,0 +1,27 @@ +@core_dataprivacy @app @javascript @lms_from4.4 +Feature: Contact the privacy officer + As a user + In order to reach out to the site's privacy officer + I need to be able to contact the site's privacy officer in Moodle + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | s1@example.com | + + Scenario: Contacting the privacy officer + Given the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + When I entered the app as "student1" + And I press the user menu button in the app + And I press "Data privacy" in the app + And I press "Contact the privacy officer" in the app + And I set the field "Message" to "Hello DPO!" in the app + And I press "Send" in the app + Then I should find "Your request has been submitted to the privacy officer" in the app + And I should find "Hello DPO!" in the app + + Scenario: Contacting the privacy officer when not enabled + When I entered the app as "student1" + And I press the user menu button in the app + Then I should not find ""Data privacy" in the app diff --git a/src/core/features/dataprivacy/tests/behat/create_data_request.feature b/src/core/features/dataprivacy/tests/behat/create_data_request.feature new file mode 100644 index 00000000000..4be00278772 --- /dev/null +++ b/src/core/features/dataprivacy/tests/behat/create_data_request.feature @@ -0,0 +1,62 @@ +@core_dataprivacy @app @javascript @lms_from4.4 +Feature: Data export and delete from the privacy API + In order to export or delete data for users and meet legal requirements + I need to be able to request my data data be exported or deleted + + Background: + Given the following "users" exist: + | username | firstname | lastname | + | victim | Victim User | 1 | + And the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + | privacyrequestexpiry | 55 | tool_dataprivacy | + | dporoles | 1 | tool_dataprivacy | + + Scenario: As a student, request deletion of account and data + Given I entered the app as "victim" + And I press the user menu button in the app + And I press "Data privacy" in the app + And I press "New request" in the app + And I press "Delete all of my personal data" in the app + And I press "Send" in the app + Then I should find "Delete all of my personal data" in the app + And I should find "Awaiting approval" near "Delete all of my personal data" in the app + + Scenario: As a student, I cannot create data deletion request unless I have permission. + Given the following "permission overrides" exist: + | capability | permission | role | contextlevel | reference | + | tool/dataprivacy:requestdelete | Prevent | user | System | | + And I entered the app as "victim" + And I press the user menu button in the app + And I press "Data privacy" in the app + When I press "New request" in the app + Then I should not find "Delete all of my personal data" in the app + + Scenario: As a student, request data export and then see the status + Given I entered the app as "victim" + And I press the user menu button in the app + And I press "Data privacy" in the app + When I press "New request" in the app + And I press "Export all of my personal data" in the app + And I set the field "Comments" to "Export my data" in the app + And I press "Send" in the app + Then I should find "Export all of my personal data" in the app + And I should find "Awaiting approval" near "Export all of my personal data" in the app + And I should find "Export my data" near "Export all of my personal data" in the app + + # The next step allows to naavigate to site administration + When I change viewport size to "1200x640" in the app + And I open a browser tab with url "$WWWROOT" + And I log in as "admin" + And I navigate to "Users > Privacy and policies > Data requests" in site administration + And I open the action menu in "Victim User 1" "table_row" + And I follow "Approve request" + And I press "Approve request" + + And I switch back to the app + And I pull to refresh in the app + Then I should find "Approved" near "Export all of my personal data" in the app + When I run all adhoc tasks + And I pull to refresh in the app + And I should find "Download ready" near "Export all of my personal data" in the app + # TODO: Add download link and test it. diff --git a/src/core/features/dataprivacy/tests/behat/my_data_requests.feature b/src/core/features/dataprivacy/tests/behat/my_data_requests.feature new file mode 100644 index 00000000000..ba3715fa2f1 --- /dev/null +++ b/src/core/features/dataprivacy/tests/behat/my_data_requests.feature @@ -0,0 +1,24 @@ +@core_dataprivacy @app @javascript @lms_from4.4 +Feature: Manage my own data requests + In order to manage my own data requests + As a user + I need to be able to view and cancel all my data requests + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | s1@example.com | + And the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + + Scenario: Cancel my own data request + Given I entered the app as "student1" + And I press the user menu button in the app + And I press "Data privacy" in the app + And I press "Contact the privacy officer" in the app + And I set the field "Message" to "Hello DPO!" in the app + And I press "Send" in the app + Then I should find "Your request has been submitted to the privacy officer" in the app + When I press "Cancel" near "Hello DPO!" in the app + And I press "Cancel request" "button" in the app + Then I should find "Cancelled" near "Hello DPO!" in the app diff --git a/src/core/features/editor/editor.module.ts b/src/core/features/editor/editor.module.ts index 49cf8588779..d179f925f95 100644 --- a/src/core/features/editor/editor.module.ts +++ b/src/core/features/editor/editor.module.ts @@ -24,8 +24,6 @@ export const CORE_EDITOR_SERVICES: Type[] = [ ]; @NgModule({ - declarations: [ - ], imports: [ CoreEditorComponentsModule, ], diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 61c97cbe730..6a38207d825 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -19,6 +19,7 @@ import { CoreCommentsModule } from './comments/comments.module'; import { CoreContentLinksModule } from './contentlinks/contentlinks.module'; import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; +import { CoreDataPrivacyModule } from './dataprivacy/dataprivacy.module'; import { CoreEditorModule } from './editor/editor.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreEnrolModule } from './enrol/enrol.module'; @@ -53,6 +54,7 @@ import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module'; CoreContentLinksModule, CoreCourseModule, CoreCoursesModule, + CoreDataPrivacyModule, CoreEditorModule, CoreEnrolModule, CoreFileUploaderModule, diff --git a/src/core/features/grades/pages/course/course.html b/src/core/features/grades/pages/course/course.html index 1079d5ff05d..ea8aa5cbd93 100644 --- a/src/core/features/grades/pages/course/course.html +++ b/src/core/features/grades/pages/course/course.html @@ -15,7 +15,7 @@

{{ title }}

- +
- @@ -48,15 +48,27 @@

- + @if (handlers.length + accountHandlers.length > 0) { + + - +