From 521dc9eb6d7270405c2a2412cf43d3db0e9aed90 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 7 Feb 2024 11:38:13 +0100 Subject: [PATCH 1/8] MOBILE-2768 policy: Move site policy code to new feature --- scripts/langindex.json | 12 +- src/core/classes/sites/authenticated-site.ts | 2 +- src/core/features/features.module.ts | 2 + src/core/features/login/lang.json | 6 - src/core/features/login/login-lazy.module.ts | 6 - .../pages/email-signup/email-signup.html | 6 +- .../login/pages/email-signup/email-signup.ts | 8 +- .../features/login/services/login-helper.ts | 83 +++--------- src/core/features/policy/constants.ts | 17 +++ src/core/features/policy/lang.json | 8 ++ .../pages/site-policy/site-policy.html | 10 +- .../pages/site-policy/site-policy.scss | 0 .../pages/site-policy/site-policy.ts | 12 +- .../features/policy/policy-lazy.module.ts | 38 ++++++ src/core/features/policy/policy.module.ts | 60 +++++++++ src/core/features/policy/services/policy.ts | 121 ++++++++++++++++++ 16 files changed, 287 insertions(+), 104 deletions(-) create mode 100644 src/core/features/policy/constants.ts create mode 100644 src/core/features/policy/lang.json rename src/core/features/{login => policy}/pages/site-policy/site-policy.html (78%) rename src/core/features/{login => policy}/pages/site-policy/site-policy.scss (100%) rename src/core/features/{login => policy}/pages/site-policy/site-policy.ts (91%) create mode 100644 src/core/features/policy/policy-lazy.module.ts create mode 100644 src/core/features/policy/policy.module.ts create mode 100644 src/core/features/policy/services/policy.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index 009ac93f884..d3cf2c75f80 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2155,11 +2155,6 @@ "core.login.passwordforgotten": "moodle", "core.login.passwordforgotteninstructions2": "moodle", "core.login.passwordrequired": "local_moodlemobileapp", - "core.login.policyaccept": "moodle", - "core.login.policyacceptmandatory": "local_moodlemobileapp", - "core.login.policyagree": "moodle", - "core.login.policyagreement": "moodle", - "core.login.policyagreementclick": "moodle", "core.login.potentialidps": "auth", "core.login.profileinvaliddata": "admin", "core.login.recaptchachallengeimage": "local_moodlemobileapp", @@ -2183,7 +2178,6 @@ "core.login.sitehasredirect": "local_moodlemobileapp", "core.login.siteinmaintenance": "local_moodlemobileapp", "core.login.sitenotallowed": "local_moodlemobileapp", - "core.login.sitepolicynotagreederror": "local_moodlemobileapp", "core.login.siteurl": "local_moodlemobileapp", "core.login.siteurlrequired": "local_moodlemobileapp", "core.login.startsignup": "moodle", @@ -2288,6 +2282,12 @@ "core.phone": "moodle", "core.pictureof": "moodle", "core.play": "local_moodlemobileapp", + "core.policy.policyaccept": "moodle", + "core.policy.policyacceptmandatory": "local_moodlemobileapp", + "core.policy.policyagree": "moodle", + "core.policy.policyagreement": "moodle", + "core.policy.policyagreementclick": "moodle", + "core.policy.sitepolicynotagreederror": "local_moodlemobileapp", "core.previous": "moodle", "core.proceed": "moodle", "core.publicprofile": "moodle", diff --git a/src/core/classes/sites/authenticated-site.ts b/src/core/classes/sites/authenticated-site.ts index ed6f6b43d34..7db3ad6a969 100644 --- a/src/core/classes/sites/authenticated-site.ts +++ b/src/core/classes/sites/authenticated-site.ts @@ -669,7 +669,7 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite { } else if (error.errorcode === 'sitepolicynotagreed') { // Site policy not agreed, trigger event. this.triggerSiteEvent(CoreEvents.SITE_POLICY_NOT_AGREED, {}); - error.message = Translate.instant('core.login.sitepolicynotagreederror'); + error.message = Translate.instant('core.policy.sitepolicynotagreederror'); throw new CoreWSError(error); } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.hasUnicodeData(data)) { diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index 6a38207d825..a6acf68ce47 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -46,6 +46,7 @@ import { CoreUserToursModule } from './usertours/user-tours.module'; import { CoreViewerModule } from './viewer/viewer.module'; import { CoreXAPIModule } from './xapi/xapi.module'; import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module'; +import { CorePolicyModule } from './policy/policy.module'; @NgModule({ imports: [ @@ -80,6 +81,7 @@ import { CoreReportBuilderModule } from './reportbuilder/reportbuilder.module'; CoreViewerModule, CoreXAPIModule, CoreReportBuilderModule, + CorePolicyModule, // Import last to allow overrides. CoreEmulatorModule, diff --git a/src/core/features/login/lang.json b/src/core/features/login/lang.json index 2642ca11e87..edbf0c44c7c 100644 --- a/src/core/features/login/lang.json +++ b/src/core/features/login/lang.json @@ -90,11 +90,6 @@ "passwordforgotten": "Forgotten password", "passwordforgotteninstructions2": "To reset your password, submit your username or your email address below. If we can find you in the database, an email will be sent to your email address, with instructions how to get access again.", "passwordrequired": "Password required", - "policyaccept": "I understand and agree", - "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", "potentialidps": "Log in using your account on:", "profileinvaliddata": "Invalid value", "recaptchachallengeimage": "reCAPTCHA challenge image", @@ -118,7 +113,6 @@ "sitehasredirect": "Your site contains at least one HTTP redirect. The app cannot follow redirects, this could be the issue that's preventing the app from connecting to your site.", "siteinmaintenance": "Your site is in maintenance mode", "sitenotallowed": "This site is no longer available.", - "sitepolicynotagreederror": "Site policy not agreed.", "siteurl": "Site URL", "siteurlrequired": "Site URL required i.e https://campus.example.edu", "startsignup": "Create new account", diff --git a/src/core/features/login/login-lazy.module.ts b/src/core/features/login/login-lazy.module.ts index 0a575f5e71b..13e05d136b5 100644 --- a/src/core/features/login/login-lazy.module.ts +++ b/src/core/features/login/login-lazy.module.ts @@ -20,7 +20,6 @@ import { hasSitesGuard } from './guards/has-sites'; import { CoreLoginComponentsModule } from './components/components.module'; import { CoreLoginHelper } from './services/login-helper'; import { CoreLoginForgottenPasswordPage } from '@features/login/pages/forgotten-password/forgotten-password'; -import { CoreLoginSitePolicyPage } from '@features/login/pages/site-policy/site-policy'; import { CoreUserComponentsModule } from '@features/user/components/components.module'; import { CoreLoginEmailSignupPage } from '@features/login/pages/email-signup/email-signup'; import { CoreLoginSitePage } from '@features/login/pages/site/site'; @@ -54,10 +53,6 @@ const routes: Routes = [ path: 'changepassword', component: CoreLoginChangePasswordPage, }, - { - path: 'sitepolicy', - component: CoreLoginSitePolicyPage, - }, { path: 'emailsignup', component: CoreLoginEmailSignupPage, @@ -77,7 +72,6 @@ const routes: Routes = [ ], declarations: [ CoreLoginForgottenPasswordPage, - CoreLoginSitePolicyPage, CoreLoginSitePage, CoreLoginSitesPage, CoreLoginChangePasswordPage, diff --git a/src/core/features/login/pages/email-signup/email-signup.html b/src/core/features/login/pages/email-signup/email-signup.html index 1c721637651..d59e5b9999a 100644 --- a/src/core/features/login/pages/email-signup/email-signup.html +++ b/src/core/features/login/pages/email-signup/email-signup.html @@ -183,19 +183,19 @@

{{ 'core.login.security_question' | translate }} -

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

+

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

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

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

+

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

diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index 186ba464c64..5a05c71b21c 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -118,7 +118,7 @@ export class CoreLoginEmailSignupPage implements OnInit { }; this.passwordErrors = { required: 'core.login.passwordrequired' }; this.emailErrors = { required: 'core.login.missingemail' }; - this.policyErrors = { required: 'core.login.policyagree' }; + this.policyErrors = { required: 'core.policy.policyagree' }; this.email2Errors = { required: 'core.login.missingemail', pattern: 'core.login.emailnotmatch', @@ -215,11 +215,7 @@ export class CoreLoginEmailSignupPage implements OnInit { * @returns Promise resolved when done. */ protected async getSignupSettings(): Promise { - this.settings = await CoreWS.callAjax( - 'auth_email_get_signup_settings', - {}, - { siteUrl: this.site.getURL() }, - ); + this.settings = await CoreLoginHelper.getEmailSignupSettings(this.site.getURL()); if (CoreUserProfileFieldDelegate.hasRequiredUnsupportedField(this.settings.profilefields)) { this.allRequiredSupported = false; diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index c80fc521e16..a2aded067ed 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -57,6 +57,7 @@ import { IDENTITY_PROVIDER_FEATURE_NAME_PREFIX, } from '../constants'; import { LazyRoutesModule } from '@/app/app-routing.module'; +import { CorePolicy } from '@features/policy/services/policy'; /** * Helper provider that provides some common features regarding authentication. @@ -87,30 +88,10 @@ export class CoreLoginHelperProvider { * * @param siteId Site ID. If not defined, current site. * @returns Promise resolved if success, rejected if failure. + * @deprecated since 4.4. Use CorePolicy.acceptMandatoryPolicies instead. */ async acceptSitePolicy(siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); - - const result = await site.write('core_user_agree_site_policy', {}); - - if (result.status) { - return; - } - - if (!result.warnings?.length) { - throw new CoreError('Cannot agree site policy'); - } - - // Check if there is a warning 'alreadyagreed'. - const found = result.warnings.some((warning) => warning.warningcode === 'alreadyagreed'); - if (found) { - // Policy already agreed, treat it as a success. - return; - } - - // Another warning, reject. - throw new CoreWSError(result.warnings[0]); - + return CorePolicy.acceptMandatorySitePolicies(siteId); } /** @@ -286,36 +267,25 @@ export class CoreLoginHelperProvider { return params && params.oauthsso !== undefined ? Number(params.oauthsso) : undefined; } + /** + * Get email signup settings. + * + * @param siteUrl Site URL. + * @returns Signup settings. + */ + async getEmailSignupSettings(siteUrl: string): Promise { + return await CoreWS.callAjax('auth_email_get_signup_settings', {}, { siteUrl }); + } + /** * Get the site policy. * * @param siteId Site ID. If not defined, current site. * @returns Promise resolved with the site policy. + * @deprecated since 4.4. Use CorePolicy.getSitePoliciesURL instead. */ async getSitePolicy(siteId?: string): Promise { - const site = await CoreSites.getSite(siteId); - - let sitePolicy: string | undefined; - - try { - // Try to get the latest config, maybe the site policy was just added or has changed. - sitePolicy = await site.getConfig('sitepolicy', true); - } catch (error) { - // Cannot get config, try to get the site policy using auth_email_get_signup_settings. - const settings = await CoreWS.callAjax( - 'auth_email_get_signup_settings', - {}, - { siteUrl: site.getURL() }, - ); - - sitePolicy = settings.sitepolicy; - } - - if (!sitePolicy) { - throw new CoreError('Cannot retrieve site policy'); - } - - return sitePolicy; + return CorePolicy.getSitePoliciesURL(siteId); } /** @@ -1067,20 +1037,11 @@ export class CoreLoginHelperProvider { * Function called when site policy is not agreed. Reserved for core use. * * @param siteId Site ID. If not defined, current site. + * @returns void + * @deprecated since 4.4. Use CorePolicy.goToAcceptSitePolicies instead. */ sitePolicyNotAgreed(siteId?: string): void { - siteId = siteId || CoreSites.getCurrentSiteId(); - if (!siteId || siteId != CoreSites.getCurrentSiteId()) { - // Only current site allowed. - return; - } - - // If current page is already site policy, stop. - if (CoreNavigator.isCurrent('/login/sitepolicy')) { - return; - } - - CoreNavigator.navigate('/login/sitepolicy', { params: { siteId }, reset: true }); + return CorePolicy.goToAcceptSitePolicies(siteId); } /** @@ -1535,14 +1496,6 @@ export type CoreLoginSSOData = CoreRedirectPayload & { ssoUrlParams?: CoreUrlParams; // Other params added to the login url. }; -/** - * Result of WS core_user_agree_site_policy. - */ -type AgreeSitePolicyResult = { - status: boolean; // Status: true only if we set the policyagreed to 1 for the user. - warnings?: CoreWSExternalWarning[]; -}; - /** * Result of WS auth_email_get_signup_settings. */ diff --git a/src/core/features/policy/constants.ts b/src/core/features/policy/constants.ts new file mode 100644 index 00000000000..c341a326ada --- /dev/null +++ b/src/core/features/policy/constants.ts @@ -0,0 +1,17 @@ +// (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 POLICY_PAGE_NAME = 'policy'; +export const SITE_POLICY_PAGE_NAME = 'sitepolicy'; diff --git a/src/core/features/policy/lang.json b/src/core/features/policy/lang.json new file mode 100644 index 00000000000..66fdf8295fe --- /dev/null +++ b/src/core/features/policy/lang.json @@ -0,0 +1,8 @@ +{ + "policyaccept": "I understand and agree", + "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", + "sitepolicynotagreederror": "Site policy not agreed." +} diff --git a/src/core/features/login/pages/site-policy/site-policy.html b/src/core/features/policy/pages/site-policy/site-policy.html similarity index 78% rename from src/core/features/login/pages/site-policy/site-policy.html rename to src/core/features/policy/pages/site-policy/site-policy.html index 76ca02525f4..b17608f3046 100644 --- a/src/core/features/login/pages/site-policy/site-policy.html +++ b/src/core/features/policy/pages/site-policy/site-policy.html @@ -5,7 +5,7 @@ -

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

+

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

@@ -14,13 +14,13 @@

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

-

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

+

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

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

@@ -28,10 +28,10 @@

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

- {{ 'core.login.policyacceptmandatory' | translate }} + {{ 'core.policy.policyacceptmandatory' | translate }} - {{ 'core.login.cancel' | translate }} + {{ 'core.cancel' | translate }}
diff --git a/src/core/features/login/pages/site-policy/site-policy.scss b/src/core/features/policy/pages/site-policy/site-policy.scss similarity index 100% rename from src/core/features/login/pages/site-policy/site-policy.scss rename to src/core/features/policy/pages/site-policy/site-policy.scss diff --git a/src/core/features/login/pages/site-policy/site-policy.ts b/src/core/features/policy/pages/site-policy/site-policy.ts similarity index 91% rename from src/core/features/login/pages/site-policy/site-policy.ts rename to src/core/features/policy/pages/site-policy/site-policy.ts index 3d28649b40b..55997cbcc98 100644 --- a/src/core/features/login/pages/site-policy/site-policy.ts +++ b/src/core/features/policy/pages/site-policy/site-policy.ts @@ -18,22 +18,22 @@ import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; -import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreSite } from '@classes/sites/site'; import { CoreNavigator } from '@services/navigator'; import { CoreEvents } from '@singletons/events'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { Translate } from '@singletons'; +import { CorePolicy } from '@features/policy/services/policy'; /** * Page to accept a site policy. */ @Component({ - selector: 'page-core-login-site-policy', + selector: 'page-core-policy-site-policy', templateUrl: 'site-policy.html', styleUrls: ['site-policy.scss'], }) -export class CoreLoginSitePolicyPage implements OnInit { +export class CorePolicySitePolicyPage implements OnInit { sitePolicy?: string; showInline?: boolean; @@ -76,7 +76,7 @@ export class CoreLoginSitePolicyPage implements OnInit { */ protected async fetchSitePolicy(): Promise { try { - this.sitePolicy = await CoreLoginHelper.getSitePolicy(this.siteId); + this.sitePolicy = await CorePolicy.getSitePoliciesURL(this.siteId); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting site policy.'); this.cancel(); @@ -100,7 +100,7 @@ export class CoreLoginSitePolicyPage implements OnInit { CoreAnalytics.logEvent({ type: CoreAnalyticsEventType.VIEW_ITEM, ws: 'auth_email_get_signup_settings', - name: Translate.instant('core.login.policyagreement'), + name: Translate.instant('core.policy.policyagreement'), data: { category: 'policy' }, url: '/user/policy.php', }); @@ -126,7 +126,7 @@ export class CoreLoginSitePolicyPage implements OnInit { const modal = await CoreDomUtils.showModalLoading('core.sending', true); try { - await CoreLoginHelper.acceptSitePolicy(this.siteId); + await CorePolicy.acceptMandatorySitePolicies(this.siteId); // Success accepting, go to site initial page. // Invalidate cache since some WS don't return error if site policy is not accepted. diff --git a/src/core/features/policy/policy-lazy.module.ts b/src/core/features/policy/policy-lazy.module.ts new file mode 100644 index 00000000000..fd9bca426e2 --- /dev/null +++ b/src/core/features/policy/policy-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 { CorePolicySitePolicyPage } from '@features/policy/pages/site-policy/site-policy'; +import { SITE_POLICY_PAGE_NAME } from './constants'; + +const routes: Routes = [ + { + path: SITE_POLICY_PAGE_NAME, + component: CorePolicySitePolicyPage, + }, +]; + +@NgModule({ + imports: [ + CoreSharedModule, + RouterModule.forChild(routes), + ], + declarations: [ + CorePolicySitePolicyPage, + ], +}) +export class CorePolicyLazyModule {} diff --git a/src/core/features/policy/policy.module.ts b/src/core/features/policy/policy.module.ts new file mode 100644 index 00000000000..b8c79184df7 --- /dev/null +++ b/src/core/features/policy/policy.module.ts @@ -0,0 +1,60 @@ +// (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, Type } from '@angular/core'; +import { Routes } from '@angular/router'; + +import { AppRoutingModule } from '@/app/app-routing.module'; +import { CoreEvents } from '@singletons/events'; +import { POLICY_PAGE_NAME } from './constants'; + +/** + * Get policy services. + * + * @returns Policy services. + */ +export async function getPolicyServices(): Promise[]> { + const { CorePolicyService } = await import('@features/policy/services/policy'); + + return [ + CorePolicyService, + ]; +} + +const routes: Routes = [ + { + path: POLICY_PAGE_NAME, + loadChildren: () => import('./policy-lazy.module').then(m => m.CorePolicyLazyModule), + }, +]; + +@NgModule({ + imports: [ + AppRoutingModule.forChild(routes), + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + useValue: async () => { + CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, async (data) => { + const { CorePolicy } = await import('@features/policy/services/policy'); + + CorePolicy.goToAcceptSitePolicies(data.siteId); + }); + }, + }, + ], +}) +export class CorePolicyModule {} diff --git a/src/core/features/policy/services/policy.ts b/src/core/features/policy/services/policy.ts new file mode 100644 index 00000000000..b77363700b2 --- /dev/null +++ b/src/core/features/policy/services/policy.ts @@ -0,0 +1,121 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreLoginHelper } from '@features/login/services/login-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreWSExternalWarning } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { POLICY_PAGE_NAME, SITE_POLICY_PAGE_NAME } from '../constants'; + +/** + * Service that provides some common features regarding policies. + */ +@Injectable({ providedIn: 'root' }) +export class CorePolicyService { + + /** + * Accept all mandatory site policies. + * + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved if success, rejected if failure. + */ + async acceptMandatorySitePolicies(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const result = await site.write('core_user_agree_site_policy', {}); + + if (result.status) { + return; + } + + if (!result.warnings?.length) { + throw new CoreError('Cannot agree site policy'); + } + + // Check if there is a warning 'alreadyagreed'. + const found = result.warnings.some((warning) => warning.warningcode === 'alreadyagreed'); + if (found) { + // Policy already agreed, treat it as a success. + return; + } + + // Another warning, reject. + throw new CoreWSError(result.warnings[0]); + } + + /** + * Get the URL to view the site policy (or all the site policies in a single page if there's more than one). + * + * @param siteId Site ID. If not defined, current site. + * @returns Promise resolved with the site policy. + */ + async getSitePoliciesURL(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + let sitePolicy: string | undefined; + + try { + // Try to get the latest config, maybe the site policy was just added or has changed. + sitePolicy = await site.getConfig('sitepolicy', true); + } catch (error) { + // Cannot get config, try to get the site policy using signup settings. + const settings = await CoreLoginHelper.getEmailSignupSettings(site.getURL()); + + sitePolicy = settings.sitepolicy; + } + + if (!sitePolicy) { + throw new CoreError('Cannot retrieve site policy'); + } + + return sitePolicy; + } + + /** + * Open page to accept site policies. + * + * @param siteId Site ID. If not defined, current site. + */ + goToAcceptSitePolicies(siteId?: string): void { + siteId = siteId || CoreSites.getCurrentSiteId(); + if (!siteId || siteId != CoreSites.getCurrentSiteId()) { + // Only current site allowed. + return; + } + + const routePath = `/${POLICY_PAGE_NAME}/${SITE_POLICY_PAGE_NAME}`; + + // If current page is already site policy, stop. + if (CoreNavigator.isCurrent(routePath)) { + return; + } + + CoreNavigator.navigate(routePath, { params: { siteId }, reset: true }); + } + +} + +export const CorePolicy = makeSingleton(CorePolicyService); + +/** + * Result of WS core_user_agree_site_policy. + */ +type AgreeSitePolicyResult = { + status: boolean; // Status: true only if we set the policyagreed to 1 for the user. + warnings?: CoreWSExternalWarning[]; +}; From 9b8414e8b4be3c3a9babfc40a1962263ab3b97ce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 12 Feb 2024 13:00:27 +0100 Subject: [PATCH 2/8] MOBILE-2768 mimetype: Improve mimetype detection for PHP URLs --- src/core/services/utils/utils.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 312dc74d370..00c1e272936 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -749,16 +749,17 @@ export class CoreUtilsProvider { async getMimeTypeFromUrl(url: string): Promise { // First check if it can be guessed from the URL. const extension = CoreMimetypeUtils.guessExtensionFromUrl(url); - let mimetype = extension && CoreMimetypeUtils.getMimeType(extension); + const mimetype = extension && CoreMimetypeUtils.getMimeType(extension); - if (mimetype) { + // Ignore PHP extension for now, it could be serving a file. + if (mimetype && extension !== 'php') { return mimetype; } // Can't be guessed, get the remote mimetype. - mimetype = await CoreWS.getRemoteFileMimeType(url); + const remoteMimetype = await CoreWS.getRemoteFileMimeType(url); - return mimetype || ''; + return remoteMimetype || mimetype || ''; } /** From 380bd72087b9acca8c4e39de7deb670feb63f342 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 13 Feb 2024 13:08:26 +0100 Subject: [PATCH 3/8] MOBILE-2768 site: Add missing fields in site info type --- src/core/classes/sites/unauthenticated-site.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/classes/sites/unauthenticated-site.ts b/src/core/classes/sites/unauthenticated-site.ts index 0dd9c69fe3e..0f999406b49 100644 --- a/src/core/classes/sites/unauthenticated-site.ts +++ b/src/core/classes/sites/unauthenticated-site.ts @@ -374,6 +374,9 @@ export type CoreSiteInfoResponse = { usercalendartype?: string; // Calendar typed used by the user. userissiteadmin?: boolean; // Whether the user is a site admin or not. theme?: string; // Current theme for the user. + limitconcurrentlogins?: number; // @since 4.0. Number of concurrent sessions allowed. + usersessionscount?: number; // @since 4.0. Number of active sessions for current user. Only if limitconcurrentlogins is used. + policyagreed?: number; // @since 4.4. Whether user accepted all the policies. }; /** From d7ce9677464ad3a883f7236b4e9638f38843d403 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 13 Feb 2024 13:09:03 +0100 Subject: [PATCH 4/8] MOBILE-2768 policy: Implement new WebService calls --- src/core/features/policy/services/policy.ts | 147 +++++++++++++++++++- 1 file changed, 144 insertions(+), 3 deletions(-) diff --git a/src/core/features/policy/services/policy.ts b/src/core/features/policy/services/policy.ts index b77363700b2..d67b71aca0e 100644 --- a/src/core/features/policy/services/policy.ts +++ b/src/core/features/policy/services/policy.ts @@ -17,10 +17,11 @@ import { CoreError } from '@classes/errors/error'; import { CoreWSError } from '@classes/errors/wserror'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { CoreNavigator } from '@services/navigator'; -import { CoreSites } from '@services/sites'; +import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; import { CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; import { POLICY_PAGE_NAME, SITE_POLICY_PAGE_NAME } from '../constants'; +import { CoreSite } from '@classes/sites/site'; /** * Service that provides some common features regarding policies. @@ -28,6 +29,8 @@ import { POLICY_PAGE_NAME, SITE_POLICY_PAGE_NAME } from '../constants'; @Injectable({ providedIn: 'root' }) export class CorePolicyService { + protected static readonly ROOT_CACHE_KEY = 'CorePolicy:'; + /** * Accept all mandatory site policies. * @@ -37,7 +40,7 @@ export class CorePolicyService { async acceptMandatorySitePolicies(siteId?: string): Promise { const site = await CoreSites.getSite(siteId); - const result = await site.write('core_user_agree_site_policy', {}); + const result = await site.write('core_user_agree_site_policy', {}); if (result.status) { return; @@ -86,6 +89,42 @@ export class CorePolicyService { return sitePolicy; } + /** + * Get user acceptances. + * + * @param options Options + * @returns List of policies with their acceptances. + */ + async getUserAcceptances(options: CoreSitesCommonWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const data: CorePolicyGetUserAcceptancesWSParams = { + userid: site.getUserId(), + }; + const preSets = { + cacheKey: this.getUserAcceptancesCacheKey(site.getUserId()), + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), + }; + + const response = await site.read('tool_policy_get_user_acceptances', data, preSets); + if (response.warnings?.length) { + throw new CoreWSError(response.warnings[0]); + } + + return response.policies; + } + + /** + * Get the cache key for the get user acceptances call. + * + * @param userId ID of the user to get the badges from. + * @returns Cache key. + */ + protected getUserAcceptancesCacheKey(userId: number): string { + return CorePolicyService.ROOT_CACHE_KEY + 'userAcceptances:' + userId; + } + /** * Open page to accept site policies. * @@ -108,6 +147,44 @@ export class CorePolicyService { CoreNavigator.navigate(routePath, { params: { siteId }, reset: true }); } + /** + * Check whether a site allows getting and setting acceptances. + * + * @param siteId Site Id. + * @returns Whether the site allows getting and setting acceptances. + */ + async isManageAcceptancesAvailable(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('tool_policy_get_user_acceptances') && site.wsAvailable('tool_policy_set_acceptances_status'); + } + + /** + * Set user acceptances. + * + * @param policies Policies to accept or decline. Keys are policy version id, value is whether to accept or decline. + * @param siteId Site ID. If not defined, current site. + * @returns New value for policyagreed. + */ + async setUserAcceptances(policies: Record, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + const data: CorePolicySetAcceptancesWSParams = { + userid: site.getUserId(), + policies: Object.keys(policies).map((versionId) => ({ + versionid: Number(versionId), + status: policies[versionId], + })), + }; + + const response = await site.write('tool_policy_get_user_acceptances', data); + if (response.warnings?.length) { + throw new CoreWSError(response.warnings[0]); + } + + return response.policyagreed; + } + } export const CorePolicy = makeSingleton(CorePolicyService); @@ -115,7 +192,71 @@ export const CorePolicy = makeSingleton(CorePolicyService); /** * Result of WS core_user_agree_site_policy. */ -type AgreeSitePolicyResult = { +type CorePolicyAgreeSitePolicyResult = { status: boolean; // Status: true only if we set the policyagreed to 1 for the user. warnings?: CoreWSExternalWarning[]; }; + +/** + * Params of tool_policy_get_user_acceptances WS. + */ +type CorePolicyGetUserAcceptancesWSParams = { + userid?: number; // The user id we want to retrieve the acceptances. +}; + +/** + * Data returned by tool_policy_get_user_acceptances WS. + */ +type CorePolicyGetUserAcceptancesWSResponse = { + policies: CorePolicySitePolicy[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Policy data returned by tool_policy_get_user_acceptances WS. + */ +export type CorePolicySitePolicy = { + policyid: number; // The policy id. + versionid: number; // The policy version id. + agreementstyle: number; // The policy agreement style. 0: consent page, 1: own page. + optional: number; // Whether the policy is optional. 0: compulsory, 1: optional. + revision: string; // The policy revision. + status: number; // The policy status. 0: draft, 1: active, 2: archived. + name: string; // The policy name. + summary?: string; // The policy summary. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN, or 4 = MARKDOWN). + 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. +}; + +/** + * Policy acceptance data returned by tool_policy_get_user_acceptances WS. + */ +export type CorePolicySitePolicyAcceptance = { + status: number; // The acceptance status. 0: declined, 1: accepted. + lang: string; // The policy lang. + timemodified: number; // The time the acceptance was set. + usermodified: number; // The user who accepted. + note?: string; // The policy note/remarks. + modfullname?: string; // The fullname who accepted on behalf. +}; + +/** + * Params of tool_policy_set_acceptances_status WS. + */ +type CorePolicySetAcceptancesWSParams = { + policies: { + versionid: number; // The policy version id. + status: number; // The policy acceptance status. 0: decline, 1: accept. + }[]; // Policies acceptances for the given user. + userid?: number; // The user id we want to set the acceptances. Default is the current user. +}; + +/** + * Data returned by tool_policy_set_acceptances_status WS. + */ +type CorePolicySetAcceptancesWSResponse = { + policyagreed: number; // Whether the user has provided acceptance to all current site policies. 1 if yes, 0 if not. + warnings?: CoreWSExternalWarning[]; +}; From c7262f715e42335ef9f947d19d59dfac711fd000 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 13 Feb 2024 13:03:30 +0100 Subject: [PATCH 5/8] MOBILE-2768 policy: Adapt the UI of accept policy page --- scripts/langindex.json | 2 +- src/core/features/policy/lang.json | 2 +- .../policy/pages/site-policy/site-policy.html | 71 +++++++++++++------ .../policy/pages/site-policy/site-policy.scss | 39 ++++++++-- .../policy/pages/site-policy/site-policy.ts | 70 +++++++++++++----- src/theme/theme.base.scss | 12 +++- 6 files changed, 146 insertions(+), 50 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index d3cf2c75f80..f0697bec70a 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2282,7 +2282,7 @@ "core.phone": "moodle", "core.pictureof": "moodle", "core.play": "local_moodlemobileapp", - "core.policy.policyaccept": "moodle", + "core.policy.havereadandagreepolicy": "local_moodlemobileapp", "core.policy.policyacceptmandatory": "local_moodlemobileapp", "core.policy.policyagree": "moodle", "core.policy.policyagreement": "moodle", diff --git a/src/core/features/policy/lang.json b/src/core/features/policy/lang.json index 66fdf8295fe..12b54abe948 100644 --- a/src/core/features/policy/lang.json +++ b/src/core/features/policy/lang.json @@ -1,5 +1,5 @@ { - "policyaccept": "I understand and agree", + "havereadandagreepolicy": "I have read and agree to the {{policyname}}", "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", diff --git a/src/core/features/policy/pages/site-policy/site-policy.html b/src/core/features/policy/pages/site-policy/site-policy.html index b17608f3046..160a2b81b38 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.html +++ b/src/core/features/policy/pages/site-policy/site-policy.html @@ -4,35 +4,62 @@ - -

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

+ +

+ + + + + - + - +
-

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

+

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

- - -

- {{ 'core.policy.policyagreementclick' | translate }} -

-
-
- - - - - {{ 'core.policy.policyacceptmandatory' | translate }} - - - {{ 'core.cancel' | translate }} - - +
+ + + + + + + +

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

+
+
+
+ +
+ + + + {{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:'core.policy.policyagreement' | translate } }} + + + {{ 'core.policy.policyacceptmandatory' | translate }} + + + + + {{ 'core.continue' | translate }} + +
+
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 fa7bca31269..7192dca7bb7 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.scss +++ b/src/core/features/policy/pages/site-policy/site-policy.scss @@ -1,20 +1,45 @@ +@use "theme/globals" as *; + :host { - ion-list { + hr { + background: var(--black); + margin: 0 16px; + } + + form { display: flex; flex-direction: column; + flex-grow: 1; height: 100%; - ion-item { + ion-item, .core-info-card { flex-shrink: 0; } - .core-site-policy-iframe-container { - height: 100%; + ion-item { + --inner-border-width: 0; + } - core-iframe { - height: 100%; - width: 100%; + .core-site-policy-link { + p { + text-decoration: underline; + font-size: 1rem; + + ion-icon { + font-size: 0.875rem; + @include margin-horizontal(4px, 0); + } } } + + .core-site-policy-iframe-container { + margin: 8px; + display: flex; + flex-grow: 1; + } + + ion-button[type="submit"] { + margin-bottom: 12px;; + } } } diff --git a/src/core/features/policy/pages/site-policy/site-policy.ts b/src/core/features/policy/pages/site-policy/site-policy.ts index 55997cbcc98..55c057238a0 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.ts +++ b/src/core/features/policy/pages/site-policy/site-policy.ts @@ -24,6 +24,7 @@ import { CoreEvents } from '@singletons/events'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { Translate } from '@singletons'; import { CorePolicy } from '@features/policy/services/policy'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; /** * Page to accept a site policy. @@ -35,20 +36,26 @@ import { CorePolicy } from '@features/policy/services/policy'; }) export class CorePolicySitePolicyPage implements OnInit { - sitePolicy?: string; + siteName?: string; + isManageAcceptancesAvailable = false; + isPoliciesURL = false; + sitePoliciesURL?: string; showInline?: boolean; policyLoaded?: boolean; + policyForm?: FormGroup; + protected siteId?: string; protected currentSite!: CoreSite; /** * @inheritdoc */ - ngOnInit(): void { + async ngOnInit(): Promise { this.siteId = CoreNavigator.getRouteParam('siteId'); try { this.currentSite = CoreSites.getRequiredCurrentSite(); + this.siteName = (await CoreUtils.ignoreErrors(this.currentSite.getSiteName(), '')) || ''; } catch { // Not logged in, stop. this.cancel(); @@ -66,17 +73,36 @@ export class CorePolicySitePolicyPage implements OnInit { return; } - this.fetchSitePolicy(); + this.isManageAcceptancesAvailable = await CorePolicy.isManageAcceptancesAvailable(this.siteId); + this.isPoliciesURL = this.isManageAcceptancesAvailable ? + (await this.currentSite.getConfig('sitepolicyhandler')) !== 'tool_policy' : + true; // Site doesn't support managing acceptances, just display it as a URL. + + if (this.isPoliciesURL) { + this.initFormForPoliciesURL(); + + await this.fetchSitePoliciesURL(); + } else { + // TODO + } + + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM, + ws: 'auth_email_get_signup_settings', + name: Translate.instant('core.policy.policyagreement'), + data: { category: 'policy' }, + url: '/user/policy.php', + }); } /** - * Fetch the site policy URL. + * Fetch the site policies URL. * * @returns Promise resolved when done. */ - protected async fetchSitePolicy(): Promise { + protected async fetchSitePoliciesURL(): Promise { try { - this.sitePolicy = await CorePolicy.getSitePoliciesURL(this.siteId); + this.sitePoliciesURL = await CorePolicy.getSitePoliciesURL(this.siteId); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'Error getting site policy.'); this.cancel(); @@ -86,9 +112,9 @@ export class CorePolicySitePolicyPage implements OnInit { // Try to get the mime type. try { - const mimeType = await CoreUtils.getMimeTypeFromUrl(this.sitePolicy); + const mimeType = await CoreUtils.getMimeTypeFromUrl(this.sitePoliciesURL); - const extension = CoreMimetypeUtils.getExtension(mimeType, this.sitePolicy); + const extension = CoreMimetypeUtils.getExtension(mimeType, this.sitePoliciesURL); this.showInline = extension == 'html' || extension == 'htm'; } catch { // Unable to get mime type, assume it's not supported. @@ -96,13 +122,17 @@ export class CorePolicySitePolicyPage implements OnInit { } finally { this.policyLoaded = true; } + } - CoreAnalytics.logEvent({ - type: CoreAnalyticsEventType.VIEW_ITEM, - ws: 'auth_email_get_signup_settings', - name: Translate.instant('core.policy.policyagreement'), - data: { category: 'policy' }, - url: '/user/policy.php', + /** + * Init the form to accept the policies using a URL. + */ + protected initFormForPoliciesURL(): void { + this.policyForm = new FormGroup({ + agreepolicy: new FormControl(false, { + validators: Validators.requiredTrue, + nonNullable: true, + }), }); } @@ -118,11 +148,19 @@ export class CorePolicySitePolicyPage implements OnInit { } /** - * Accept the site policy. + * Submit the acceptances to one or several policies. * + * @param event Event. * @returns Promise resolved when done. */ - async accept(): Promise { + async submitAcceptances(event: Event): Promise { + event.preventDefault(); + event.stopPropagation(); + + if (!this.policyForm?.valid) { + return; + } + const modal = await CoreDomUtils.showModalLoading('core.sending', true); try { diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 25b28b99639..4ac4722f319 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -99,7 +99,9 @@ ion-item .in-item { // Correctly inherit ion-text-wrap onto labels. .item > ion-label, -.fake-ion-item { +.fake-ion-item, +.item.ion-text-wrap > ion-checkbox::part(label), +ion-checkbox.ion-text-wrap::part(label) { core-format-text, core-format-text > *:not(pre) { white-space: nowrap; @@ -110,7 +112,9 @@ ion-item .in-item { .item.ion-text-wrap > ion-label, ion-item > .in-item, -.fake-ion-item.ion-text-wrap { +.fake-ion-item.ion-text-wrap, +.item.ion-text-wrap > ion-checkbox::part(label), +ion-checkbox.ion-text-wrap::part(label) { core-format-text, core-format-text > *:not(pre) { white-space: normal; @@ -118,7 +122,9 @@ ion-item > .in-item, } } -.item.ion-text-wrap > ion-label { +.item.ion-text-wrap > ion-label, +.item.ion-text-wrap > ion-checkbox::part(label), +ion-checkbox.ion-text-wrap::part(label) { white-space: normal !important; } From 85e6446f4b086d43a5f2ddaac99a9f809e2fb8a7 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 15 Feb 2024 15:32:40 +0100 Subject: [PATCH 6/8] MOBILE-2768 policy: Support accepting all types of policies --- scripts/langindex.json | 7 + src/core/classes/sites/authenticated-site.ts | 2 +- .../input-errors/core-input-errors.html | 5 +- src/core/features/login/login.module.ts | 4 - .../policy/components/components.module.ts | 31 ++ .../components/policy-modal/policy-modal.html | 24 ++ .../components/policy-modal/policy-modal.ts | 37 ++ src/core/features/policy/lang.json | 9 +- .../policy/pages/site-policy/site-policy.html | 113 +++++- .../policy/pages/site-policy/site-policy.scss | 40 +- .../policy/pages/site-policy/site-policy.ts | 354 ++++++++++++++++-- .../features/policy/policy-lazy.module.ts | 2 + src/core/features/policy/policy.module.ts | 15 +- src/core/features/policy/services/policy.ts | 42 ++- 14 files changed, 626 insertions(+), 59 deletions(-) create mode 100644 src/core/features/policy/components/components.module.ts create mode 100644 src/core/features/policy/components/policy-modal/policy-modal.html create mode 100644 src/core/features/policy/components/policy-modal/policy-modal.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index f0697bec70a..c43f2fe20dc 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -2282,12 +2282,19 @@ "core.phone": "moodle", "core.pictureof": "moodle", "core.play": "local_moodlemobileapp", + "core.policy.agreepolicies": "tool_policy", + "core.policy.backtotop": "tool_policy", + "core.policy.consentpagetitle": "tool_policy", "core.policy.havereadandagreepolicy": "local_moodlemobileapp", + "core.policy.idontagree": "tool_policy", + "core.policy.mustagreetocontinue": "tool_policy", "core.policy.policyacceptmandatory": "local_moodlemobileapp", "core.policy.policyagree": "moodle", "core.policy.policyagreement": "moodle", "core.policy.policyagreementclick": "moodle", + "core.policy.refertofullpolicytext": "tool_policy", "core.policy.sitepolicynotagreederror": "local_moodlemobileapp", + "core.policy.steppolicies": "tool_policy", "core.previous": "moodle", "core.proceed": "moodle", "core.publicprofile": "moodle", diff --git a/src/core/classes/sites/authenticated-site.ts b/src/core/classes/sites/authenticated-site.ts index 7db3ad6a969..7769d479f6c 100644 --- a/src/core/classes/sites/authenticated-site.ts +++ b/src/core/classes/sites/authenticated-site.ts @@ -671,7 +671,7 @@ export class CoreAuthenticatedSite extends CoreUnauthenticatedSite { this.triggerSiteEvent(CoreEvents.SITE_POLICY_NOT_AGREED, {}); error.message = Translate.instant('core.policy.sitepolicynotagreederror'); - throw new CoreWSError(error); + throw new CoreSilentError(error); } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.hasUnicodeData(data)) { if (!this.cleanUnicode) { // Try again cleaning unicode. diff --git a/src/core/components/input-errors/core-input-errors.html b/src/core/components/input-errors/core-input-errors.html index 89de8500094..2f7da442d91 100644 --- a/src/core/components/input-errors/core-input-errors.html +++ b/src/core/components/input-errors/core-input-errors.html @@ -2,7 +2,10 @@
- {{ errorMessages[error] | translate }} + + {{ 'core.login.invalidvaluemax' | translate:{$a: control.errors!.max.max} }} diff --git a/src/core/features/login/login.module.ts b/src/core/features/login/login.module.ts index 4a1e6244d76..7d15b21759e 100644 --- a/src/core/features/login/login.module.ts +++ b/src/core/features/login/login.module.ts @@ -62,10 +62,6 @@ const appRoutes: Routes = [ CoreLoginHelper.passwordChangeForced(data.siteId); }); - CoreEvents.on(CoreEvents.SITE_POLICY_NOT_AGREED, (data) => { - CoreLoginHelper.sitePolicyNotAgreed(data.siteId); - }); - await CoreLoginHelper.initialize(); }, }, diff --git a/src/core/features/policy/components/components.module.ts b/src/core/features/policy/components/components.module.ts new file mode 100644 index 00000000000..21c3b118b45 --- /dev/null +++ b/src/core/features/policy/components/components.module.ts @@ -0,0 +1,31 @@ +// (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 { CorePolicyViewPolicyModalComponent } from './policy-modal/policy-modal'; + +@NgModule({ + declarations: [ + CorePolicyViewPolicyModalComponent, + ], + imports: [ + CoreSharedModule, + ], + exports: [ + CorePolicyViewPolicyModalComponent, + ], +}) +export class CorePolicyComponentsModule {} diff --git a/src/core/features/policy/components/policy-modal/policy-modal.html b/src/core/features/policy/components/policy-modal/policy-modal.html new file mode 100644 index 00000000000..b555e7f34a4 --- /dev/null +++ b/src/core/features/policy/components/policy-modal/policy-modal.html @@ -0,0 +1,24 @@ + + + +

{{ policy.name }}

+
+ + + + +
+
+ + + + + + + + + + + + diff --git a/src/core/features/policy/components/policy-modal/policy-modal.ts b/src/core/features/policy/components/policy-modal/policy-modal.ts new file mode 100644 index 00000000000..d9f9a6da2ae --- /dev/null +++ b/src/core/features/policy/components/policy-modal/policy-modal.ts @@ -0,0 +1,37 @@ +// (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 } from '@angular/core'; +import { CorePolicySitePolicy } from '@features/policy/services/policy'; +import { ModalController } from '@singletons'; + +/** + * Modal to view a policy. + */ +@Component({ + selector: 'core-policy-view-policy-modal', + templateUrl: 'policy-modal.html', +}) +export class CorePolicyViewPolicyModalComponent { + + @Input() policy?: CorePolicySitePolicy; + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + +} diff --git a/src/core/features/policy/lang.json b/src/core/features/policy/lang.json index 12b54abe948..fd53e1f171b 100644 --- a/src/core/features/policy/lang.json +++ b/src/core/features/policy/lang.json @@ -1,8 +1,15 @@ { + "agreepolicies": "Please agree to the following policies", + "backtotop": "Back to top", + "consentpagetitle": "Consent", "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.", "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", - "sitepolicynotagreederror": "Site policy not agreed." + "refertofullpolicytext": "Please refer to the full {{$a}} if you would like to review the text.", + "sitepolicynotagreederror": "Site policy not agreed.", + "steppolicies": "Policy {{$a.numpolicy}} out of {{$a.totalpolicies}}" } diff --git a/src/core/features/policy/pages/site-policy/site-policy.html b/src/core/features/policy/pages/site-policy/site-policy.html index 160a2b81b38..8054a867e45 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.html +++ b/src/core/features/policy/pages/site-policy/site-policy.html @@ -17,13 +17,22 @@

-
- + + -

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

+

{{ 'core.policy.steppolicies' | translate:{ $a:stepData } }}

+
+
+ + + +

{{ title }}

+

{{ subTitle }}


+ + @@ -48,18 +57,104 @@

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

- +

{{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:'core.policy.policyagreement' | translate } }} - - +

+

{{ 'core.policy.policyacceptmandatory' | translate }} - +

- +
+ + + + + + + + + + + + + + + + + + + + +

{{ policy.name }}

+
+ +
+
+
+ + +

+
+
+ + +
+ + + +

{{ 'core.policy.steppolicies' | translate:{ $a:stepData } }}

+
+ + +
+ + + + + + +
+ {{ 'core.continue' | translate }} - + + {{ 'core.next' | translate }} + +
+ + + + +

+ {{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:policy.name } }} +

+
+ +
+ + + +

{{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:policy.name } }}

+
+
+ + +

{{ 'core.policy.idontagree' | translate:{ $a:policy.name } }}

+
+ +
+
+
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 7192dca7bb7..9d5a17fa8ea 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.scss +++ b/src/core/features/policy/pages/site-policy/site-policy.scss @@ -6,6 +6,10 @@ margin: 0 16px; } + h2 { + font-size: 1.25rem; + } + form { display: flex; flex-direction: column; @@ -20,6 +24,17 @@ --inner-border-width: 0; } + .core-site-policy-top-bar, .core-site-policy-bottom-bar { + font-size: 0.875rem; + } + + .core-site-policy-top-bar ion-label { + margin-bottom: 0; + } + .core-site-policy-bottom-bar ion-label { + margin-top: 0; + } + .core-site-policy-link { p { text-decoration: underline; @@ -38,8 +53,29 @@ flex-grow: 1; } - ion-button[type="submit"] { - margin-bottom: 12px;; + .core-site-policy-content ion-label { + margin-top: 0; + } + + .core-site-policy-go-top-button { + --border-radius: 50%; + --box-shadow: 0 3px 5px -1px rgba(0, 0, 0, .2), 0 6px 10px 0 rgba(0, 0, 0, .14), 0 1px 18px 0 rgba(0, 0, 0, .12); + margin-bottom: 8px; + } + + .core-site-policy-buttons { + text-align: center; + + ion-button { + margin-left: 0; + margin-right: 0; + margin-bottom: 12px; + } + } + + .item ::ng-deep ion-label p span { + color: var(--core-link-color); } + } } diff --git a/src/core/features/policy/pages/site-policy/site-policy.ts b/src/core/features/policy/pages/site-policy/site-policy.ts index 55c057238a0..afe259d6828 100644 --- a/src/core/features/policy/pages/site-policy/site-policy.ts +++ b/src/core/features/policy/pages/site-policy/site-policy.ts @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { CoreSites } from '@services/sites'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreMimetypeUtils } from '@services/utils/mimetype'; @@ -23,8 +23,14 @@ import { CoreNavigator } from '@services/navigator'; import { CoreEvents } from '@singletons/events'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { Translate } from '@singletons'; -import { CorePolicy } from '@features/policy/services/policy'; +import { CorePolicy, CorePolicyAgreementStyle, CorePolicySitePolicy } from '@features/policy/services/policy'; import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { CoreUrlUtils } from '@services/utils/url'; +import { IonContent } from '@ionic/angular'; +import { CoreScreen } from '@services/screen'; +import { Subscription } from 'rxjs'; +import { CoreDom } from '@singletons/dom'; +import { CorePolicyViewPolicyModalComponent } from '@features/policy/components/policy-modal/policy-modal'; /** * Page to accept a site policy. @@ -34,18 +40,38 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; templateUrl: 'site-policy.html', styleUrls: ['site-policy.scss'], }) -export class CorePolicySitePolicyPage implements OnInit { +export class CorePolicySitePolicyPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; siteName?: string; isManageAcceptancesAvailable = false; + policyLoaded?: boolean; + policiesForm?: FormGroup; isPoliciesURL = false; + title = ''; + subTitle?: string; + hasScroll = false; + isTablet = false; + + // Variables for accepting policies using a URL. sitePoliciesURL?: string; showInline?: boolean; - policyLoaded?: boolean; - policyForm?: FormGroup; + + // Variables for accepting policies one by one. + currentPolicy?: SitePolicy; + pendingPolicies?: SitePolicy[]; + agreeInOwnPage = false; + numPolicy = 1; + showConsentForm = false; + stepData?: {numpolicy: number; totalpolicies: number}; + policiesErrors = { required: Translate.instant('core.policy.mustagreetocontinue') }; protected siteId?: string; protected currentSite!: CoreSite; + protected layoutSubscription?: Subscription; + + constructor(protected elementRef: ElementRef, protected changeDetector: ChangeDetectorRef) {} /** * @inheritdoc @@ -73,26 +99,23 @@ export class CorePolicySitePolicyPage implements OnInit { return; } + this.isTablet = CoreScreen.isTablet; + this.layoutSubscription = CoreScreen.layoutObservable.subscribe(() => { + this.isTablet = CoreScreen.isTablet; + }); + this.isManageAcceptancesAvailable = await CorePolicy.isManageAcceptancesAvailable(this.siteId); this.isPoliciesURL = this.isManageAcceptancesAvailable ? (await this.currentSite.getConfig('sitepolicyhandler')) !== 'tool_policy' : true; // Site doesn't support managing acceptances, just display it as a URL. if (this.isPoliciesURL) { - this.initFormForPoliciesURL(); - await this.fetchSitePoliciesURL(); + + this.initFormForPoliciesURL(); } else { - // TODO + await this.fetchNextPoliciesToAccept(); } - - CoreAnalytics.logEvent({ - type: CoreAnalyticsEventType.VIEW_ITEM, - ws: 'auth_email_get_signup_settings', - name: Translate.instant('core.policy.policyagreement'), - data: { category: 'policy' }, - url: '/user/policy.php', - }); } /** @@ -101,6 +124,9 @@ export class CorePolicySitePolicyPage implements OnInit { * @returns Promise resolved when done. */ protected async fetchSitePoliciesURL(): Promise { + this.title = Translate.instant('core.policy.policyagreement'); + this.subTitle = undefined; + try { this.sitePoliciesURL = await CorePolicy.getSitePoliciesURL(this.siteId); } catch (error) { @@ -122,13 +148,103 @@ export class CorePolicySitePolicyPage implements OnInit { } finally { this.policyLoaded = true; } + + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM, + ws: 'auth_email_get_signup_settings', + name: Translate.instant('core.policy.policyagreement'), + data: { category: 'policy' }, + url: '/user/policy.php', + }); + } + + /** + * Fetch the next site policies to accept. + * + * @returns Promise resolved when done. + */ + protected async fetchNextPoliciesToAccept(): Promise { + try { + this.scrollTop(); + + const pendingPolicies = await CorePolicy.getNextPendingPolicies({ + readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK, + siteId: this.siteId, + }); + + // Add some calculated data. + this.pendingPolicies = pendingPolicies.map((policy: SitePolicy) => { + policy.referToFullPolicyText = Translate.instant('core.policy.refertofullpolicytext', { + $a: `${policy.name}`, + }); + + return policy; + }); + + const policy = this.pendingPolicies[0]; + if (!policy) { + // No more policies to accept. + await this.finishAcceptingPolicies(); + + return; + } + + this.initFormForPendingPolicies(); + + this.agreeInOwnPage = policy.agreementstyle === CorePolicyAgreementStyle.OwnPage; + this.showConsentForm = false; + this.numPolicy = 1; + this.setCurrentPolicy(policy); + this.policyLoaded = true; + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error getting site policy.'); + this.cancel(); + } + } + + /** + * Log in analytics viewing a certain policy. + */ + protected logAnalyticsPolicyView(): void { + if (!this.currentPolicy) { + return; + } + + const analyticsParams: Record = { + versionid: this.currentPolicy.versionid, + }; + if (!this.agreeInOwnPage) { + analyticsParams.numpolicy = this.numPolicy; + analyticsParams.totalpolicies = this.pendingPolicies?.length ?? this.numPolicy; + } + + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM, + ws: 'tool_policy_get_user_acceptances', + name: this.currentPolicy.name, + data: analyticsParams, + url: CoreUrlUtils.addParamsToUrl('/admin/tool/policy/view.php', analyticsParams), + }); + } + + /** + * Log in analytics viewing the consent form. + */ + protected logAnalyticsConsentFormView(): void { + CoreAnalytics.logEvent({ + type: CoreAnalyticsEventType.VIEW_ITEM, + ws: 'tool_policy_get_user_acceptances', + name: Translate.instant('core.policy.consentpagetitle'), + data: {}, + url: CoreUrlUtils.addParamsToUrl('/admin/tool/policy/index.php'), + }); } /** * Init the form to accept the policies using a URL. */ protected initFormForPoliciesURL(): void { - this.policyForm = new FormGroup({ + this.policiesForm = new FormGroup({ agreepolicy: new FormControl(false, { validators: Validators.requiredTrue, nonNullable: true, @@ -136,6 +252,26 @@ export class CorePolicySitePolicyPage implements OnInit { }); } + /** + * Init the form to accept the current pending policies. + */ + protected initFormForPendingPolicies(): void { + this.policiesForm = new FormGroup({}); + + this.pendingPolicies?.forEach(policy => { + if (policy.optional) { + this.policiesForm?.addControl('agreepolicy' + policy.versionid, new FormControl(undefined, { + validators: Validators.required, + })); + } else { + this.policiesForm?.addControl('agreepolicy' + policy.versionid, new FormControl(false, { + validators: Validators.requiredTrue, + nonNullable: true, + })); + } + }); + } + /** * Cancel. * @@ -147,6 +283,68 @@ export class CorePolicySitePolicyPage implements OnInit { await CoreNavigator.navigate('/login/sites', { reset: true }); } + /** + * Load next policy. + * + * @param event Event. + */ + nextPolicy(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.pendingPolicies) { + return; + } + + this.scrollTop(); + + if (this.numPolicy < this.pendingPolicies.length) { + this.numPolicy++; + this.setCurrentPolicy(this.pendingPolicies[this.numPolicy - 1]); + + return; + } + + // All policies seen, display the consent form. + this.currentPolicy = undefined; + this.stepData = undefined; + this.showConsentForm = true; + this.title = Translate.instant('core.policy.consentpagetitle'); + this.subTitle = Translate.instant('core.policy.agreepolicies'); + + this.logAnalyticsConsentFormView(); + } + + /** + * Set current policy. + */ + protected setCurrentPolicy(policy?: CorePolicySitePolicy): void { + if (!policy) { + return; + } + + this.hasScroll = false; + this.currentPolicy = policy; + this.title = policy.name || ''; + this.subTitle = undefined; + this.stepData = !this.agreeInOwnPage ? + { numpolicy: this.numPolicy, totalpolicies: this.pendingPolicies?.length ?? this.numPolicy } : + undefined; + + this.logAnalyticsPolicyView(); + } + + /** + * Check if the content has scroll. + */ + protected async checkScroll(): Promise { + await CoreUtils.wait(400); + + const scrollElement = await this.content?.getScrollElement(); + + this.hasScroll = !!scrollElement && scrollElement.scrollHeight > scrollElement.clientHeight; + } + /** * Submit the acceptances to one or several policies. * @@ -157,27 +355,131 @@ export class CorePolicySitePolicyPage implements OnInit { event.preventDefault(); event.stopPropagation(); - if (!this.policyForm?.valid) { + if (!this.policiesForm?.valid) { + for (const name in this.policiesForm?.controls) { + this.policiesForm.controls[name].markAsDirty(); + } + this.changeDetector.detectChanges(); + + // Scroll to the first element with errors. + const errorFound = await CoreDom.scrollToInputError( + this.elementRef.nativeElement, + ); + + if (!errorFound) { + // Input not found, show an error modal. + CoreDomUtils.showErrorModal('core.policy.mustagreetocontinue', true); + } + return; } const modal = await CoreDomUtils.showModalLoading('core.sending', true); try { - await CorePolicy.acceptMandatorySitePolicies(this.siteId); + if (!this.isPoliciesURL) { + await this.acceptPendingPolicies(); - // Success accepting, go to site initial page. - // Invalidate cache since some WS don't return error if site policy is not accepted. - await CoreUtils.ignoreErrors(this.currentSite.invalidateWsCache()); + return; + } - CoreEvents.trigger(CoreEvents.SITE_POLICY_AGREED, {}, this.siteId); + await CorePolicy.acceptMandatorySitePolicies(this.siteId); - await CoreNavigator.navigateToSiteHome(); + await this.finishAcceptingPolicies(); } catch (error) { - CoreDomUtils.showErrorModalDefault(error, 'Error accepting site policy.'); + CoreDomUtils.showErrorModalDefault(error, 'Error accepting site policies.'); } finally { modal.dismiss(); } } + /** + * Accept current pending policies. + */ + protected async acceptPendingPolicies(): Promise { + if (!this.pendingPolicies) { + return; + } + + const acceptances: Record = {}; + + this.pendingPolicies?.forEach(policy => { + const control = this.policiesForm?.controls['agreepolicy' + policy.versionid]; + if (!control) { + return; + } + + if (policy.optional) { + if (control.value === null || control.value === undefined) { + // Not answered, this code shouldn't be reached. Display error. + CoreDomUtils.showErrorModal('core.policy.mustagreetocontinue', true); + + return; + } + + acceptances[policy.versionid] = control.value; + } else { + if (!control.value) { + // Not answered, this code shouldn't be reached. Display error. + CoreDomUtils.showErrorModal('core.policy.mustagreetocontinue', true); + + return; + } + + acceptances[policy.versionid] = 1; + } + }); + + await CorePolicy.setUserAcceptances(acceptances, this.siteId); + + await this.fetchNextPoliciesToAccept(); + } + + /** + * All mandatory policies have been accepted, go to site initial page. + */ + protected async finishAcceptingPolicies(): Promise { + // Invalidate cache since some WS don't return error if site policy is not accepted. + await CoreUtils.ignoreErrors(this.currentSite.invalidateWsCache()); + + CoreEvents.trigger(CoreEvents.SITE_POLICY_AGREED, {}, this.siteId); + + await CoreNavigator.navigateToSiteHome(); + } + + /** + * Scroll to top. + * + * @param event Event. + */ + scrollTop(event?: Event): void { + event?.preventDefault(); + event?.stopPropagation(); + + this.content?.scrollToTop(400); + } + + /** + * View the full policy. + * + * @param policy Policy. + */ + viewFullPolicy(policy: CorePolicySitePolicy): void { + CoreDomUtils.openModal({ + component: CorePolicyViewPolicyModalComponent, + componentProps: { policy }, + }); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.layoutSubscription?.unsubscribe(); + } + } + +type SitePolicy = CorePolicySitePolicy & { + referToFullPolicyText?: string; +}; diff --git a/src/core/features/policy/policy-lazy.module.ts b/src/core/features/policy/policy-lazy.module.ts index fd9bca426e2..eb49beae230 100644 --- a/src/core/features/policy/policy-lazy.module.ts +++ b/src/core/features/policy/policy-lazy.module.ts @@ -18,6 +18,7 @@ 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 { CorePolicyComponentsModule } from './components/components.module'; const routes: Routes = [ { @@ -30,6 +31,7 @@ const routes: Routes = [ imports: [ CoreSharedModule, RouterModule.forChild(routes), + CorePolicyComponentsModule, ], declarations: [ CorePolicySitePolicyPage, diff --git a/src/core/features/policy/policy.module.ts b/src/core/features/policy/policy.module.ts index b8c79184df7..54ace343db6 100644 --- a/src/core/features/policy/policy.module.ts +++ b/src/core/features/policy/policy.module.ts @@ -12,26 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { AppRoutingModule } from '@/app/app-routing.module'; import { CoreEvents } from '@singletons/events'; import { POLICY_PAGE_NAME } from './constants'; -/** - * Get policy services. - * - * @returns Policy services. - */ -export async function getPolicyServices(): Promise[]> { - const { CorePolicyService } = await import('@features/policy/services/policy'); - - return [ - CorePolicyService, - ]; -} - const routes: Routes = [ { path: POLICY_PAGE_NAME, diff --git a/src/core/features/policy/services/policy.ts b/src/core/features/policy/services/policy.ts index d67b71aca0e..c5caa910edc 100644 --- a/src/core/features/policy/services/policy.ts +++ b/src/core/features/policy/services/policy.ts @@ -89,6 +89,38 @@ export class CorePolicyService { return sitePolicy; } + /** + * Get the next policies to accept. + * + * @param options Options + * @returns Next pending policies + */ + async getNextPendingPolicies(options: CoreSitesCommonWSOptions = {}): Promise { + const policies = await this.getUserAcceptances(options); + + const pendingPolicies: CorePolicySitePolicy[] = []; + + for (const i in policies) { + const policy = policies[i]; + const hasAccepted = policy.acceptance?.status === 1; + const hasDeclined = policy.acceptance?.status === 0; + + if (hasAccepted || (hasDeclined && policy.optional === 1)) { + // Policy already answered, ignore. + continue; + } + + if (policy.agreementstyle === CorePolicyAgreementStyle.OwnPage) { + // Policy needs to be accepted on its own page, it's the next policy to accept. + return [policy]; + } + + pendingPolicies.push(policy); + } + + return pendingPolicies; + } + /** * Get user acceptances. * @@ -177,7 +209,7 @@ export class CorePolicyService { })), }; - const response = await site.write('tool_policy_get_user_acceptances', data); + const response = await site.write('tool_policy_set_acceptances_status', data); if (response.warnings?.length) { throw new CoreWSError(response.warnings[0]); } @@ -260,3 +292,11 @@ type CorePolicySetAcceptancesWSResponse = { policyagreed: number; // Whether the user has provided acceptance to all current site policies. 1 if yes, 0 if not. warnings?: CoreWSExternalWarning[]; }; + +/** + * Agreement style. + */ +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. +} From b33b8b07fc5ab7f2ee1f439c115f17e3d3bc05d9 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 22 Feb 2024 09:37:06 +0100 Subject: [PATCH 7/8] MOBILE-2768 policy: Support viewing agreements --- .github/workflows/acceptance.yml | 1 + scripts/langindex.json | 24 ++ .../font-awesome/arrow-turn-down-right.svg | 3 + src/core/features/policy/constants.ts | 1 + src/core/features/policy/lang.json | 25 +- .../policy/pages/acceptances/acceptances.html | 309 ++++++++++++++++++ .../policy/pages/acceptances/acceptances.scss | 129 ++++++++ .../policy/pages/acceptances/acceptances.ts | 284 ++++++++++++++++ .../policy/pages/site-policy/site-policy.scss | 2 +- .../features/policy/policy-lazy.module.ts | 8 +- src/core/features/policy/policy.module.ts | 9 + .../services/handlers/acceptances-link.ts | 63 ++++ .../features/policy/services/handlers/user.ts | 85 +++++ src/core/features/policy/services/policy.ts | 38 ++- .../policy/tests/behat/consent.feature | 179 ++++++++++ .../policy/tests/behat/contactdpo.feature | 28 ++ src/core/lang.json | 1 + src/theme/theme.base.scss | 7 +- 18 files changed, 1187 insertions(+), 9 deletions(-) create mode 100644 src/assets/fonts/moodle/font-awesome/arrow-turn-down-right.svg create mode 100644 src/core/features/policy/pages/acceptances/acceptances.html create mode 100644 src/core/features/policy/pages/acceptances/acceptances.scss create mode 100644 src/core/features/policy/pages/acceptances/acceptances.ts create mode 100644 src/core/features/policy/services/handlers/acceptances-link.ts create mode 100644 src/core/features/policy/services/handlers/user.ts create mode 100755 src/core/features/policy/tests/behat/consent.feature create mode 100755 src/core/features/policy/tests/behat/contactdpo.feature diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index a2d2e1330b3..e0fcb95ef4a 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 c43f2fe20dc..5619f03fd00 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 00000000000..b092fa12fa6 --- /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 c341a326ada..88e436d5a44 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 fd53e1f171b..8e7cba17b03 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 00000000000..d9a123a6249 --- /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 00000000000..eca3e66bdbd --- /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 00000000000..0212671eca6 --- /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 9d5a17fa8ea..b19040ec686 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 eb49beae230..66011756351 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 54ace343db6..407713570a1 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 00000000000..047de66106e --- /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 00000000000..730f8401a95 --- /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 c5caa910edc..82caaafebb1 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 00000000000..b95dc091aff --- /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 00000000000..54e42641304 --- /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 5138ebfac38..7180aa28404 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 4ac4722f319..f23baa8c45c 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); From 62a04239cb9eb0dead1844d9c038f12ce778436b Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 4 Mar 2024 10:24:52 +0100 Subject: [PATCH 8/8] MOBILE-2768 policy: Decouple policy service from initial bundle --- src/core/features/login/services/login-helper.ts | 9 +++++++-- .../policy/services/handlers/acceptances-link.ts | 3 ++- src/core/features/policy/services/handlers/user.ts | 3 ++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index a2aded067ed..98fb1c72cee 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -57,7 +57,6 @@ import { IDENTITY_PROVIDER_FEATURE_NAME_PREFIX, } from '../constants'; import { LazyRoutesModule } from '@/app/app-routing.module'; -import { CorePolicy } from '@features/policy/services/policy'; /** * Helper provider that provides some common features regarding authentication. @@ -91,6 +90,8 @@ export class CoreLoginHelperProvider { * @deprecated since 4.4. Use CorePolicy.acceptMandatoryPolicies instead. */ async acceptSitePolicy(siteId?: string): Promise { + const { CorePolicy } = await import('@features/policy/services/policy'); + return CorePolicy.acceptMandatorySitePolicies(siteId); } @@ -285,6 +286,8 @@ export class CoreLoginHelperProvider { * @deprecated since 4.4. Use CorePolicy.getSitePoliciesURL instead. */ async getSitePolicy(siteId?: string): Promise { + const { CorePolicy } = await import('@features/policy/services/policy'); + return CorePolicy.getSitePoliciesURL(siteId); } @@ -1040,7 +1043,9 @@ export class CoreLoginHelperProvider { * @returns void * @deprecated since 4.4. Use CorePolicy.goToAcceptSitePolicies instead. */ - sitePolicyNotAgreed(siteId?: string): void { + async sitePolicyNotAgreed(siteId?: string): Promise { + const { CorePolicy } = await import('@features/policy/services/policy'); + return CorePolicy.goToAcceptSitePolicies(siteId); } diff --git a/src/core/features/policy/services/handlers/acceptances-link.ts b/src/core/features/policy/services/handlers/acceptances-link.ts index 047de66106e..69485e126c5 100644 --- a/src/core/features/policy/services/handlers/acceptances-link.ts +++ b/src/core/features/policy/services/handlers/acceptances-link.ts @@ -18,7 +18,6 @@ import { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base 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'; @@ -55,6 +54,8 @@ export class CorePolicyAcceptancesLinkHandlerService extends CoreContentLinksHan return false; } + const { CorePolicy } = await import('@features/policy/services/policy'); + return CorePolicy.isManageAcceptancesAvailable(siteId); } diff --git a/src/core/features/policy/services/handlers/user.ts b/src/core/features/policy/services/handlers/user.ts index 730f8401a95..89159a9f4e1 100644 --- a/src/core/features/policy/services/handlers/user.ts +++ b/src/core/features/policy/services/handlers/user.ts @@ -19,7 +19,6 @@ import { 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'; @@ -40,6 +39,8 @@ export class CorePolicyUserHandlerService implements CoreUserProfileHandler { * @inheritdoc */ async isEnabled(): Promise { + const { CorePolicy } = await import('@features/policy/services/policy'); + const wsAvailable = await CorePolicy.isManageAcceptancesAvailable(); if (!wsAvailable) { return false;