diff --git a/scripts/langindex.json b/scripts/langindex.json
index f0697bec7..c43f2fe20 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 7db3ad6a9..7769d479f 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 89de85000..2f7da442d 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 @@
-
+
{{ '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 7192dca7b..9d5a17fa8 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 55c057238..afe259d68 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();
- } 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',
- });
+ this.initFormForPoliciesURL();
+ } else {
+ await this.fetchNextPoliciesToAccept();
+ }
}
/**
@@ -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 {
+ if (!this.isPoliciesURL) {
+ await this.acceptPendingPolicies();
+
+ return;
+ }
+
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.
- await CoreUtils.ignoreErrors(this.currentSite.invalidateWsCache());
-
- CoreEvents.trigger(CoreEvents.SITE_POLICY_AGREED, {}, 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 fd9bca426..eb49beae2 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 b8c79184d..54ace343d 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 d67b71aca..c5caa910e 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.
+}