Merge pull request #3727 from crazyserver/MOBILE-4009

Mobile 4009
main
Noel De Martin 2023-07-10 13:08:45 +02:00 committed by GitHub
commit 271e012493
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 565 additions and 347 deletions

View File

@ -3,7 +3,7 @@
# Functions used to create langidex.
#
SERVER_URL='https://download.moodle.org/'
SERVER_URL='https://packaging.moodle.org/'
# Downloads a file and if it's a zip file, unzip it.
function download_file {
@ -12,7 +12,7 @@ function download_file {
pushd "$LANGPACKS_PATH" > /dev/null
curl -s "$url" --output "$filename" > /dev/null
curl -L -s "$url" --output "$filename" > /dev/null
size=$(du -k "$filename" | cut -f 1)
if [ ! -n "$filename" ] || [ "$size" -le 1 ]; then
echo "Wrong or corrupt file $filename"
@ -45,7 +45,7 @@ function get_english {
get_app_version
echo "Getting English language..."
download_file "$SERVER_URL/download.php/direct/langpack/$LANGVERSION/en.zip"
download_file "$SERVER_URL/langpack/$LANGVERSION/en.zip"
}
#Saves or updates a key on langindex_old.json

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

@ -18,13 +18,11 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreCourseComponentsModule } from '@features/course/components/components.module';
import { AddonModLessonIndexComponent } from './index/index';
import { AddonModLessonMenuModalPage } from './menu-modal/menu-modal';
import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal';
@NgModule({
declarations: [
AddonModLessonIndexComponent,
AddonModLessonMenuModalPage,
AddonModLessonPasswordModalComponent,
],
imports: [
CoreSharedModule,
@ -35,7 +33,6 @@ import { AddonModLessonPasswordModalComponent } from './password-modal/password-
exports: [
AddonModLessonIndexComponent,
AddonModLessonMenuModalPage,
AddonModLessonPasswordModalComponent,
],
})
export class AddonModLessonComponentsModule {}

View File

@ -1,30 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.login.password' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding addon-mod_lesson-password-modal">
<form (ngSubmit)="submitPassword($event, passwordinput)" #passwordForm>
<ion-item>
<ion-label>{{ 'addon.mod_lesson.enterpassword' | translate }}</ion-label>
<core-show-password name="password">
<ion-input name="password" type="password" placeholder="{{ 'core.login.password' | translate }}" core-auto-focus
#passwordinput [clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<ion-button expand="block" type="submit">
{{ 'addon.mod_lesson.continue' | translate }}
<ion-icon slot="end" name="fas-chevron-right" aria-hidden="true"></ion-icon>
</ion-button>
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
<input type="submit" class="core-submit-hidden-enter" />
</form>
</ion-content>

View File

@ -1,57 +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, ViewChild, ElementRef } from '@angular/core';
import { IonInput } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { CoreForms } from '@singletons/form';
import { ModalController } from '@singletons';
/**
* Modal that asks the password for a lesson.
*/
@Component({
selector: 'page-addon-mod-lesson-password-modal',
templateUrl: 'password-modal.html',
})
export class AddonModLessonPasswordModalComponent {
@ViewChild('passwordForm') formElement?: ElementRef;
/**
* Send the password back.
*
* @param e Event.
* @param password The input element.
*/
submitPassword(e: Event, password: IonInput): void {
e.preventDefault();
e.stopPropagation();
CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId());
ModalController.dismiss(password.value);
}
/**
* Close modal.
*/
closeModal(): void {
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
ModalController.dismiss();
}
}

View File

@ -13,7 +13,6 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { CoreCanceledError } from '@classes/errors/cancelederror';
import { CoreError } from '@classes/errors/error';
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
@ -26,7 +25,6 @@ import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSFile } from '@services/ws';
import { makeSingleton, Translate } from '@singletons';
import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal';
import {
AddonModLesson,
AddonModLessonGetAccessInformationWSResponse,
@ -48,24 +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.
const modalData = await CoreDomUtils.openModal<string>({
component: AddonModLessonPasswordModalComponent,
});
if (typeof modalData != 'string') {
throw new CoreCanceledError();
}
return modalData;
}
/**
* Get the download size of a module.
*
@ -155,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

@ -24,7 +24,7 @@ import {
import {
CoreCourseModulePrefetchDelegate,
CoreCourseModulePrefetchHandler } from '@features/course/services/module-prefetch-delegate';
import { CoreCourses } from '@features/courses/services/courses';
import { CoreCourseAnyCourseData, CoreCourses } from '@features/courses/services/courses';
import { CoreNavigator } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
@ -103,7 +103,9 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
this.title = Translate.instant('core.sitehome.sitehome');
}
this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest');
this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest') ??
(await CoreCourseHelper.courseUsesGuestAccessInfo(this.courseId)).guestAccess;
this.initialSectionId = CoreNavigator.getRouteNumberParam('sectionId');
this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite();
@ -657,6 +659,25 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
this.changeDetectorRef.markForCheck();
}
protected async getCourse(courseId: number): Promise<CoreCourseAnyCourseData | undefined> {
try {
// Check if user is enrolled. If enrolled, no guest access.
return await CoreCourses.getUserCourse(courseId, true);
} catch {
// Ignore errors.
}
try {
// The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course.
return await CoreCourses.getCourse(courseId);
} catch {
// Ignore errors.
}
return await CoreCourses.getCourseByField('id', this.courseId);
}
/**
* Prefetch the whole course.
*
@ -666,12 +687,10 @@ export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy {
event.stopPropagation();
event.preventDefault();
const courses = await CoreCourses.getUserCourses(true);
let course = courses.find((course) => course.id == this.courseId);
if (!course) {
course = await CoreCourses.getCourse(this.courseId);
}
const course = await this.getCourse(this.courseId);
if (!course) {
CoreDomUtils.showErrorModal('core.course.errordownloadingcourse', true);
return;
}

View File

@ -0,0 +1,34 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ title | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="closeModal()" [attr.aria-label]="'core.close' | translate">
<ion-icon slot="icon-only" name="fas-xmark" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<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>
<!-- Remove this once Ionic fixes this bug: https://github.com/ionic-team/ionic-framework/issues/19368 -->
<input type="submit" class="core-submit-hidden-enter" />
</form>
</ion-content>

View File

@ -0,0 +1,29 @@
// (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 { CorePasswordModalComponent } from './password-modal';
import { CoreSharedModule } from '@/core/shared.module';
export { CorePasswordModalComponent };
@NgModule({
declarations: [
CorePasswordModalComponent,
],
imports: [
CoreSharedModule,
],
})
export class CorePasswordModalModule {}

View File

@ -0,0 +1,111 @@
// (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, 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.
*
* WARNING: This component is not loaded with components.module.ts.
*/
@Component({
selector: 'core-password-modal',
templateUrl: 'password-modal.html',
})
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() 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.
*/
async submitPassword(e: Event): Promise<void> {
e.preventDefault();
e.stopPropagation();
CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId());
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;
}
/**
* Close modal.
*/
closeModal(): void {
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
ModalController.dismiss();
}
}
export type CorePasswordModalParams = Partial<Pick<CorePasswordModalComponent, 'title' | 'placeholder' | 'submit' | 'validator'>>;
export type CorePasswordModalResponse = {
password: string;
validated?: boolean;
error?: string;
};

View File

@ -75,6 +75,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
@Input() initialSectionId?: number; // The section to load first (by ID).
@Input() initialSectionNumber?: number; // The section to load first (by number).
@Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section.
@Input() isGuest?: boolean; // If user is accessing as a guest.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList<CoreDynamicComponent<any>>;
@ -462,6 +463,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy {
params: {
title: this.course.fullname,
sectionId: selectedId,
isGuest: this.isGuest,
},
},
);

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

@ -5,7 +5,7 @@
<core-loading [hideUntil]="dataLoaded && !updatingData">
<core-course-format [course]="course" [sections]="sections" [initialSectionId]="sectionId" [initialSectionNumber]="sectionNumber"
[moduleId]="moduleId" class="core-course-format-{{course.format}}" *ngIf="dataLoaded && sections">
[moduleId]="moduleId" class="core-course-format-{{course.format}}" *ngIf="dataLoaded && sections" [isGuest]="isGuest">
</core-course-format>
</core-loading>
</ion-content>

View File

@ -64,6 +64,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
moduleId?: number;
displayEnableDownload = false;
displayRefresher = false;
isGuest?: boolean;
protected formatOptions?: Record<string, unknown>;
protected completionObserver?: CoreEventObserver;
@ -92,6 +93,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy, CoreRefreshCon
this.sectionId = CoreNavigator.getRouteNumberParam('sectionId');
this.sectionNumber = CoreNavigator.getRouteNumberParam('sectionNumber');
this.moduleId = CoreNavigator.getRouteNumberParam('moduleId');
this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest');
this.debouncedUpdateCachedCompletion = CoreUtils.debounce(() => {
if (this.modulesHaveCompletion) {

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

@ -31,7 +31,6 @@ import {
} from '@features/course/services/course-options-delegate';
import { CoreCourseHelper } from '@features/course/services/course-helper';
import { ActionSheetController, ModalController, NgZone, Translate } from '@singletons';
import { CoreCoursesSelfEnrolPasswordComponent } from '../../../courses/components/self-enrol-password/self-enrol-password';
import { CoreNavigator } from '@services/navigator';
import { CoreUtils } from '@services/utils/utils';
import { CoreCoursesHelper, CoreCourseWithImageAndColor } from '@features/courses/services/courses-helper';
@ -40,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'];
@ -65,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;
@ -142,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());
}
/**
@ -288,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 });
}
@ -342,16 +381,59 @@ 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> {
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);
throw error;
}
}
return response;
};
const modal = await CoreDomUtils.showModalLoading('core.loading', true);
let response: CorePasswordModalResponse | undefined;
try {
await CoreCourses.selfEnrol(this.courseId, password, instanceId);
response = await validatePassword();
} catch {
return;
} finally {
modal.dismiss();
}
// Close modal and refresh data.
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;
@ -368,35 +450,6 @@ export class CoreCourseSummaryPage implements OnInit, OnDestroy {
});
this.openCourse(true);
modal?.dismiss();
} catch (error) {
modal?.dismiss();
if (error && error.errorcode === CoreCoursesProvider.ENROL_INVALID_KEY) {
// Initialize the self enrol modal.
// Invalid password, show the modal to enter the password.
const modalData = await CoreDomUtils.openModal<string>(
{
component: CoreCoursesSelfEnrolPasswordComponent,
componentProps: { password },
},
);
if (modalData !== undefined) {
this.selfEnrolInCourse(instanceId, modalData);
return;
}
if (!password) {
// No password entered, don't show error.
return;
}
}
CoreDomUtils.showErrorModalDefault(error, 'core.courses.errorselfenrol', true);
}
}
/**

View File

@ -143,7 +143,9 @@ export class CoreCourseIndexPage implements OnInit, OnDestroy {
this.firstTabName = CoreNavigator.getRouteParam('selectedTab');
this.module = CoreNavigator.getRouteParam<CoreCourseModuleData>('module');
this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest');
this.isGuest = CoreNavigator.getRouteBooleanParam('isGuest') ??
(!!this.course && (await CoreCourseHelper.courseUsesGuestAccessInfo(this.course.id)).guestAccess);
this.modNavOptions = CoreNavigator.getRouteParam<CoreNavigationOptions>('modNavOptions');
this.openModule = CoreNavigator.getRouteBooleanParam('openModule') ?? true; // If false, just scroll to module.
if (!this.modNavOptions) {

View File

@ -469,12 +469,6 @@ export class CoreCourseHelperProvider {
let handlers: CoreCourseOptionsHandlerToDisplay[] = [];
let menuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = [];
let success = true;
let isGuest = false;
if (options.canHaveGuestCourses) {
// Check if the user can only access as guest.
isGuest = await this.courseUsesGuestAccess(course.id, siteId);
}
// Get the sections and the handlers.
subPromises.push(CoreCourse.getSections(course.id, false, true).then((courseSections) => {
@ -483,12 +477,12 @@ export class CoreCourseHelperProvider {
return;
}));
subPromises.push(CoreCourseOptionsDelegate.getHandlersToDisplay(course, false, isGuest).then((cHandlers) => {
subPromises.push(CoreCourseOptionsDelegate.getHandlersToDisplay(course, false).then((cHandlers) => {
handlers = cHandlers;
return;
}));
subPromises.push(CoreCourseOptionsDelegate.getMenuHandlersToDisplay(course, false, isGuest).then((mHandlers) => {
subPromises.push(CoreCourseOptionsDelegate.getMenuHandlersToDisplay(course, false).then((mHandlers) => {
menuHandlers = mHandlers;
return;
@ -598,19 +592,22 @@ export class CoreCourseHelperProvider {
}
/**
* Check whether a course is accessed using guest access.
* 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.
* @returns Promise resolved with boolean: whether course is accessed using guest access.
* @returns Promise resolved with guestAccess and passwordRequired booleans.
*/
async courseUsesGuestAccess(courseId: number, siteId?: string): Promise<boolean> {
async courseUsesGuestAccessInfo(
courseId: number,
siteId?: string,
): Promise<{guestAccess: boolean; passwordRequired?: boolean}> {
try {
try {
// Check if user is enrolled. If enrolled, no guest access.
await CoreCourses.getUserCourse(courseId, false, siteId);
return false;
return { guestAccess: false };
} catch {
// Ignore errors.
}
@ -619,7 +616,7 @@ export class CoreCourseHelperProvider {
// The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course.
await CoreCourses.getCourse(courseId, siteId);
return false;
return { guestAccess: false };
} catch {
// Ignore errors.
}
@ -630,19 +627,19 @@ export class CoreCourseHelperProvider {
const method = enrolmentMethods.find((method) => method.type === 'guest');
if (!method) {
return false;
return { guestAccess: false };
}
const info = await CoreCourses.getCourseGuestEnrolmentInfo(method.id);
if (!info.status) {
// Not active, reject.
return false;
}
// Don't allow guest access if it requires a password.
return !info.passwordrequired;
// Don't allow guest access if it requires a password and it's available.
return {
guestAccess: info.status === true &&
(!info.passwordrequired || CoreCourses.isValidateGuestAccessPasswordAvailable()),
passwordRequired: info.passwordrequired,
};
} catch {
return false;
return { guestAccess: false };
}
}
@ -1215,20 +1212,17 @@ export class CoreCourseHelperProvider {
*
* @param courses Courses array to prefetch.
* @param prefetch Prefetch information to be updated.
* @param options Other options.
* @returns Promise resolved when done.
*/
async prefetchCourses(
courses: CoreCourseAnyCourseData[],
prefetch: CorePrefetchStatusInfo,
options: CoreCoursePrefetchCoursesOptions = {},
): Promise<void> {
prefetch.loading = true;
prefetch.icon = CoreConstants.ICON_DOWNLOADING;
prefetch.badge = '';
const prefetchOptions = {
...options,
onProgress: (progress) => {
prefetch.badge = progress.count + ' / ' + progress.total;
prefetch.badgeA11yText = Translate.instant('core.course.downloadcoursesprogressdescription', progress);
@ -2143,17 +2137,10 @@ export type CoreCoursePrefetchCourseOptions = {
isGuest?: boolean; // Whether the user is guest.
};
/**
* Options for prefetch courses function.
*/
export type CoreCoursePrefetchCoursesOptions = {
canHaveGuestCourses?: boolean; // Whether the list of courses can contain courses with only guest access.
};
/**
* Options for confirm and prefetch courses function.
*/
export type CoreCourseConfirmPrefetchCoursesOptions = CoreCoursePrefetchCoursesOptions & {
export type CoreCourseConfirmPrefetchCoursesOptions = {
onProgress?: (data: CoreCourseCoursesProgress) => void;
};

View File

@ -442,41 +442,6 @@ Feature: Test basic usage of one course in app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
@lms_from4.0
Scenario: Guest access
Given I entered the course "Course 1" as "teacher1" in the app
And I press "Course summary" in the app
And I press "Open in browser" in the app
And I switch to the browser tab opened by the app
And I log in as "teacher1"
And I click on "Participants" "link"
And I select "Enrolment methods" from the "jump" singleselect
And I click on "Enable" "icon" in the "Guest access" "table_row"
And I close the browser tab opened by the app
Given I entered the app as "student2"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
Then I should find "Course summary" in the app
And I should find "Course" in the app
When I press "View course" "ion-button" in the app
Then the header should be "Course 1" in the app
And I should find "Choice course 1" in the app
And I should find "assignment" in the app
And I should find "Test forum name" in the app
And I should find "Test chat name" in the app
And I should find "Web links" in the app
And I should find "Test feedback name" in the app
And I should find "Test glossary" in the app
And I should find "Quiz 1" in the app
And I should find "Test survey name" in the app
And I should find "Test wiki name" in the app
And I should find "Test lesson name" in the app
And I should find "Test scorm name" in the app
And I should find "Test workshop name" in the app
Scenario: View blocks on drawer
Given the following "blocks" exist:
| blockname | contextlevel | reference | pagetypepattern | defaultregion | configdata |

View File

@ -0,0 +1,84 @@
@core @core_course @app @javascript @enrol @enrol_guest
Feature: Test basic usage of guest access course in app
Background:
Given the following "users" exist:
| username | firstname | lastname | email |
| teacher1 | Teacher | teacher | teacher1@example.com |
| student1 | Student | student | student1@example.com |
And the following "courses" exist:
| fullname | shortname | category |
| Course 1 | C1 | 0 |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
And the following "activities" exist:
| activity | course | idnumber | name | intro | assignsubmission_onlinetext_enabled | section |
| assign | C1 | assign1 | assignment | Test assignment description | 1 | 1 |
And the following "activities" exist:
| activity | name | intro | course | idnumber | groupmode |
| wiki | Test wiki name | Test wiki | C1 | wiki | 0 |
@lms_from4.0
Scenario: Guest access without password (student)
Given I log in as "teacher1"
And I am on the "Course 1" "enrolment methods" page
And I click on "Edit" "link" in the "Guest access" "table_row"
And I set the following fields to these values:
| Allow guest access | Yes |
And I press "Save changes"
And I entered the app as "student1"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
Then I should find "Course summary" in the app
And I should find "Course" in the app
When I press "View course" "ion-button" in the app
Then the header should be "Course 1" in the app
And I should find "assignment" in the app
And I should find "Test wiki name" in the app
When I press "assignment" in the app
Then I should not find "Add submission" in the app
@lms_from4.3
Scenario: Guest access with password (student)
Given I log in as "teacher1"
And I am on the "Course 1" "enrolment methods" page
And I click on "Edit" "link" in the "Guest access" "table_row"
And I set the following fields to these values:
| Allow guest access | Yes |
| Password | moodle_rules |
And I press "Save changes"
And I entered the app as "student1"
When I press "Site home" in the app
And I press "Available courses" in the app
And I press "Course 1" in the app
Then I should find "Course summary" in the app
And I should find "Course" in the app
And I should find "Guest access requires password" in the app
When I press "View course" "ion-button" in the app
And I set the following fields to these values in the app:
| Password | wrong |
And I press "Submit" "ion-button" in the app
Then I should find "Incorrect access password, please try again" in the app
# Show the hint.
Given the following config values are set as admin:
| showhint | 1 | enrol_guest |
When I press "Submit" "ion-button" in the app
Then I should find "That access password was incorrect, please try again" in the app
When I set the following fields to these values in the app:
| Password | moodle_rules |
And I press "Submit" "ion-button" in the app
Then the header should be "Course 1" in the app
And I should find "assignment" in the app
And I should find "Test wiki name" in the app
When I press "assignment" in the app
Then I should not find "Add submission" in the app

View File

@ -18,14 +18,12 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreCoursesCourseListItemComponent } from './course-list-item/course-list-item';
import { CoreCoursesCourseProgressComponent } from './course-progress/course-progress';
import { CoreCoursesCourseOptionsMenuComponent } from './course-options-menu/course-options-menu';
import { CoreCoursesSelfEnrolPasswordComponent } from './self-enrol-password/self-enrol-password';
@NgModule({
declarations: [
CoreCoursesCourseListItemComponent,
CoreCoursesCourseProgressComponent,
CoreCoursesCourseOptionsMenuComponent,
CoreCoursesSelfEnrolPasswordComponent,
],
imports: [
CoreSharedModule,
@ -34,7 +32,6 @@ import { CoreCoursesSelfEnrolPasswordComponent } from './self-enrol-password/sel
CoreCoursesCourseListItemComponent,
CoreCoursesCourseProgressComponent,
CoreCoursesCourseOptionsMenuComponent,
CoreCoursesSelfEnrolPasswordComponent,
],
})
export class CoreCoursesComponentsModule {}

View File

@ -1,27 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>
<h1>{{ 'core.courses.selfenrolment' | translate }}</h1>
</ion-title>
<ion-buttons slot="end">
<ion-button fill="clear" (click)="close()" [attr.aria-label]="'core.close' | translate">
<ion-icon name="fas-xmark" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form (ngSubmit)="submitPassword($event)" #enrolPasswordForm>
<ion-item>
<ion-label class="sr-only">{{ 'core.courses.password' | translate }}</ion-label>
<core-show-password name="password">
<ion-input class="ion-text-wrap core-ioninput-password" name="password" type="password"
placeholder="{{ 'core.courses.password' | translate }}" [(ngModel)]="password" core-auto-focus [clearOnEdit]="false">
</ion-input>
</core-show-password>
</ion-item>
<div class="ion-padding">
<ion-button expand="block" [disabled]="!password" type="submit">{{ 'core.courses.enrolme' | translate }}</ion-button>
</div>
</form>
</ion-content>

View File

@ -1,62 +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, ViewChild, ElementRef } from '@angular/core';
import { NavParams } from '@ionic/angular';
import { CoreSites } from '@services/sites';
import { ModalController } from '@singletons';
import { CoreForms } from '@singletons/form';
/**
* Modal that displays a form to enter a password to self enrol in a course.
*/
@Component({
selector: 'page-core-courses-self-enrol-password',
templateUrl: 'self-enrol-password.html',
})
export class CoreCoursesSelfEnrolPasswordComponent {
@ViewChild('enrolPasswordForm') formElement!: ElementRef;
password = '';
constructor(
navParams: NavParams,
) {
this.password = navParams.get('password') || '';
}
/**
* Close help modal.
*/
close(): void {
CoreForms.triggerFormCancelledEvent(this.formElement, CoreSites.getCurrentSiteId());
ModalController.dismiss();
}
/**
* Submit password.
*
* @param e Event.
*/
submitPassword(e: Event): void {
e.preventDefault();
e.stopPropagation();
CoreForms.triggerFormSubmittedEvent(this.formElement, false, CoreSites.getCurrentSiteId());
ModalController.dismiss(this.password);
}
}

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,6 +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, CorePasswordModalResponse } from '@components/password-modal/password-modal';
/*
* "Utils" service with helper functions for UI, DOM elements and HTML code.
@ -1880,6 +1881,33 @@ export class CoreDomUtilsProvider {
}
}
/**
* Prompts password to the user and returns the entered text.
*
* @param passwordParams Params to show the modal.
* @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<CorePasswordModalResponse>(
{
cssClass: 'core-password-modal',
showBackdrop: true,
backdropDismiss: true,
component: CorePasswordModalComponent,
componentProps: passwordParams,
},
);
if (modalData === undefined) {
throw new CoreCanceledError();
}
return modalData;
}
/**
* View an image in a modal.
*

View File

@ -729,6 +729,21 @@ body.core-iframe-fullscreen ion-router-outlet {
}
}
.core-password-modal {
--border-radius: var(--medium-radius);
--min-width: auto;
--min-height: 300px;
--width: 384px;
--height: auto;
form {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
}
// Hidden submit button.
.core-submit-hidden-enter {
position: absolute;
@ -871,7 +886,7 @@ img.large-avatar,
max-width: var(--core-large-avatar-size);
max-height: var(--core-large-avatar-size);
margin-bottom: 10px;
border-radius : 50%;
border-radius: 50%;
padding: 4px;
border: 1px solid var(--stroke);
background-color: transparent;