diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index a2d2e1330..e0fcb95ef 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -94,6 +94,7 @@ jobs: "@core_grades" "@core_login" "@core_mainmenu" + "@core_policy" "@core_reminders" "@core_reportbuilder" "@core_search" diff --git a/scripts/langindex.json b/scripts/langindex.json index c43f2fe20..5619f03fd 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1553,6 +1553,7 @@ "core.connectionlost": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", "core.contactsupport": "local_moodlemobileapp", + "core.contactverb": "local_moodlemobileapp", "core.content": "moodle", "core.contenteditingsynced": "local_moodlemobileapp", "core.contentlinks.chooseaccount": "local_moodlemobileapp", @@ -2282,19 +2283,42 @@ "core.phone": "moodle", "core.pictureof": "moodle", "core.play": "local_moodlemobileapp", + "core.policy.acceptancenote": "tool_policy", + "core.policy.acceptancestatusaccepted": "tool_policy", + "core.policy.acceptancestatusacceptedbehalf": "tool_policy", + "core.policy.acceptancestatusdeclined": "tool_policy", + "core.policy.acceptancestatusdeclinedbehalf": "tool_policy", + "core.policy.acceptancestatuspending": "tool_policy", "core.policy.agreepolicies": "tool_policy", "core.policy.backtotop": "tool_policy", "core.policy.consentpagetitle": "tool_policy", + "core.policy.contactdpo": "tool_policy", "core.policy.havereadandagreepolicy": "local_moodlemobileapp", "core.policy.idontagree": "tool_policy", "core.policy.mustagreetocontinue": "tool_policy", + "core.policy.nopoliciesyet": "local_moodlemobileapp", + "core.policy.policiesagreements": "tool_policy", "core.policy.policyacceptmandatory": "local_moodlemobileapp", "core.policy.policyagree": "moodle", "core.policy.policyagreement": "moodle", "core.policy.policyagreementclick": "moodle", + "core.policy.policydocname": "tool_policy", + "core.policy.policydocoptionalyes": "tool_policy", + "core.policy.policydocrevision": "tool_policy", "core.policy.refertofullpolicytext": "tool_policy", + "core.policy.response": "tool_policy", + "core.policy.responseby": "tool_policy", + "core.policy.responseon": "tool_policy", "core.policy.sitepolicynotagreederror": "local_moodlemobileapp", + "core.policy.status1": "tool_policy", "core.policy.steppolicies": "tool_policy", + "core.policy.useracceptanceactionaccept": "tool_policy", + "core.policy.useracceptanceactionacceptone": "tool_policy", + "core.policy.useracceptanceactiondecline": "tool_policy", + "core.policy.useracceptanceactiondeclineone": "tool_policy", + "core.policy.useracceptanceactionrevoke": "tool_policy", + "core.policy.useracceptanceactionrevokeone": "tool_policy", + "core.policy.viewpolicy": "local_moodlemobileapp", "core.previous": "moodle", "core.proceed": "moodle", "core.publicprofile": "moodle", diff --git a/src/assets/fonts/moodle/font-awesome/arrow-turn-down-right.svg b/src/assets/fonts/moodle/font-awesome/arrow-turn-down-right.svg new file mode 100644 index 000000000..b092fa12f --- /dev/null +++ b/src/assets/fonts/moodle/font-awesome/arrow-turn-down-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/features/policy/constants.ts b/src/core/features/policy/constants.ts index c341a326a..88e436d5a 100644 --- a/src/core/features/policy/constants.ts +++ b/src/core/features/policy/constants.ts @@ -15,3 +15,4 @@ // Routing. export const POLICY_PAGE_NAME = 'policy'; export const SITE_POLICY_PAGE_NAME = 'sitepolicy'; +export const ACCEPTANCES_PAGE_NAME = 'acceptances'; diff --git a/src/core/features/policy/lang.json b/src/core/features/policy/lang.json index fd53e1f17..8e7cba17b 100644 --- a/src/core/features/policy/lang.json +++ b/src/core/features/policy/lang.json @@ -1,15 +1,38 @@ { + "acceptancenote": "Remarks", + "acceptancestatusaccepted": "Accepted", + "acceptancestatusacceptedbehalf": "Accepted on user's behalf", + "acceptancestatusdeclined": "Declined", + "acceptancestatusdeclinedbehalf": "Declined on user's behalf", + "acceptancestatuspending": "Pending", "agreepolicies": "Please agree to the following policies", "backtotop": "Back to top", "consentpagetitle": "Consent", + "contactdpo": "For any questions about the policies please contact the privacy officer.", "havereadandagreepolicy": "I have read and agree to the {{policyname}}", "idontagree": "No thanks, I decline {{$a}}", "mustagreetocontinue": "Before continuing you need to acknowledge all these policies.", + "nopoliciesyet": "No policies and agreements yet.", + "policiesagreements": "Policies and agreements", "policyacceptmandatory": "I understand and agree to the mandatory site policies", "policyagree": "You must agree to this policy to continue using this site. Do you agree?", "policyagreement": "Site policy agreement", "policyagreementclick": "Link to site policy agreement", + "policydocname": "Name", + "policydocoptionalyes": "Optional", + "policydocrevision": "Version", "refertofullpolicytext": "Please refer to the full {{$a}} if you would like to review the text.", + "response": "Response", + "responseby": "Respondent", + "responseon": "Date", "sitepolicynotagreederror": "Site policy not agreed.", - "steppolicies": "Policy {{$a.numpolicy}} out of {{$a.totalpolicies}}" + "steppolicies": "Policy {{$a.numpolicy}} out of {{$a.totalpolicies}}", + "status1": "Active", + "useracceptanceactionaccept": "Accept", + "useracceptanceactionacceptone": "Accept {{$a}}", + "useracceptanceactiondecline": "Decline", + "useracceptanceactiondeclineone": "Decline {{$a}}", + "useracceptanceactionrevoke": "Withdraw", + "useracceptanceactionrevokeone": "Withdraw acceptance of {{$a}}", + "viewpolicy": "View policy {{policyname}}." } diff --git a/src/core/features/policy/pages/acceptances/acceptances.html b/src/core/features/policy/pages/acceptances/acceptances.html new file mode 100644 index 000000000..d9a123a62 --- /dev/null +++ b/src/core/features/policy/pages/acceptances/acceptances.html @@ -0,0 +1,309 @@ + + + + + + + +

{{ 'core.policy.policiesagreements' | translate }}

+
+
+
+ + + + + +
+ + + +
+ {{ 'core.contactverb' | translate }} +
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ 'core.policy.policydocname' | translate }}{{ 'core.policy.policydocrevision' | translate }}{{ 'core.policy.response' | translate }}{{ 'core.policy.responseon' | translate }}{{ 'core.policy.responseby' | translate }}{{ 'core.policy.acceptancenote' | translate }}
+ +
+ + + +
+ + + +
+
+ + + + + + + + + + +
+
+ +

{{ policy.revision }}

+

{{ policy.name }}

+
+ + +
+ +
+ + + +

{{ 'core.policy.policydocname' | translate }}

+

{{ policy.name }}

+
+ +

{{ 'core.policy.policydocrevision' | translate }}

+

{{ policy.revision }}

+
+ + {{ 'core.policy.status1' | translate }} + + + {{ 'core.policy.policydocoptionalyes' | translate }} + +
+
+ + + +

{{ 'core.policy.response' | translate }}

+

+ + + + + + +

+
+
+ + + +

{{ 'core.policy.responseon' | translate }}

+

+ {{ policy.acceptance.timemodified * 1000 | coreFormatDate:'strftimedatetime' }} +

+

-

+
+
+ + + + +

{{ 'core.policy.responseby' | translate }}

+

{{ policy.acceptance.modfullname }}

+
+
+ + + +

{{ 'core.policy.acceptancenote' | translate }}

+

+ +

+

-

+
+
+
+ +
+ + + +
+
+
diff --git a/src/core/features/policy/pages/acceptances/acceptances.scss b/src/core/features/policy/pages/acceptances/acceptances.scss new file mode 100644 index 000000000..eca3e66bd --- /dev/null +++ b/src/core/features/policy/pages/acceptances/acceptances.scss @@ -0,0 +1,129 @@ +@use "theme/globals" as *; + +:host { + .core-policy-revision ion-badge { + @include margin-horizontal(0px, 4px); + } + + .core-policy-responseby-name { + color: var(--core-link-color); + text-decoration: none; + } + + .core-policy-user-agreement-info { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: flex-start; + + ion-icon { + @include margin-horizontal(0px, 8px); + font-size: 1rem; + } + + ion-button { + --background: transparent; + color: var(--ion-color-primary); + margin-top: 0px; + margin-bottom: 0px; + text-transform: none; + + &:first-child:not(:last-child) { + @include margin-horizontal(null, 0px); + } + &:not(:first-child) { + @include margin-horizontal(0px, null); + } + } + } + + .core-policy-mobile-container { + .core-policy-title div[slot="start"] { + display: flex; + align-items: center; + margin: 0px; + + ion-icon { + font-size: 1.125rem; + margin: 0px; + } + } + + .core-policy-details { + .item:not(.core-policy-title) { + --background: var(--gray-100); + } + + .core-policy-user-agreement.core-policy-agreement-has-actions { + ion-label { + margin-bottom: 0px; + } + + .core-policy-user-agreement-status { + line-height: 44px; + } + } + + .core-policy-user-agreement-info { + flex-wrap: wrap; + justify-content: flex-end; + + .core-policy-user-agreement-status { + flex-grow: 1; + } + } + } + } + + table.core-policy-tablet-container { + th { + &:first-child { + @include padding-horizontal(24px, 0px); + } + } + + th, td { + min-width: 200px; + text-wrap: nowrap; + } + + p { + margin: 0px; + } + + .core-policy-title { + display: flex; + align-items: center; + + ion-icon { + font-size: 1.125rem; + padding: 13px; + margin: 3px; + } + + .core-policy-icon-placeholder { + visibility: hidden; + } + } + + .core-policy-revision p { + margin-bottom: 4px; + } + + } + + .core-policy-mobile-container .core-policy-title .expandable-status-icon, + .core-policy-tablet-container .core-policy-title ion-icon { + min-height: auto; + min-width: auto; + font-size: 1.125rem; + padding: 13px; + } + + .core-policy-title .expandable-status-icon { + border-radius: 50%; + &:hover { + background: var(--secondary); + } + } +} diff --git a/src/core/features/policy/pages/acceptances/acceptances.ts b/src/core/features/policy/pages/acceptances/acceptances.ts new file mode 100644 index 000000000..0212671ec --- /dev/null +++ b/src/core/features/policy/pages/acceptances/acceptances.ts @@ -0,0 +1,284 @@ +// (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, OnDestroy, OnInit } from '@angular/core'; + +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; +import { Translate } from '@singletons'; +import { CorePolicy, CorePolicySitePolicy, CorePolicyStatus } from '@features/policy/services/policy'; +import { CorePolicyViewPolicyModalComponent } from '@features/policy/components/policy-modal/policy-modal'; +import { CoreTime } from '@singletons/time'; +import { CoreScreen } from '@services/screen'; +import { Subscription } from 'rxjs'; +import { CORE_DATAPRIVACY_PAGE_NAME } from '@features/dataprivacy/constants'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDataPrivacy } from '@features/dataprivacy/services/dataprivacy'; + +/** + * Page to view user acceptances. + */ +@Component({ + selector: 'page-core-policy-acceptances', + templateUrl: 'acceptances.html', + styleUrls: ['acceptances.scss'], +}) +export class CorePolicyAcceptancesPage implements OnInit, OnDestroy { + + dataLoaded = false; + policies: ActiveSitePolicy[] = []; + activeStatus = CorePolicyStatus.Active; + isTablet = false; + hasOnBehalf = false; + canContactDPO = false; + + protected logView: () => void; + protected layoutSubscription?: Subscription; + + constructor() { + this.logView = CoreTime.once(() => { + const currentUserId = CoreSites.getCurrentSiteUserId(); + + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM_LIST, + ws: 'tool_policy_get_user_acceptances', + name: Translate.instant('core.policy.policiesagreements'), + data: { userid: currentUserId }, + url: `/admin/tool/policy/user.php?userid=${currentUserId}`, + }); + }); + } + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.isTablet = CoreScreen.isTablet; + this.layoutSubscription = CoreScreen.layoutObservable.subscribe(() => { + this.isTablet = CoreScreen.isTablet; + }); + + this.fetchCanContactDPO(); + this.fetchAcceptances().finally(() => { + this.dataLoaded = true; + }); + } + + /** + * Check if user can contact DPO. + */ + protected async fetchCanContactDPO(): Promise { + this.canContactDPO = await CoreUtils.ignoreErrors(CoreDataPrivacy.isEnabled(), false); + } + + /** + * Fetch the policies and acceptances. + * + * @returns Promise resolved when done. + */ + protected async fetchAcceptances(): Promise { + try { + const allPolicies = await CorePolicy.getUserAcceptances(); + + this.hasOnBehalf = false; + + const policiesById = allPolicies.reduce((groupedPolicies, policy) => { + const formattedPolicy = this.formatSitePolicy(policy); + this.hasOnBehalf = this.hasOnBehalf || formattedPolicy.onBehalf; + + groupedPolicies[policy.policyid] = groupedPolicies[policy.policyid] || []; + groupedPolicies[policy.policyid].push(formattedPolicy); + + return groupedPolicies; + }, > {}); + + this.policies = []; + for (const policyId in policiesById) { + const policyVersions = policiesById[policyId]; + + let activePolicy: ActiveSitePolicy | undefined = + policyVersions.find((policy) => policy.status === CorePolicyStatus.Active); + if (!activePolicy) { + // No active policy, it shouldn't happen. Use the one with highest versionid. + policyVersions.sort((a, b) => b.versionid - a.versionid); + activePolicy = policyVersions[0]; + } + + activePolicy.previousVersions = policyVersions.filter(policy => policy !== activePolicy); + this.policies.push(activePolicy); + } + + this.logView(); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error getting policies.'); + } + } + + /** + * Format a site policy, adding some calculated data. + * + * @param policy Policy to format. + * @param expanded Whether the policy should be expanded or not. + * @returns Formatted policy. + */ + protected formatSitePolicy(policy: CorePolicySitePolicy, expanded = false): SitePolicy { + const hasAccepted = policy.acceptance?.status === 1; + const hasDeclined = policy.acceptance?.status === 0; + const onBehalf = !!policy.acceptance && policy.acceptance.usermodified !== CoreSites.getCurrentSiteUserId(); + + return { + ...policy, + expanded, + hasAccepted, + hasDeclined, + onBehalf, + hasActions: hasDeclined || !hasAccepted || !!policy.optional, + }; + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + */ + async refreshAcceptances(refresher?: HTMLIonRefresherElement): Promise { + await CoreUtils.ignoreErrors(CorePolicy.invalidateAcceptances()); + + await CoreUtils.ignoreErrors(this.fetchAcceptances()); + + refresher?.complete(); + } + + /** + * Toogle the visibility of a policy (expand/collapse). + * + * @param event Event. + * @param policy Policy. + */ + toggle(event: Event, policy: SitePolicy): void { + event.preventDefault(); + event.stopPropagation(); + policy.expanded = !policy.expanded; + } + + /** + * View the full policy. + * + * @param event Event. + * @param policy Policy. + */ + viewFullPolicy(event: Event, policy: CorePolicySitePolicy): void { + event.preventDefault(); + event.stopPropagation(); + + CoreDomUtils.openModal({ + component: CorePolicyViewPolicyModalComponent, + componentProps: { policy }, + }); + } + + /** + * Set the acceptance of a policy. + * + * @param event Event. + * @param policy Policy + * @param accept Whether to accept or not. + */ + async setAcceptance(event: Event, policy: SitePolicy, accept: boolean): Promise { + event.preventDefault(); + event.stopPropagation(); + + const modal = await CoreDomUtils.showModalLoading('core.sending', true); + + try { + await CorePolicy.setUserAcceptances({ [policy.versionid]: accept ? 1 : 0 }); + + await this.updatePolicyAcceptance(policy, accept); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error changing policy status.'); + } finally { + modal.dismiss(); + } + } + + /** + * Update the acceptance data for a certain policy. + * + * @param policy Policy to update. + * @param accepted Whether the policy has just been accepted or declined. + */ + protected async updatePolicyAcceptance(policy: SitePolicy, accepted: boolean): Promise { + try { + const policies = await CorePolicy.getUserAcceptances({ readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK }); + + const newPolicy = policies.find((p) => p.versionid === policy.versionid); + + if (!newPolicy) { + throw new Error('Policy not found.'); + } + + policy.acceptance = newPolicy.acceptance; + } catch (error) { + // Error updating the acceptance, calculate it in the app. + policy.acceptance = { + status: accepted ? 1 : 0, + lang: policy.acceptance?.lang ?? 'en', + timemodified: Date.now(), + usermodified: CoreSites.getCurrentSiteUserId(), + }; + } + + Object.assign(policy, this.formatSitePolicy(policy, policy.expanded)); + } + + /** + * Open page to contact DPO. + * + * @param event Event. + */ + openContactDPO(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + CoreNavigator.navigateToSitePath(CORE_DATAPRIVACY_PAGE_NAME); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.layoutSubscription?.unsubscribe(); + } + +} + +/** + * Site policy with some calculated data. + */ +type SitePolicy = CorePolicySitePolicy & { + expanded: boolean; + hasAccepted: boolean; + hasDeclined: boolean; + onBehalf: boolean; + hasActions: boolean; +}; + +/** + * Active site policy with some calculated data. + */ +type ActiveSitePolicy = SitePolicy & { + previousVersions?: SitePolicy[]; +}; diff --git a/src/core/features/policy/pages/site-policy/site-policy.scss b/src/core/features/policy/pages/site-policy/site-policy.scss index 9d5a17fa8..b19040ec6 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.scss +++ b/src/core/features/policy/pages/site-policy/site-policy.scss @@ -73,7 +73,7 @@ } } - .item ::ng-deep ion-label p span { + .item ::ng-deep ion-label .core-site-policy-full-policy-link { color: var(--core-link-color); } diff --git a/src/core/features/policy/policy-lazy.module.ts b/src/core/features/policy/policy-lazy.module.ts index eb49beae2..660117563 100644 --- a/src/core/features/policy/policy-lazy.module.ts +++ b/src/core/features/policy/policy-lazy.module.ts @@ -17,14 +17,19 @@ import { RouterModule, Routes } from '@angular/router'; import { CoreSharedModule } from '@/core/shared.module'; import { CorePolicySitePolicyPage } from '@features/policy/pages/site-policy/site-policy'; -import { SITE_POLICY_PAGE_NAME } from './constants'; +import { ACCEPTANCES_PAGE_NAME, SITE_POLICY_PAGE_NAME } from './constants'; import { CorePolicyComponentsModule } from './components/components.module'; +import { CorePolicyAcceptancesPage } from './pages/acceptances/acceptances'; const routes: Routes = [ { path: SITE_POLICY_PAGE_NAME, component: CorePolicySitePolicyPage, }, + { + path: ACCEPTANCES_PAGE_NAME, + component: CorePolicyAcceptancesPage, + }, ]; @NgModule({ @@ -35,6 +40,7 @@ const routes: Routes = [ ], declarations: [ CorePolicySitePolicyPage, + CorePolicyAcceptancesPage, ], }) export class CorePolicyLazyModule {} diff --git a/src/core/features/policy/policy.module.ts b/src/core/features/policy/policy.module.ts index 54ace343d..407713570 100644 --- a/src/core/features/policy/policy.module.ts +++ b/src/core/features/policy/policy.module.ts @@ -18,6 +18,11 @@ import { Routes } from '@angular/router'; import { AppRoutingModule } from '@/app/app-routing.module'; import { CoreEvents } from '@singletons/events'; import { POLICY_PAGE_NAME } from './constants'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CorePolicyUserHandler } from './services/handlers/user'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CorePolicyAcceptancesLinkHandler } from './services/handlers/acceptances-link'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; const routes: Routes = [ { @@ -29,12 +34,16 @@ const routes: Routes = [ @NgModule({ imports: [ AppRoutingModule.forChild(routes), + CoreMainMenuTabRoutingModule.forChild(routes), ], providers: [ { provide: APP_INITIALIZER, multi: true, useValue: async () => { + CoreUserDelegate.registerHandler(CorePolicyUserHandler.instance); + CoreContentLinksDelegate.registerHandler(CorePolicyAcceptancesLinkHandler.instance); + CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, async (data) => { const { CorePolicy } = await import('@features/policy/services/policy'); diff --git a/src/core/features/policy/services/handlers/acceptances-link.ts b/src/core/features/policy/services/handlers/acceptances-link.ts new file mode 100644 index 000000000..047de6610 --- /dev/null +++ b/src/core/features/policy/services/handlers/acceptances-link.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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { CorePolicy } from '../policy'; +import { ACCEPTANCES_PAGE_NAME, POLICY_PAGE_NAME } from '@features/policy/constants'; +import { CoreSites } from '@services/sites'; + +/** + * Handler to treat links to policy acceptances page. + */ +@Injectable({ providedIn: 'root' }) +export class CorePolicyAcceptancesLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'CorePolicyAcceptancesLinkHandler'; + pattern = /\/admin\/tool\/policy\/user\.php/; + featureName = 'CoreUserDelegate_CorePolicy'; + + /** + * @inheritdoc + */ + getActions(): CoreContentLinksAction[] { + return [{ + action: async (siteId: string): Promise => { + await CoreNavigator.navigateToSitePath(`/${POLICY_PAGE_NAME}/${ACCEPTANCES_PAGE_NAME}`, { siteId }); + }, + }]; + } + + /** + * @inheritdoc + */ + async isEnabled(siteId: string, url: string, params: Record): Promise { + const site = await CoreSites.getSite(siteId); + const userId = Number(params.userid); + + if (userId && userId !== site.getUserId()) { + // Only viewing your own policies is supported. + return false; + } + + return CorePolicy.isManageAcceptancesAvailable(siteId); + } + +} + +export const CorePolicyAcceptancesLinkHandler = makeSingleton(CorePolicyAcceptancesLinkHandlerService); diff --git a/src/core/features/policy/services/handlers/user.ts b/src/core/features/policy/services/handlers/user.ts new file mode 100644 index 000000000..730f8401a --- /dev/null +++ b/src/core/features/policy/services/handlers/user.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 { Injectable } from '@angular/core'; +import { + CoreUserDelegateContext, + CoreUserProfileHandlerType, + CoreUserProfileHandler, + CoreUserProfileHandlerData, +} from '@features/user/services/user-delegate'; +import { CorePolicy } from '../policy'; +import { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUserProfile } from '@features/user/services/user'; +import { ACCEPTANCES_PAGE_NAME, POLICY_PAGE_NAME } from '@features/policy/constants'; + +/** + * Handler to inject an option into user menu. + */ +@Injectable({ providedIn: 'root' }) +export class CorePolicyUserHandlerService implements CoreUserProfileHandler { + + type = CoreUserProfileHandlerType.LIST_ACCOUNT_ITEM; + name = 'CorePolicy'; + priority = 50; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + const wsAvailable = await CorePolicy.isManageAcceptancesAvailable(); + if (!wsAvailable) { + return false; + } + + const policyHandler = await CoreSites.getCurrentSite()?.getConfig('sitepolicyhandler'); + + return policyHandler === 'tool_policy'; + } + + /** + * @inheritdoc + */ + async isEnabledForContext(context: CoreUserDelegateContext): Promise { + return context === CoreUserDelegateContext.USER_MENU; + } + + /** + * @inheritdoc + */ + async isEnabledForUser(user: CoreUserProfile): Promise { + return user.id == CoreSites.getCurrentSiteUserId(); + } + + /** + * @inheritdoc + */ + getDisplayData(): CoreUserProfileHandlerData { + return { + icon: 'fas-file-shield', + title: 'core.policy.policiesagreements', + class: 'core-policy-user-handler', + action: (event): void => { + event.preventDefault(); + event.stopPropagation(); + CoreNavigator.navigateToSitePath(`/${POLICY_PAGE_NAME}/${ACCEPTANCES_PAGE_NAME}`); + }, + }; + } + +} + +export const CorePolicyUserHandler = makeSingleton(CorePolicyUserHandlerService); diff --git a/src/core/features/policy/services/policy.ts b/src/core/features/policy/services/policy.ts index c5caa910e..82caaafeb 100644 --- a/src/core/features/policy/services/policy.ts +++ b/src/core/features/policy/services/policy.ts @@ -127,14 +127,15 @@ export class CorePolicyService { * @param options Options * @returns List of policies with their acceptances. */ - async getUserAcceptances(options: CoreSitesCommonWSOptions = {}): Promise { + async getUserAcceptances(options: CorePolicyGetAcceptancesOptions = {}): Promise { const site = await CoreSites.getSite(options.siteId); + const userId = options.userId || site.getUserId(); const data: CorePolicyGetUserAcceptancesWSParams = { - userid: site.getUserId(), + userid: userId, }; const preSets = { - cacheKey: this.getUserAcceptancesCacheKey(site.getUserId()), + cacheKey: this.getUserAcceptancesCacheKey(userId), updateFrequency: CoreSite.FREQUENCY_RARELY, ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), }; @@ -179,6 +180,18 @@ export class CorePolicyService { CoreNavigator.navigate(routePath, { params: { siteId }, reset: true }); } + /** + * Invalidate acceptances WS call. + * + * @param options Options. + * @returns Promise resolved when data is invalidated. + */ + async invalidateAcceptances(options: {userId?: number; siteId?: string} = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + await site.invalidateWsCacheForKey(this.getUserAcceptancesCacheKey(options.userId || site.getUserId())); + } + /** * Check whether a site allows getting and setting acceptances. * @@ -221,6 +234,13 @@ export class CorePolicyService { export const CorePolicy = makeSingleton(CorePolicyService); +/** + * Options for get policy acceptances. + */ +type CorePolicyGetAcceptancesOptions = CoreSitesCommonWSOptions & { + userId?: number; // User ID. If not defined, current user. +}; + /** * Result of WS core_user_agree_site_policy. */ @@ -260,6 +280,9 @@ export type CorePolicySitePolicy = { content?: string; // The policy content. contentformat: number; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN, or 4 = MARKDOWN). acceptance?: CorePolicySitePolicyAcceptance; // Acceptance status for the given user. + canaccept: boolean; // Whether the policy can be accepted. + candecline: boolean; // Whether the policy can be declined. + canrevoke: boolean; // Whether the policy can be revoked. }; /** @@ -300,3 +323,12 @@ export enum CorePolicyAgreementStyle { ConsentPage = 0, // Policy to be accepted together with others on the consent page. OwnPage = 1, // Policy to be accepted on its own page before reaching the consent page. } + +/** + * Status of a policy. + */ +export enum CorePolicyStatus { + Draft = 0, + Active = 1, + Archived = 2, +} diff --git a/src/core/features/policy/tests/behat/consent.feature b/src/core/features/policy/tests/behat/consent.feature new file mode 100755 index 000000000..b95dc091a --- /dev/null +++ b/src/core/features/policy/tests/behat/consent.feature @@ -0,0 +1,179 @@ +@core_policy @app @javascript @lms_from4.4 +Feature: Test accepting pending policies on signup + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | student | User | One | one@example.com | + + Scenario: Accept policy using default handler + Given the following config values are set as admin: + | sitepolicyhandler | | + | sitepolicy | https://moodle.org/invalidfile.pdf | + When I launch the app + And I set the field "Your site" to "$WWWROOT" in the app + And I press "Connect to your site" in the app + And I set the following fields to these values in the app: + | Username | student | + | Password | student | + And I press "Log in" near "Lost password?" in the app + Then I should find "You must agree to this policy to continue using this site" in the app + But I should not be able to press "Continue" in the app + And I should not be able to press "User account" in the app + + When I press "Link to site policy agreement" in the app + And I press "OK" in the app + Then the app should have opened a browser tab with url "moodle.org" + + When I close the browser tab opened by the app + And I press "I have read and agree to the Site policy agreement" in the app + And I press "Continue" in the app + Then I should be able to press "User account" in the app + + When I press "User account" in the app + Then I should not find "Policies and agreements" in the app + + Scenario: Accept policy using tool_policy + Given the following config values are set as admin: + | sitepolicyhandler | tool_policy | + And the following policies exist: + | name | agreementstyle | optional | revision | content | summary | + | Mandatory policy own page | 1 | 0 | mo v1 | Content mand own page | Summ mand own page | + | Optional policy own page | 1 | 1 | oo v1 | Content opt own page | Summ opt own page | + | Mandatory policy consent page | 0 | 0 | mc v1 | Content mand consent page | Summ mand consent page | + | Optional policy consent page | 0 | 1 | oc v1 | Content opt consent page | Summ opt consent page | + When I launch the app + And I set the field "Your site" to "$WWWROOT" in the app + And I press "Connect to your site" in the app + And I set the following fields to these values in the app: + | Username | student | + | Password | student | + And I press "Log in" near "Lost password?" in the app + Then I should find "Mandatory policy own page" in the app + And I should find "Summ mand own page" in the app + And I should find "Content mand own page" in the app + But I should not be able to press "Continue" in the app + And I should not be able to press "User account" in the app + + When I press "I have read and agree to the Mandatory policy own page" in the app + And I press "I have read and agree to the Mandatory policy own page" in the app + Then I should find "Before continuing you need to acknowledge all these policies" in the app + But I should not be able to press "Continue" in the app + + When I press "I have read and agree to the Mandatory policy own page" in the app + And I press "Continue" in the app + Then I should find "Optional policy own page" in the app + And I should find "Summ opt own page" in the app + And I should find "Content opt own page" in the app + But I should not be able to press "Continue" in the app + + When I press "No thanks, I decline Optional policy own page" in the app + And I press "Continue" in the app + Then I should find "Policy 1 out of 2" in the app + And I should find "Mandatory policy consent page" in the app + And I should find "Summ mand consent page" in the app + And I should find "Content mand consent page" in the app + But I should not find "I have read and agree" in the app + + When I press "Next" in the app + Then I should find "Policy 2 out of 2" in the app + And I should find "Optional policy consent page" in the app + And I should find "Summ opt consent page" in the app + And I should find "Content opt consent page" in the app + But I should not find "No thanks, I decline" in the app + + When I press "Next" in the app + Then I should find "Please agree to the following policies" in the app + And I should find "Mandatory policy consent page" in the app + And I should find "Summ mand consent page" in the app + And I should find "Optional policy consent page" in the app + And I should find "Summ opt consent page" in the app + But I should not find "Content mand consent page" in the app + And I should not find "Content opt consent page" in the app + + When I press "Please refer to the full Mandatory policy consent page" in the app + Then I should find "Content mand consent page" in the app + But I should not find "Content opt consent page" in the app + + When I press "Close" in the app + And I press "Please refer to the full Optional policy consent page" in the app + Then I should find "Content opt consent page" in the app + But I should not find "Content mand consent page" in the app + + When I press "Close" in the app + And I press "Continue" in the app + Then I should find "Before continuing you need to acknowledge all these policies" in the app + + When I press "I have read and agree to the Mandatory policy consent page" in the app + And I press "Continue" in the app + Then I should find "Before continuing you need to acknowledge all these policies" in the app + + When I press "I have read and agree to the Optional policy consent page" in the app + And I press "Continue" in the app + Then I should be able to press "User account" in the app + + # TODO: Add a new version for a policy and check that the user is prompted to accept it. + # This is currently not possible with the current step to create policies. + + # View policies and agreements. Do it in this Scenario because there is no generator to set acceptances. + When I press "User account" in the app + And I press "Policies and agreements" in the app + Then I should find "Mandatory policy own page" in the app + And I should find "Optional policy own page" in the app + And I should find "Mandatory policy consent page" in the app + And I should find "Optional policy consent page" in the app + But I should not find "Revision" in the app + And I should not find "Summ mand own page" in the app + And I should not find "Content mand own page" in the app + + When I press "View policy Mandatory policy own page" in the app + Then I should find "Summ mand own page" in the app + And I should find "Content mand own page" in the app + But I should not find "Summ opt own page" in the app + + When I press "Close" in the app + And I press "Expand" within "Mandatory policy own page" "ion-item" in the app + Then I should find "mo v1" in the app + And I should find "Active" in the app + And I should find "Accepted" in the app + But I should not be able to press "Withdraw" in the app + + When I press "Collapse" within "Mandatory policy own page" "ion-item" in the app + And I press "Expand" within "Optional policy own page" "ion-item" in the app + Then I should find "oo v1" in the app + And I should find "Active" in the app + And I should find "Declined" in the app + + When I press "Accept" in the app + Then I should find "Accepted" in the app + But I should not find "Declined" in the app + + When I press "Withdraw" in the app + Then I should find "Declined" in the app + But I should not find "Accepted" in the app + + # Test tablet view now. + When I press the back button in the app + And I change viewport size to "1200x640" in the app + And I press "User account" in the app + And I press "Policies and agreements" in the app + Then I should find "Mandatory policy own page" in the app + And I should find "Optional policy own page" in the app + And I should find "Mandatory policy consent page" in the app + And I should find "Optional policy consent page" in the app + And I should find "mo v1" within "Mandatory policy own page" "tr" in the app + And I should find "Active" within "Mandatory policy own page" "tr" in the app + And I should find "Accepted" within "Mandatory policy own page" "tr" in the app + And I should find "Declined" within "Optional policy own page" "tr" in the app + But I should not be able to press "Withdraw" within "Mandatory policy own page" "tr" in the app + And I should not find "Summ mand own page" in the app + And I should not find "Content mand own page" in the app + + When I press "Mandatory policy own page" in the app + Then I should find "Summ mand own page" in the app + And I should find "Content mand own page" in the app + But I should not find "Summ opt own page" in the app + + When I press "Close" in the app + And I press "Accept" within "Optional policy own page" "tr" in the app + Then I should find "Accepted" within "Optional policy own page" "tr" in the app diff --git a/src/core/features/policy/tests/behat/contactdpo.feature b/src/core/features/policy/tests/behat/contactdpo.feature new file mode 100755 index 000000000..54e426413 --- /dev/null +++ b/src/core/features/policy/tests/behat/contactdpo.feature @@ -0,0 +1,28 @@ +@core_policy @app @javascript @lms_from4.4 +Feature: Test contact DPO from acceptances page + + Background: + Given the following config values are set as admin: + | sitepolicyhandler | tool_policy | + And the following "users" exist: + | username | firstname | lastname | email | + | student | User | One | one@example.com | + + Scenario: Cannot contact DPO if not enabled + When I entered the app as "student" + And I press the user menu button in the app + And I press "Policies and agreements" in the app + Then I should find "For any questions about the policies please contact the privacy officer" in the app + But I should not be able to press "Contact" in the app + + Scenario: Can contact DPO if enabled + Given the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + When I entered the app as "student" + And I press the user menu button in the app + And I press "Policies and agreements" in the app + Then I should find "For any questions about the policies please contact the privacy officer" in the app + + When I press "Contact" in the app + Then I should find "Data privacy" in the app + And I should be able to press "Contact the privacy officer" in the app diff --git a/src/core/lang.json b/src/core/lang.json index 5138ebfac..7180aa284 100644 --- a/src/core/lang.json +++ b/src/core/lang.json @@ -55,6 +55,7 @@ "connectionlost": "Connection to site lost", "considereddigitalminor": "You are too young to create an account on this site.", "contactsupport": "Contact support", + "contactverb": "Contact", "content": "Content", "contenteditingsynced": "The content you are editing has been synced.", "continue": "Continue", diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 4ac4722f3..f23baa8c4 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -942,7 +942,7 @@ ion-card { display: flex; flex-direction: row; justify-content: flex-end; - margin: 0 8px 8px 8px; + margin: 0px 8px 8px 8px; ion-button { &[fill="outline"] { @@ -1633,9 +1633,10 @@ ion-item.item.divider { } ion-item-divider.item, -ion-item.item { +ion-item.item, +td { .expandable-status-icon { - font-size: 18px; + font-size: 1.125rem; @include core-transition(transform, 200ms); @include margin-horizontal(null, 16px);