MOBILE-4009 course: Validate guest access password and enter the course

main
Pau Ferrer Ocaña 2023-06-28 11:40:52 +02:00
parent 2804951810
commit 73addbf42e
11 changed files with 284 additions and 100 deletions

View File

@ -1582,6 +1582,9 @@
"core.course.errordownloadingsection": "local_moodlemobileapp",
"core.course.errorgetmodule": "local_moodlemobileapp",
"core.course.failed": "completion",
"core.course.guestaccess_passwordinvalid": "enrol_guest/passwordinvalid",
"core.course.guestaccess_withpassword": "enrol_guest",
"core.course.guestaccess": "enrol_guest/pluginname",
"core.course.hiddenfromstudents": "moodle",
"core.course.hiddenoncoursepage": "moodle",
"core.course.highlighted": "moodle",

View File

@ -46,20 +46,6 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref
// Don't check timers to decrease positives. If a user performs some action it will be reflected in other items.
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/;
/**
* Ask password.
*
* @returns Promise resolved with the password.
*/
protected async askUserPassword(): Promise<string> {
// Create and show the modal.
return CoreDomUtils.promptPassword({
title: 'addon.mod_lesson.enterpassword',
placeholder: 'core.login.password',
submit: 'addon.mod_lesson.continue',
});
}
/**
* Get the download size of a module.
*
@ -149,7 +135,13 @@ export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPref
throw new CoreError(accessInfo.preventaccessreasons[0].message);
}
password = await this.askUserPassword();
// Create and show the modal.
const response = await CoreDomUtils.promptPassword({
title: 'addon.mod_lesson.enterpassword',
placeholder: 'core.login.password',
submit: 'addon.mod_lesson.continue',
});
password = response.password;
return this.validatePassword(lessonId, accessInfo, password, options);
}

View File

@ -11,15 +11,20 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form (ngSubmit)="submitPassword($event)" #passwordForm class="ion-padding-vertical">
<ion-item>
<ion-label class="sr-only">{{ placeholder | translate }}</ion-label>
<core-show-password name="password">
<ion-input class="ion-text-wrap core-ioninput-password" name="password" type="password"
placeholder="{{ placeholder | translate }}" [(ngModel)]="password" core-auto-focus [clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<form (ngSubmit)="submitPassword($event)" #passwordForm>
<div>
<ion-item>
<ion-label class="sr-only">{{ placeholder | translate }}</ion-label>
<core-show-password name="password">
<ion-input class="ion-text-wrap core-ioninput-password" name="password" type="password"
placeholder="{{ placeholder | translate }}" [(ngModel)]="password" core-auto-focus [clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<ion-item *ngIf="error" class="ion-text-wrap ion-padding-top text-danger">
<core-format-text [text]="error | translate"></core-format-text>
</ion-item>
</div>
<ion-button expand="block" type="submit" [disabled]="!password">
{{ submit | translate }}
</ion-button>

View File

@ -17,6 +17,7 @@ import { Component, ViewChild, ElementRef, Input } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreForms } from '@singletons/form';
import { ModalController } from '@singletons';
import { CoreDomUtils } from '@services/utils/dom';
/**
* Modal that asks the password.
@ -31,23 +32,63 @@ export class CorePasswordModalComponent {
@ViewChild('passwordForm') formElement?: ElementRef;
@Input() title? = 'core.login.password'; // Translatable string to be shown on modal title.
@Input() placeholder? = 'core.login.password'; // Translatable string to be shown on password input as placeholder.
@Input() submit? = 'core.submit'; // Translatable string to be shown on submit button.
@Input() password? = ''; // Previous entered password.
@Input() title = 'core.login.password'; // Translatable string to be shown on modal title.
@Input() placeholder = 'core.login.password'; // Translatable string to be shown on password input as placeholder.
@Input() submit = 'core.submit'; // Translatable string to be shown on submit button.
@Input() validator?: (password?: string) => Promise<CorePasswordModalResponse>; // Function to validate the password.
password = ''; // Previous entered password.
error?: string; // Error message to be shown.
/**
* Send the password back.
*
* @param e Event.
*/
submitPassword(e: Event): void {
async submitPassword(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId());
ModalController.dismiss(this.password);
const response = await this.validatePassword(this.password);
if (response.validated === undefined) {
ModalController.dismiss(response);
}
if (response.validated) {
ModalController.dismiss(response);
}
this.error = response.error;
}
/**
* Validates the entered password if validator is available.
*
* @param password Entered password.
* @returns Response of the modal.
*/
protected async validatePassword(password: string): Promise<CorePasswordModalResponse> {
const response: CorePasswordModalResponse = { password };
if (!this.validator) {
return response;
}
const modal = await CoreDomUtils.showModalLoading('core.loading', true);
try {
return await this.validator(password);
} catch (error) {
response.validated = false;
response.error = error;
} finally {
modal.dismiss();
}
return response;
}
/**
@ -61,4 +102,10 @@ export class CorePasswordModalComponent {
}
export type CorePasswordModalParams = Pick<CorePasswordModalComponent, 'title' | 'placeholder' | 'submit' | 'password'>;
export type CorePasswordModalParams = Partial<Pick<CorePasswordModalComponent, 'title' | 'placeholder' | 'submit' | 'validator'>>;
export type CorePasswordModalResponse = {
password: string;
validated?: boolean;
error?: string;
};

View File

@ -35,6 +35,9 @@
"errordownloadingsection": "Error downloading section.",
"errorgetmodule": "Error getting activity data.",
"failed": "Failed",
"guestaccess_passwordinvalid": "Incorrect access password, please try again",
"guestaccess_withpassword": "Guest access requires password",
"guestaccess": "Guest access",
"hiddenfromstudents": "Hidden from students",
"hiddenoncoursepage": "Available but not shown on course page",
"highlighted": "Highlighted",

View File

@ -151,6 +151,13 @@
</ion-item>
</ion-card>
<ion-card class="core-info-card ion-text-wrap" *ngIf="!isEnrolled && useGuestAccess && guestAccessPasswordRequired">
<ion-item>
<ion-icon name="fas-key" slot="start" aria-hidden="true"></ion-icon>
<ion-label>{{ 'core.course.guestaccess_withpassword' | translate }}</ion-label>
</ion-item>
</ion-card>
<ion-button (click)="openCourse()" *ngIf="!isModal && canAccessCourse" expand="block" fill="outline" class="ion-text-wrap">
<ion-icon name="fas-eye" slot="start" aria-hidden="true"></ion-icon>
{{ 'core.course.viewcourse' | translate }}

View File

@ -39,6 +39,8 @@ import { CoreColors } from '@singletons/colors';
import { CorePath } from '@singletons/path';
import { CorePromisedValue } from '@classes/promised-value';
import { CorePlatform } from '@services/platform';
import { CoreCourse } from '@features/course/services/course';
import { CorePasswordModalResponse } from '@components/password-modal/password-modal';
const ENROL_BROWSER_METHODS = ['fee', 'paypal'];
@ -64,15 +66,13 @@ export class CoreCourseSummaryPage implements OnInit, OnDestroy {
dataLoaded = false;
isModal = false;
contactsExpanded = false;
useGuestAccess = false;
guestAccessPasswordRequired = false;
courseUrl = '';
progress?: number;
protected actionSheet?: HTMLIonActionSheetElement;
courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = [];
protected useGuestAccess = false;
protected actionSheet?: HTMLIonActionSheetElement;
protected guestInstanceId = new CorePromisedValue<number | undefined>();
protected courseData = new CorePromisedValue<CoreCourseSummaryData | undefined>();
protected waitStart = 0;
@ -141,8 +141,10 @@ export class CoreCourseSummaryPage implements OnInit, OnDestroy {
const info = await CoreCourses.getCourseGuestEnrolmentInfo(guestInstanceId);
// Guest access with password is not supported by the app.
return !!info.status && !info.passwordrequired;
// Don't allow guest access if it requires a password if not supported.
this.guestAccessPasswordRequired = info.passwordrequired;
return info.status === true && (!info.passwordrequired || CoreCourses.isValidateGuestAccessPasswordAvailable());
}
/**
@ -287,11 +289,49 @@ export class CoreCourseSummaryPage implements OnInit, OnDestroy {
*
* @param replaceCurrentPage If current place should be replaced in the navigation stack.
*/
openCourse(replaceCurrentPage = false): void {
async openCourse(replaceCurrentPage = false): Promise<void> {
if (!this.canAccessCourse || !this.course || this.isModal) {
return;
}
const guestInstanceId = await this.guestInstanceId;
if (this.useGuestAccess && this.guestAccessPasswordRequired && guestInstanceId) {
// Check if the user has access to the course as guest with a previous sent password.
let validated = await CoreUtils.promiseWorks(
CoreCourse.getSections(this.courseId, true, true, undefined, undefined, false),
);
if (!validated) {
try {
const validatePassword = async (password: string): Promise<CorePasswordModalResponse> => {
const response = await CoreCourses.validateGuestAccessPassword(guestInstanceId, password);
validated = response.validated;
let error = response.hint;
if (!validated && !error) {
error = 'core.course.guestaccess_passwordinvalid';
}
return {
password, validated, error,
};
};
const response = await CoreDomUtils.promptPassword({
title: 'core.course.guestaccess',
validator: validatePassword,
});
if (!response.validated) {
return;
}
} catch {
// Cancelled, return
return;
}
}
}
CoreCourseHelper.openCourse(this.course, { params: { isGuest: this.useGuestAccess }, replace: replaceCurrentPage });
}
@ -341,59 +381,75 @@ export class CoreCourseSummaryPage implements OnInit, OnDestroy {
* Self enrol in a course.
*
* @param instanceId The instance ID.
* @param password Password to use.
* @returns Promise resolved when self enrolled.
*/
async selfEnrolInCourse(instanceId: number, password = ''): Promise<void> {
const modal = await CoreDomUtils.showModalLoading('core.loading', true);
async selfEnrolInCourse(instanceId: number): Promise<void> {
const validatePassword = async (password = ''): Promise<CorePasswordModalResponse> => {
const response: CorePasswordModalResponse = {
password,
};
try {
response.validated = await CoreCourses.selfEnrol(this.courseId, password, instanceId);
} catch (error) {
if (error && error.errorcode === CoreCoursesProvider.ENROL_INVALID_KEY) {
response.validated = false;
response.error = error.message;
} else {
CoreDomUtils.showErrorModalDefault(error, 'core.courses.errorselfenrol', true);
try {
await CoreCourses.selfEnrol(this.courseId, password, instanceId);
// Close modal and refresh data.
this.isEnrolled = true;
this.dataLoaded = false;
// Sometimes the list of enrolled courses takes a while to be updated. Wait for it.
await this.waitForEnrolled(true);
await this.refreshData().finally(() => {
// My courses have been updated, trigger event.
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {
courseId: this.courseId,
course: this.course,
action: CoreCoursesProvider.ACTION_ENROL,
}, CoreSites.getCurrentSiteId());
});
this.openCourse(true);
modal?.dismiss();
} catch (error) {
modal?.dismiss();
if (error && error.errorcode === CoreCoursesProvider.ENROL_INVALID_KEY) {
try {
// Initialize the self enrol modal.
// Invalid password, show the modal to enter the password.
const modalData = await CoreDomUtils.promptPassword({
password,
title: 'core.courses.selfenrolment',
placeholder: 'core.courses.password',
submit: 'core.courses.enrolme',
});
this.selfEnrolInCourse(instanceId, modalData);
} catch {
// No password entered, don't show error.
throw error;
}
return;
}
CoreDomUtils.showErrorModalDefault(error, 'core.courses.errorselfenrol', true);
return response;
};
const modal = await CoreDomUtils.showModalLoading('core.loading', true);
let response: CorePasswordModalResponse | undefined;
try {
response = await validatePassword();
} catch {
return;
} finally {
modal.dismiss();
}
if (!response.validated) {
try {
const response = await CoreDomUtils.promptPassword({
validator: validatePassword,
title: 'core.courses.selfenrolment',
placeholder: 'core.courses.password',
submit: 'core.courses.enrolme',
});
if (!response.validated) {
return;
}
} catch {
// Cancelled, return
return;
}
}
// Refresh data.
this.isEnrolled = true;
this.dataLoaded = false;
// Sometimes the list of enrolled courses takes a while to be updated. Wait for it.
await this.waitForEnrolled(true);
await this.refreshData().finally(() => {
// My courses have been updated, trigger event.
CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, {
courseId: this.courseId,
course: this.course,
action: CoreCoursesProvider.ACTION_ENROL,
}, CoreSites.getCurrentSiteId());
});
this.openCourse(true);
}
/**

View File

@ -592,7 +592,7 @@ export class CoreCourseHelperProvider {
}
/**
* Check whether a course is accessed using guest access and if requires password to enter.
* Check whether a course is accessed using guest access and if it requires password to enter.
*
* @param courseId Course ID.
* @param siteId Site ID. If not defined, current site.
@ -634,7 +634,8 @@ export class CoreCourseHelperProvider {
// Don't allow guest access if it requires a password and it's available.
return {
guestAccess: !!info.status && !info.passwordrequired,
guestAccess: info.status === true &&
(!info.passwordrequired || CoreCourses.isValidateGuestAccessPasswordAvailable()),
passwordRequired: info.passwordrequired,
};
} catch {

View File

@ -290,7 +290,7 @@ export class CoreCoursesProvider {
}
/**
* Get course.
* Get course information if user has persmissions to view.
*
* @param id ID of the course to get.
* @param siteId Site to get the courses from. If not defined, use current site.
@ -324,7 +324,8 @@ export class CoreCoursesProvider {
updateFrequency: CoreSite.FREQUENCY_RARELY,
};
return site.read('core_enrol_get_course_enrolment_methods', params, preSets);
return site.read<CoreEnrolGetCourseEnrolmentMethodsWSResponse>
('core_enrol_get_course_enrolment_methods', params, preSets);
}
/**
@ -368,6 +369,47 @@ export class CoreCoursesProvider {
return ROOT_CACHE_KEY + 'guestinfo:' + instanceId;
}
/**
* Check if guest password validation WS is available on the current site.
*
* @returns Whether guest password validation WSget courses by field is available.
*/
isValidateGuestAccessPasswordAvailable(): boolean {
return CoreSites.wsAvailableInCurrentSite('enrol_guest_validate_password');
}
/**
* Perform password validation of guess access.
*
* @param enrolmentInstanceId Instance id of guest enrolment plugin.
* @param password Course Password.
* @returns Wether the password is valid.
*/
async validateGuestAccessPassword(
enrolmentInstanceId: number,
password: string,
): Promise<EnrolGuestValidatePasswordWSResponse> {
const site = CoreSites.getCurrentSite();
if (!site) {
return {
validated: false,
};
}
const preSets: CoreSiteWSPreSets = {
getFromCache: false,
saveToCache: false,
emergencyCache: false,
};
const params: EnrolGuestValidatePasswordWSParams = {
instanceid: enrolmentInstanceId,
password,
};
return await site.read<EnrolGuestValidatePasswordWSResponse>('enrol_guest_validate_password', params, preSets);
}
/**
* Get courses.
* Warning: if the user doesn't have permissions to view some of the courses passed the WS call will fail.
@ -1773,14 +1815,25 @@ type CoreEnrolGetCourseEnrolmentMethodsWSParams = {
};
/**
* Course enrolment method.
* Data returned by core_enrol_get_course_enrolment_methods WS.
*/
export type CoreCourseEnrolmentMethod = {
type CoreEnrolGetCourseEnrolmentMethodsWSResponse = CoreCourseEnrolmentMethod[];
/**
* Course enrolment basic info.
*/
export type CoreCourseEnrolmentInfo = {
id: number; // Id of course enrolment instance.
courseid: number; // Id of course.
type: string; // Type of enrolment plugin.
name: string; // Name of enrolment plugin.
status: string; // Status of enrolment plugin.
status: boolean | string; // Available status of enrolment plugin. True if successful, else error message or false.
};
/**
* Course enrolment method.
*/
export type CoreCourseEnrolmentMethod = CoreCourseEnrolmentInfo & {
wsfunction?: string; // Webservice function to get more information.
};
@ -1822,8 +1875,8 @@ export type CoreCourseGetRecentCoursesOptions = CoreSitesCommonWSOptions & {
/**
* Course guest enrolment method.
*/
export type CoreCourseEnrolmentGuestMethod = CoreCourseEnrolmentMethod & {
passwordrequired: boolean; // Is a password required?.
export type CoreCourseEnrolmentGuestMethod = CoreCourseEnrolmentInfo & {
passwordrequired: boolean; // Is a password required?
};
/**
@ -1857,3 +1910,20 @@ export type CoreCourseAnyCourseDataWithOptions = CoreCourseAnyCourseData & {
navOptions?: CoreCourseUserAdminOrNavOptionIndexed;
admOptions?: CoreCourseUserAdminOrNavOptionIndexed;
};
/**
* Params of enrol_guest_validate_password WS.
*/
type EnrolGuestValidatePasswordWSParams = {
instanceid: number; // instance id of guest enrolment plugin
password: string; // the course password
};
/**
* Data returned by enrol_guest_get_instance_info WS.
*/
export type EnrolGuestValidatePasswordWSResponse = {
validated: boolean; // Whether the password was successfully validated
hint?: string; // Password hint (if enabled)
warnings?: CoreWSExternalWarning[];
};

View File

@ -59,7 +59,7 @@ import { CoreErrorInfoComponent } from '@components/error-info/error-info';
import { CorePlatform } from '@services/platform';
import { CoreCancellablePromise } from '@classes/cancellable-promise';
import { CoreLang } from '@services/lang';
import { CorePasswordModalParams } from '@components/password-modal/password-modal';
import { CorePasswordModalParams, CorePasswordModalResponse } from '@components/password-modal/password-modal';
/*
* "Utils" service with helper functions for UI, DOM elements and HTML code.
@ -1885,13 +1885,13 @@ export class CoreDomUtilsProvider {
* Prompts password to the user and returns the entered text.
*
* @param passwordParams Params to show the modal.
* @returns Entered password.
* @returns Entered password, error and validation.
*/
async promptPassword(passwordParams?: CorePasswordModalParams): Promise<CorePasswordModalResponse> {
const { CorePasswordModalComponent } =
await import('@/core/components/password-modal/password-modal.module');
const modalData = await CoreDomUtils.openModal<string>(
const modalData = await CoreDomUtils.openModal<CorePasswordModalResponse>(
{
cssClass: 'core-password-modal',
showBackdrop: true,
@ -1901,7 +1901,7 @@ export class CoreDomUtilsProvider {
},
);
if (typeof modalData !== 'string') {
if (modalData === undefined) {
throw new CoreCanceledError();
}

View File

@ -732,8 +732,8 @@ body.core-iframe-fullscreen ion-router-outlet {
.core-password-modal {
--border-radius: var(--medium-radius);
--min-width: auto;
--min-height: 260px;
--width: 320px;
--min-height: 300px;
--width: 384px;
--height: auto;
form {