Merge pull request #3981 from dpalou/MOBILE-2768

Mobile 2768
main
Pau Ferrer Ocaña 2024-03-18 13:22:01 +01:00 committed by GitHub
commit d41fab4b89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2486 additions and 310 deletions

View File

@ -94,6 +94,7 @@ jobs:
"@core_grades"
"@core_login"
"@core_mainmenu"
"@core_policy"
"@core_reminders"
"@core_reportbuilder"
"@core_search"

View File

@ -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",
@ -2155,11 +2156,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 +2179,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 +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",

View File

@ -0,0 +1,3 @@
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5459 4.25281C4.5459 3.83859 4.21011 3.50281 3.7959 3.50281C3.38168 3.50281 3.0459 3.83859 3.0459 4.25281C3.0459 4.57259 3.04612 5.06957 3.04641 5.7082C3.04674 6.42035 3.04714 7.30873 3.0474 8.32386C3.04779 9.8405 4.2759 11.0715 5.79313 11.074L14.3978 11.0882L12.3432 13.1431C12.0503 13.436 12.0503 13.9109 12.3432 14.2038C12.6361 14.4966 13.111 14.4966 13.4039 14.2037L16.7351 10.8721C17.0279 10.5792 17.0279 10.1043 16.735 9.81143L13.4034 6.47991C13.1105 6.18703 12.6356 6.18704 12.3427 6.47993C12.0498 6.77283 12.0498 7.24771 12.3427 7.54059L14.3904 9.58821L5.7956 9.57403C5.10627 9.57289 4.54758 9.0134 4.5474 8.32348C4.54714 7.30775 4.54674 6.41971 4.54641 5.70791C4.54612 5.06975 4.5459 4.57254 4.5459 4.25281Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

View File

@ -669,9 +669,9 @@ 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);
throw new CoreSilentError(error);
} else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.hasUnicodeData(data)) {
if (!this.cleanUnicode) {
// Try again cleaning unicode.

View File

@ -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.
};
/**

View File

@ -2,7 +2,10 @@
<ng-container *ngFor="let error of errorKeys">
<div *ngIf="control.hasError(error)" class="core-input-error">
<ng-container *ngIf="error !== 'pattern'">
<span *ngIf="errorMessages && errorMessages[error]">{{ errorMessages[error] | translate }}</span>
<span *ngIf="errorMessages && errorMessages[error]">
<ion-icon *ngIf="error === 'required'" name="fas-circle-exclamation" aria-hidden="true" />
{{ errorMessages[error] | translate }}
</span>
<span *ngIf="(!errorMessages || !errorMessages[error]) && error === 'max' && control.errors?.max">
{{ 'core.login.invalidvaluemax' | translate:{$a: control.errors!.max.max} }}
</span>

View File

@ -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,

View File

@ -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 <i>https://campus.example.edu</i>",
"startsignup": "Create new account",

View File

@ -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,

View File

@ -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();
},
},

View File

@ -183,19 +183,19 @@
<ng-container *ngIf="settings.sitepolicy">
<ion-item-divider class="ion-text-wrap">
<ion-label>
<h2>{{ 'core.login.policyagreement' | translate }}</h2>
<h2>{{ 'core.policy.policyagreement' | translate }}</h2>
</ion-label>
</ion-item-divider>
<ion-item class="ion-text-wrap">
<ion-label>
<a [href]="settings.sitepolicy" core-link capture="false">
{{ 'core.login.policyagreementclick' | translate }}
{{ 'core.policy.policyagreementclick' | translate }}
</a>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-checkbox labelPlacement="start" justify="space-between" name="policyagreed" formControlName="policyagreed">
<p [core-mark-required]="true">{{ 'core.login.policyacceptmandatory' | translate }}</p>
<p [core-mark-required]="true">{{ 'core.policy.policyacceptmandatory' | translate }}</p>
</ion-checkbox>
<core-input-errors [control]="signupForm.controls.policyagreed" [errorMessages]="policyErrors" />
</ion-item>

View File

@ -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<void> {
this.settings = await CoreWS.callAjax<AuthEmailSignupSettings>(
'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;

View File

@ -1,38 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'core.login.policyagreement' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<core-loading [hideUntil]="policyLoaded">
<ion-list *ngIf="sitePolicy">
<ion-item class="ion-text-wrap">
<ion-label>
<p>{{ 'core.login.policyagree' | translate }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<p>
<a [href]="sitePolicy" core-link [capture]="false">{{ 'core.login.policyagreementclick' | translate }}</a>
</p>
</ion-label>
</ion-item>
<ion-card *ngIf="showInline" class="core-site-policy-iframe-container">
<core-iframe [src]="sitePolicy" />
</ion-card>
<ion-button class="ion-text-wrap ion-margin-horizontal" expand="block" (click)="accept()">
{{ 'core.login.policyacceptmandatory' | translate }}
</ion-button>
<ion-button class="ion-text-wrap ion-margin-horizontal ion-margin-bottom" expand="block" fill="outline" (click)="cancel()">
{{ 'core.login.cancel' | translate }}
</ion-button>
</ion-list>
</core-loading>
</ion-content>

View File

@ -1,20 +0,0 @@
:host {
ion-list {
display: flex;
flex-direction: column;
height: 100%;
ion-item {
flex-shrink: 0;
}
.core-site-policy-iframe-container {
height: 100%;
core-iframe {
height: 100%;
width: 100%;
}
}
}
}

View File

@ -1,145 +0,0 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Component, OnInit } from '@angular/core';
import { 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';
/**
* Page to accept a site policy.
*/
@Component({
selector: 'page-core-login-site-policy',
templateUrl: 'site-policy.html',
styleUrls: ['site-policy.scss'],
})
export class CoreLoginSitePolicyPage implements OnInit {
sitePolicy?: string;
showInline?: boolean;
policyLoaded?: boolean;
protected siteId?: string;
protected currentSite!: CoreSite;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.siteId = CoreNavigator.getRouteParam('siteId');
try {
this.currentSite = CoreSites.getRequiredCurrentSite();
} catch {
// Not logged in, stop.
this.cancel();
return;
}
const currentSiteId = this.currentSite.id;
this.siteId = this.siteId || currentSiteId;
if (this.siteId != currentSiteId) {
// Not current site, stop.
this.cancel();
return;
}
this.fetchSitePolicy();
}
/**
* Fetch the site policy URL.
*
* @returns Promise resolved when done.
*/
protected async fetchSitePolicy(): Promise<void> {
try {
this.sitePolicy = await CoreLoginHelper.getSitePolicy(this.siteId);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error getting site policy.');
this.cancel();
return;
}
// Try to get the mime type.
try {
const mimeType = await CoreUtils.getMimeTypeFromUrl(this.sitePolicy);
const extension = CoreMimetypeUtils.getExtension(mimeType, this.sitePolicy);
this.showInline = extension == 'html' || extension == 'htm';
} catch {
// Unable to get mime type, assume it's not supported.
this.showInline = false;
} finally {
this.policyLoaded = true;
}
CoreAnalytics.logEvent({
type: CoreAnalyticsEventType.VIEW_ITEM,
ws: 'auth_email_get_signup_settings',
name: Translate.instant('core.login.policyagreement'),
data: { category: 'policy' },
url: '/user/policy.php',
});
}
/**
* Cancel.
*
* @returns Promise resolved when done.
*/
async cancel(): Promise<void> {
await CoreUtils.ignoreErrors(CoreSites.logout());
await CoreNavigator.navigate('/login/sites', { reset: true });
}
/**
* Accept the site policy.
*
* @returns Promise resolved when done.
*/
async accept(): Promise<void> {
const modal = await CoreDomUtils.showModalLoading('core.sending', true);
try {
await CoreLoginHelper.acceptSitePolicy(this.siteId);
// Success accepting, go to site initial page.
// Invalidate cache since some WS don't return error if site policy is not accepted.
await CoreUtils.ignoreErrors(this.currentSite.invalidateWsCache());
CoreEvents.trigger(CoreEvents.SITE_POLICY_AGREED, {}, this.siteId);
await CoreNavigator.navigateToSiteHome();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error accepting site policy.');
} finally {
modal.dismiss();
}
}
}

View File

@ -87,30 +87,12 @@ 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<void> {
const site = await CoreSites.getSite(siteId);
const result = await site.write<AgreeSitePolicyResult>('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]);
const { CorePolicy } = await import('@features/policy/services/policy');
return CorePolicy.acceptMandatorySitePolicies(siteId);
}
/**
@ -286,36 +268,27 @@ 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<AuthEmailSignupSettings> {
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<string> {
const site = await CoreSites.getSite(siteId);
const { CorePolicy } = await import('@features/policy/services/policy');
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 = <AuthEmailSignupSettings> 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 +1040,13 @@ 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;
}
async sitePolicyNotAgreed(siteId?: string): Promise<void> {
const { CorePolicy } = await import('@features/policy/services/policy');
// 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 +1501,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.
*/

View File

@ -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 {}

View File

@ -0,0 +1,24 @@
<ion-header>
<ion-toolbar>
<ion-title *ngIf="policy">
<h1>{{ policy.name }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item *ngIf="policy" class="ion-text-wrap">
<ion-label>
<core-format-text [text]="policy.summary" contextLevel="system" [contextInstanceId]="0" />
</ion-label>
</ion-item>
<ion-item *ngIf="policy" class="ion-text-wrap">
<ion-label>
<core-format-text [text]="policy.content" contextLevel="system" [contextInstanceId]="0" />
</ion-label>
</ion-item>
</ion-content>

View File

@ -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();
}
}

View File

@ -0,0 +1,18 @@
// (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';
export const ACCEPTANCES_PAGE_NAME = 'acceptances';

View File

@ -0,0 +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}}",
"status1": "Active",
"useracceptanceactionaccept": "Accept",
"useracceptanceactionacceptone": "Accept {{$a}}",
"useracceptanceactiondecline": "Decline",
"useracceptanceactiondeclineone": "Decline {{$a}}",
"useracceptanceactionrevoke": "Withdraw",
"useracceptanceactionrevokeone": "Withdraw acceptance of {{$a}}",
"viewpolicy": "View policy {{policyname}}."
}

View File

@ -0,0 +1,309 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title>
<h1>{{ 'core.policy.policiesagreements' | translate }}</h1>
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" [disabled]="!dataLoaded" (ionRefresh)="refreshAcceptances($event.target)">
<ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}" />
</ion-refresher>
<core-loading [hideUntil]="dataLoaded">
<div class="list-item-limited-width">
<ion-card class="core-info-card">
<ion-item class="ion-text-wrap">
<ion-icon name="fas-circle-info" slot="start" aria-hidden="true" />
<ion-label>
<p>{{ 'core.policy.contactdpo' | translate }}</p>
</ion-label>
</ion-item>
<div class="core-card-buttons" *ngIf="canContactDPO">
<ion-button fill="outline" (click)="openContactDPO($event)">{{ 'core.contactverb' | translate }}</ion-button>
</div>
</ion-card>
</div>
<table *ngIf="isTablet && policies.length" class="core-table x-scrollable core-policy-tablet-container">
<thead>
<tr>
<th>{{ 'core.policy.policydocname' | translate }}</th>
<th>{{ 'core.policy.policydocrevision' | translate }}</th>
<th>{{ 'core.policy.response' | translate }}</th>
<th>{{ 'core.policy.responseon' | translate }}</th>
<ng-container *ngIf="hasOnBehalf">
<th>{{ 'core.policy.responseby' | translate }}</th>
<th>{{ 'core.policy.acceptancenote' | translate }}</th>
</ng-container>
</tr>
</thead>
<tbody class="auto-striped">
<ng-container *ngFor="let policy of policies">
<ng-container
*ngTemplateOutlet="policyTabletTemplate; context: {policy: policy, isPreviousVersion: false, hidden: false}" />
</ng-container>
</tbody>
</table>
<div *ngIf="!isTablet && policies.length" class="core-policy-mobile-container">
<ng-container *ngFor="let policy of policies">
<ng-container *ngTemplateOutlet="policyMobileTemplate; context: {policy: policy, isPreviousVersion: false}" />
</ng-container>
</div>
<core-empty-box *ngIf="!policies.length" icon="fas-file-shield" [message]="'core.policy.nopoliciesyet' | translate" />
</core-loading>
</ion-content>
<!-- Template to render a policy in a tablet device. -->
<ng-template #policyTabletTemplate let-policy="policy" let-isPreviousVersion="isPreviousVersion">
<tr [class.core-policy-previous-version]="isPreviousVersion">
<td class="core-policy-title">
<ion-icon *ngIf="isPreviousVersion" name="fam-arrow-turn-down-right" aria-hidden="true" />
<ion-icon *ngIf="!isPreviousVersion && policy.previousVersions.length" name="fas-chevron-right" flip-rtl
(ariaButtonClick)="toggle($event, policy)" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="policy.expanded" [attr.aria-expanded]="policy.expanded"
[attr.aria-label]="(policy.expanded ? 'core.collapse' : 'core.expand') | translate" />
<ion-icon *ngIf="!isPreviousVersion && !policy.previousVersions.length" class="core-policy-icon-placeholder"
aria-hidden="true" />
<a href="#" (click)="viewFullPolicy($event, policy)">{{ policy.name }}</a>
</td>
<td class="core-policy-revision">
<p>{{ policy.revision }}</p>
<ion-badge color="success" *ngIf="policy.status === activeStatus">
{{ 'core.policy.status1' | translate }}
</ion-badge>
<ion-badge color="info" *ngIf="policy.optional">
{{ 'core.policy.policydocoptionalyes' | translate }}
</ion-badge>
</td>
<td class="core-policy-user-agreement">
<p class="core-policy-user-agreement-info">
<ng-container *ngIf="policy.hasAccepted">
<ion-icon name="fas-check" color="success" aria-hidden="true" />
<span *ngIf="policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusacceptedbehalf' | translate }}
</span>
<span *ngIf="!policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusaccepted' | translate }}
</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canrevoke">
<ion-button fill="none" (click)="setAcceptance($event, policy, false)"
[attr.aria-label]="'core.policy.useracceptanceactionrevokeone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionrevoke' | translate }}
</ion-button>
</span>
</ng-container>
<ng-container *ngIf="policy.hasDeclined">
<ion-icon name="fas-xmark" color="danger" aria-hidden="true" />
<span *ngIf="policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusdeclinedbehalf' | translate }}
</span>
<span *ngIf="!policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusdeclined' | translate }}
</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canaccept">
<ion-button fill="none" (click)="setAcceptance($event, policy, true)"
[attr.aria-label]="'core.policy.useracceptanceactionacceptone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionaccept' | translate }}
</ion-button>
</span>
</ng-container>
<ng-container *ngIf="!policy.hasAccepted && !policy.hasDeclined">
<ion-icon name="fas-clock" color="warning" aria-hidden="true" />
<span class="core-policy-user-agreement-status">{{ 'core.policy.acceptancestatuspending' | translate }}</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canaccept">
<ion-button fill="none" (click)="setAcceptance($event, policy, true)"
[attr.aria-label]="'core.policy.useracceptanceactionacceptone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionaccept' | translate }}
</ion-button>
<ion-button fill="none" (click)="setAcceptance($event, policy, false)"
[attr.aria-label]="'core.policy.useracceptanceactiondeclineone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactiondecline' | translate }}
</ion-button>
</span>
</ng-container>
</p>
</td>
<td class="core-policy-responseon">
<p *ngIf="policy.acceptance">
{{ policy.acceptance.timemodified * 1000 | coreFormatDate:'strftimedatetime' }}
</p>
<p *ngIf="!policy.acceptance">-</p>
</td>
<ng-container *ngIf="hasOnBehalf">
<td class="core-policy-responseby">
<p *ngIf="policy.onBehalf">
<a href="#" core-user-link [userId]="policy.acceptance.usermodified">{{ policy.acceptance.modfullname }}</a>
</p>
<p *ngIf="!policy.onBehalf">-</p>
</td>
<td class="core-policy-acceptance-note">
<p *ngIf="policy.acceptance?.note">
<core-format-text [text]="policy.acceptance.note" contextLevel="system" [contextInstanceId]="0" />
</p>
<p *ngIf="!policy.acceptance?.note">-</p>
</td>
</ng-container>
</tr>
<ng-container *ngIf="!isPreviousVersion && policy.previousVersions.length && policy.expanded">
<ng-container *ngFor="let policy of policy.previousVersions">
<ng-container *ngTemplateOutlet="policyTabletTemplate; context: {policy: policy, isPreviousVersion: true }" />
</ng-container>
</ng-container>
</ng-template>
<!-- Template to render a policy in a mobile device. -->
<ng-template #policyMobileTemplate let-policy="policy" let-isPreviousVersion="isPreviousVersion">
<ion-item class="ion-text-wrap core-policy-title">
<div slot="start">
<ion-icon *ngIf="isPreviousVersion" name="fam-arrow-turn-down-right" aria-hidden="true" />
<ion-icon name="fas-chevron-right" flip-rtl (ariaButtonClick)="toggle($event, policy)" class="expandable-status-icon"
[class.expandable-status-icon-expanded]="policy.expanded" [attr.aria-expanded]="policy.expanded"
[attr.aria-label]="(policy.expanded ? 'core.collapse' : 'core.expand') | translate"
[attr.aria-controls]="'core-policy-details-' + policy.versionid" />
</div>
<ion-label>
<p *ngIf="isPreviousVersion">{{ policy.revision }}</p>
<p *ngIf="!isPreviousVersion">{{ policy.name }}</p>
</ion-label>
<ion-button fill="clear" (click)="viewFullPolicy($event, policy)"
[attr.aria-label]="'core.policy.viewpolicy' | translate:{policyname: policy.name}">
<ion-icon slot="icon-only" name="fas-eye" aria-hidden="true" />
</ion-button>
</ion-item>
<div [hidden]="!policy.expanded" id="core-policy-details-{{policy.versionid}}" class="core-policy-details">
<ion-item class="ion-text-wrap core-policy-revision" lines="full">
<ion-label>
<ng-container *ngIf="isPreviousVersion">
<p class="item-heading">{{ 'core.policy.policydocname' | translate }}</p>
<p>{{ policy.name }}</p>
</ng-container>
<ng-container *ngIf="!isPreviousVersion">
<p class="item-heading">{{ 'core.policy.policydocrevision' | translate }}</p>
<p>{{ policy.revision }}</p>
</ng-container>
<ion-badge color="success" *ngIf="policy.status === activeStatus">
{{ 'core.policy.status1' | translate }}
</ion-badge>
<ion-badge color="info" *ngIf="policy.optional">
{{ 'core.policy.policydocoptionalyes' | translate }}
</ion-badge>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-policy-user-agreement" lines="full"
[class.core-policy-agreement-has-actions]="policy.hasActions">
<ion-label>
<p class="item-heading">{{ 'core.policy.response' | translate }}</p>
<p class="core-policy-user-agreement-info">
<ng-container *ngIf="policy.hasAccepted">
<ion-icon name="fas-check" color="success" aria-hidden="true" />
<span *ngIf="policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusacceptedbehalf' | translate }}
</span>
<span *ngIf="!policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusaccepted' | translate }}
</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canrevoke">
<ion-button fill="none" (click)="setAcceptance($event, policy, false)"
[attr.aria-label]="'core.policy.useracceptanceactionrevokeone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionrevoke' | translate }}
</ion-button>
</span>
</ng-container>
<ng-container *ngIf="policy.hasDeclined">
<ion-icon name="fas-xmark" color="danger" aria-hidden="true" />
<span *ngIf="policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusdeclinedbehalf' | translate }}
</span>
<span *ngIf="!policy.onBehalf" class="core-policy-user-agreement-status">
{{ 'core.policy.acceptancestatusdeclined' | translate }}
</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canaccept">
<ion-button fill="none" (click)="setAcceptance($event, policy, true)"
[attr.aria-label]="'core.policy.useracceptanceactionacceptone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionaccept' | translate }}
</ion-button>
</span>
</ng-container>
<ng-container *ngIf="!policy.hasAccepted && !policy.hasDeclined">
<ion-icon name="fas-clock" color="warning" aria-hidden="true" />
<span class="core-policy-user-agreement-status">{{ 'core.policy.acceptancestatuspending' | translate }}</span>
<span class="core-policy-user-agreement-actions" *ngIf="policy.canaccept">
<ion-button fill="none" (click)="setAcceptance($event, policy, true)"
[attr.aria-label]="'core.policy.useracceptanceactionacceptone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactionaccept' | translate }}
</ion-button>
<ion-button fill="none" (click)="setAcceptance($event, policy, false)"
[attr.aria-label]="'core.policy.useracceptanceactiondeclineone' | translate:{$a: policy.name}">
{{ 'core.policy.useracceptanceactiondecline' | translate }}
</ion-button>
</span>
</ng-container>
</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-policy-responseon" lines="full">
<ion-label>
<p class="item-heading">{{ 'core.policy.responseon' | translate }}</p>
<p *ngIf="policy.acceptance">
{{ policy.acceptance.timemodified * 1000 | coreFormatDate:'strftimedatetime' }}
</p>
<p *ngIf="!policy.acceptance">-</p>
</ion-label>
</ion-item>
<ng-container *ngIf="policy.onBehalf">
<ion-item button class="ion-text-wrap core-policy-responseby" core-user-link [userId]="policy.acceptance.usermodified"
lines="full" detail="false">
<ion-label>
<p class="item-heading">{{ 'core.policy.responseby' | translate }}</p>
<p class="core-policy-responseby-name">{{ policy.acceptance.modfullname }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-policy-acceptance-note" lines="full">
<ion-label>
<p class="item-heading">{{ 'core.policy.acceptancenote' | translate }}</p>
<p *ngIf="policy.acceptance.note">
<core-format-text [text]="policy.acceptance.note" contextLevel="system" [contextInstanceId]="0" />
</p>
<p *ngIf="!policy.acceptance.note">-</p>
</ion-label>
</ion-item>
</ng-container>
<div *ngIf="!isPreviousVersion && policy.previousVersions.length" class="core-policy-previous-versions">
<ng-container *ngFor="let policy of policy.previousVersions">
<ng-container *ngTemplateOutlet="policyMobileTemplate; context: {policy: policy, isPreviousVersion: true}" />
</ng-container>
</div>
</div>
</ng-template>

View File

@ -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);
}
}
}

View File

@ -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<void> {
this.canContactDPO = await CoreUtils.ignoreErrors(CoreDataPrivacy.isEnabled(), false);
}
/**
* Fetch the policies and acceptances.
*
* @returns Promise resolved when done.
*/
protected async fetchAcceptances(): Promise<void> {
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;
}, <Record<number, SitePolicy[]>> {});
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<void> {
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<void> {
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<void> {
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[];
};

View File

@ -0,0 +1,160 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [text]="'core.back' | translate" />
</ion-buttons>
<ion-title *ngIf="siteName">
<h1><core-format-text [text]="siteName" contextLevel="system" [contextInstanceId]="0" /></h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="cancel()" [attr.aria-label]="'core.cancel' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden=true />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="limited-width">
<core-loading [hideUntil]="policyLoaded">
<form *ngIf="policiesForm" [formGroup]="policiesForm" (ngSubmit)="submitAcceptances($event)">
<ion-item class="core-site-policy-top-bar" *ngIf="stepData">
<ion-label>
<p>{{ 'core.policy.steppolicies' | translate:{ $a:stepData } }}</p>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngIf="title">
<ion-label>
<h2>{{ title }}</h2>
<p *ngIf="subTitle">{{ subTitle }}</p>
</ion-label>
</ion-item>
<hr>
<!-- Accepting all policies at once, using a URL to view them. -->
<ng-container *ngIf="isPoliciesURL && sitePoliciesURL">
<ion-card class="core-info-card">
<ion-item class="ion-text-wrap">
<ion-icon name="fas-circle-info" slot="start" aria-hidden="true" />
<ion-label>
<p>{{ 'core.policy.policyagree' | translate }}</p>
</ion-label>
</ion-item>
</ion-card>
<ion-item class="ion-text-wrap core-site-policy-link">
<ion-label>
<p>
<a [href]="sitePoliciesURL" core-link [capture]="false">
{{ 'core.policy.policyagreementclick' | translate }}<ion-icon name="fas-up-right-from-square"
aria-hidden="true" />
</a>
</p>
</ion-label>
</ion-item>
<div class="core-site-policy-iframe-container" [class.core-policy-has-iframe]="showInline">
<core-iframe *ngIf="showInline" [src]="sitePoliciesURL" />
</div>
<ion-item class="ion-text-wrap">
<ion-checkbox name="agreepolicy" formControlName="agreepolicy">
<p *ngIf="isManageAcceptancesAvailable" [core-mark-required]="true">
{{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:'core.policy.policyagreement' | translate } }}
</p>
<p *ngIf="!isManageAcceptancesAvailable" [core-mark-required]="true">
{{ 'core.policy.policyacceptmandatory' | translate }}
</p>
</ion-checkbox>
</ion-item>
</ng-container>
<!-- Accepting policies one by one , either in same page or using a consent form at the end. -->
<ng-container *ngIf="!isPoliciesURL && (currentPolicy || showConsentForm)">
<ng-container *ngIf="currentPolicy">
<ion-item class="ion-text-wrap core-site-policy-summary">
<ion-label>
<core-format-text [text]="currentPolicy.summary" contextLevel="system" [contextInstanceId]="0"
(afterRender)="checkScroll()" />
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap core-site-policy-content">
<ion-label>
<core-format-text [text]="currentPolicy.content" contextLevel="system" [contextInstanceId]="0"
(afterRender)="checkScroll()" />
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="showConsentForm">
<ng-container *ngFor="let policy of pendingPolicies">
<ion-item class="ion-text-wrap">
<ion-label>
<h2>{{ policy.name }}</h2>
<div [collapsible-item]="64">
<core-format-text [text]="policy.summary" contextLevel="system" [contextInstanceId]="0"
(afterRender)="checkScroll()" />
</div>
</ion-label>
</ion-item>
<ion-item *ngIf="policy.referToFullPolicyText" class="ion-text-wrap" button detail="false"
(click)="viewFullPolicy(policy)">
<ion-label>
<p [innerHTML]="policy.referToFullPolicyText"></p>
</ion-label>
</ion-item>
<ng-container *ngTemplateOutlet="policyForm; context: {policy: policy}" />
</ng-container>
</ng-container>
<ion-item class="core-site-policy-bottom-bar" *ngIf="stepData || hasScroll">
<ion-label>
<p *ngIf="stepData">{{ 'core.policy.steppolicies' | translate:{ $a:stepData } }}</p>
</ion-label>
<ion-button *ngIf="hasScroll" class="core-site-policy-go-top-button" color="secondary" slot="end"
(click)="scrollTop($event)" [attr.aria-label]="'core.policy.backtotop' | translate">
<ion-icon name="fas-chevron-up" slot="icon-only" aria-hidden="true" />
</ion-button>
</ion-item>
<ng-container *ngIf="agreeInOwnPage">
<ng-container *ngTemplateOutlet="policyForm; context: {policy: currentPolicy}" />
</ng-container>
</ng-container>
<div class="core-site-policy-buttons ion-margin-horizontal">
<ion-button *ngIf="(isPoliciesURL && sitePoliciesURL) || showConsentForm || agreeInOwnPage" type="submit"
class="ion-text-wrap" [expand]="isTablet ? null : 'block'"
[disabled]="(isPoliciesURL || agreeInOwnPage) && !policiesForm.valid">
{{ 'core.continue' | translate }}
</ion-button>
<ion-button *ngIf="!isPoliciesURL && currentPolicy && !agreeInOwnPage" class="ion-text-wrap"
[expand]="isTablet ? null : 'block'" (click)="nextPolicy($event)">
{{ 'core.next' | translate }}
</ion-button>
</div>
</form>
</core-loading>
</ion-content>
<ng-template #policyForm let-policy="policy">
<ion-item class="ion-text-wrap" *ngIf="!policy.optional && policiesForm">
<ion-checkbox [formControl]="policiesForm.controls['agreepolicy' + policy.versionid]">
<p [core-mark-required]="true">
{{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:policy.name } }}
</p>
</ion-checkbox>
<core-input-errors [control]="policiesForm.controls['agreepolicy' + policy.versionid]" [errorMessages]="policiesErrors" />
</ion-item>
<ion-radio-group *ngIf="policy.optional && policiesForm" [formControl]="policiesForm.controls['agreepolicy' + policy.versionid]">
<ion-item class="ion-text-wrap">
<ion-radio [value]="1">
<p>{{ 'core.policy.havereadandagreepolicy' | translate:{ policyname:policy.name } }}</p>
</ion-radio>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-radio [value]="0">
<p>{{ 'core.policy.idontagree' | translate:{ $a:policy.name } }}</p>
</ion-radio>
<core-input-errors [control]="policiesForm.controls['agreepolicy' + policy.versionid]" [errorMessages]="policiesErrors" />
</ion-item>
</ion-radio-group>
</ng-template>

View File

@ -0,0 +1,81 @@
@use "theme/globals" as *;
:host {
hr {
background: var(--black);
margin: 0 16px;
}
h2 {
font-size: 1.25rem;
}
form {
display: flex;
flex-direction: column;
flex-grow: 1;
height: 100%;
ion-item, .core-info-card {
flex-shrink: 0;
}
ion-item {
--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;
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;
}
.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 .core-site-policy-full-policy-link {
color: var(--core-link-color);
}
}
}

View File

@ -0,0 +1,485 @@
// (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 { ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
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, 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.
*/
@Component({
selector: 'page-core-policy-site-policy',
templateUrl: 'site-policy.html',
styleUrls: ['site-policy.scss'],
})
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;
// 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
*/
async ngOnInit(): Promise<void> {
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();
return;
}
const currentSiteId = this.currentSite.id;
this.siteId = this.siteId || currentSiteId;
if (this.siteId != currentSiteId) {
// Not current site, stop.
this.cancel();
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) {
await this.fetchSitePoliciesURL();
this.initFormForPoliciesURL();
} else {
await this.fetchNextPoliciesToAccept();
}
}
/**
* Fetch the site policies URL.
*
* @returns Promise resolved when done.
*/
protected async fetchSitePoliciesURL(): Promise<void> {
this.title = Translate.instant('core.policy.policyagreement');
this.subTitle = undefined;
try {
this.sitePoliciesURL = await CorePolicy.getSitePoliciesURL(this.siteId);
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error getting site policy.');
this.cancel();
return;
}
// Try to get the mime type.
try {
const mimeType = await CoreUtils.getMimeTypeFromUrl(this.sitePoliciesURL);
const extension = CoreMimetypeUtils.getExtension(mimeType, this.sitePoliciesURL);
this.showInline = extension == 'html' || extension == 'htm';
} catch {
// Unable to get mime type, assume it's not supported.
this.showInline = false;
} 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<void> {
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: `<span class="core-site-policy-full-policy-link">${policy.name}</span>`,
});
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<string, string | number> = {
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.policiesForm = new FormGroup({
agreepolicy: new FormControl(false, {
validators: Validators.requiredTrue,
nonNullable: true,
}),
});
}
/**
* 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<number | undefined>(undefined, {
validators: Validators.required,
}));
} else {
this.policiesForm?.addControl('agreepolicy' + policy.versionid, new FormControl(false, {
validators: Validators.requiredTrue,
nonNullable: true,
}));
}
});
}
/**
* Cancel.
*
* @returns Promise resolved when done.
*/
async cancel(): Promise<void> {
await CoreUtils.ignoreErrors(CoreSites.logout());
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<void> {
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.
*
* @param event Event.
* @returns Promise resolved when done.
*/
async submitAcceptances(event: Event): Promise<void> {
event.preventDefault();
event.stopPropagation();
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);
await this.finishAcceptingPolicies();
} catch (error) {
CoreDomUtils.showErrorModalDefault(error, 'Error accepting site policies.');
} finally {
modal.dismiss();
}
}
/**
* Accept current pending policies.
*/
protected async acceptPendingPolicies(): Promise<void> {
if (!this.pendingPolicies) {
return;
}
const acceptances: Record<number, number> = {};
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<void> {
// 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;
};

View File

@ -0,0 +1,46 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module';
import { CorePolicySitePolicyPage } from '@features/policy/pages/site-policy/site-policy';
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({
imports: [
CoreSharedModule,
RouterModule.forChild(routes),
CorePolicyComponentsModule,
],
declarations: [
CorePolicySitePolicyPage,
CorePolicyAcceptancesPage,
],
})
export class CorePolicyLazyModule {}

View File

@ -0,0 +1,56 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { 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 = [
{
path: POLICY_PAGE_NAME,
loadChildren: () => import('./policy-lazy.module').then(m => m.CorePolicyLazyModule),
},
];
@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');
CorePolicy.goToAcceptSitePolicies(data.siteId);
});
},
},
],
})
export class CorePolicyModule {}

View File

@ -0,0 +1,64 @@
// (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 { 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<void> => {
await CoreNavigator.navigateToSitePath(`/${POLICY_PAGE_NAME}/${ACCEPTANCES_PAGE_NAME}`, { siteId });
},
}];
}
/**
* @inheritdoc
*/
async isEnabled(siteId: string, url: string, params: Record<string, string>): Promise<boolean> {
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;
}
const { CorePolicy } = await import('@features/policy/services/policy');
return CorePolicy.isManageAcceptancesAvailable(siteId);
}
}
export const CorePolicyAcceptancesLinkHandler = makeSingleton(CorePolicyAcceptancesLinkHandlerService);

View File

@ -0,0 +1,86 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Injectable } from '@angular/core';
import {
CoreUserDelegateContext,
CoreUserProfileHandlerType,
CoreUserProfileHandler,
CoreUserProfileHandlerData,
} from '@features/user/services/user-delegate';
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<boolean> {
const { CorePolicy } = await import('@features/policy/services/policy');
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<boolean> {
return context === CoreUserDelegateContext.USER_MENU;
}
/**
* @inheritdoc
*/
async isEnabledForUser(user: CoreUserProfile): Promise<boolean> {
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);

View File

@ -0,0 +1,334 @@
// (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, 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.
*/
@Injectable({ providedIn: 'root' })
export class CorePolicyService {
protected static readonly ROOT_CACHE_KEY = 'CorePolicy:';
/**
* 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<void> {
const site = await CoreSites.getSite(siteId);
const result = await site.write<CorePolicyAgreeSitePolicyResult>('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<string> {
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;
}
/**
* Get the next policies to accept.
*
* @param options Options
* @returns Next pending policies
*/
async getNextPendingPolicies(options: CoreSitesCommonWSOptions = {}): Promise<CorePolicySitePolicy[]> {
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.
*
* @param options Options
* @returns List of policies with their acceptances.
*/
async getUserAcceptances(options: CorePolicyGetAcceptancesOptions = {}): Promise<CorePolicySitePolicy[]> {
const site = await CoreSites.getSite(options.siteId);
const userId = options.userId || site.getUserId();
const data: CorePolicyGetUserAcceptancesWSParams = {
userid: userId,
};
const preSets = {
cacheKey: this.getUserAcceptancesCacheKey(userId),
updateFrequency: CoreSite.FREQUENCY_RARELY,
...CoreSites.getReadingStrategyPreSets(options.readingStrategy),
};
const response = await site.read<CorePolicyGetUserAcceptancesWSResponse>('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.
*
* @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 });
}
/**
* Invalidate acceptances WS call.
*
* @param options Options.
* @returns Promise resolved when data is invalidated.
*/
async invalidateAcceptances(options: {userId?: number; siteId?: string} = {}): Promise<void> {
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.
*
* @param siteId Site Id.
* @returns Whether the site allows getting and setting acceptances.
*/
async isManageAcceptancesAvailable(siteId?: string): Promise<boolean> {
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<number, number>, siteId?: string): Promise<number> {
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<CorePolicySetAcceptancesWSResponse>('tool_policy_set_acceptances_status', data);
if (response.warnings?.length) {
throw new CoreWSError(response.warnings[0]);
}
return response.policyagreed;
}
}
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.
*/
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.
canaccept: boolean; // Whether the policy can be accepted.
candecline: boolean; // Whether the policy can be declined.
canrevoke: boolean; // Whether the policy can be revoked.
};
/**
* 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[];
};
/**
* 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.
}
/**
* Status of a policy.
*/
export enum CorePolicyStatus {
Draft = 0,
Active = 1,
Archived = 2,
}

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -749,16 +749,17 @@ export class CoreUtilsProvider {
async getMimeTypeFromUrl(url: string): Promise<string> {
// 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 || '';
}
/**

View File

@ -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;
}
@ -936,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"] {
@ -1627,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);