diff --git a/src/app/components/components.module.ts b/src/app/components/components.module.ts
index a6bf120a8..53d44a614 100644
--- a/src/app/components/components.module.ts
+++ b/src/app/components/components.module.ts
@@ -19,7 +19,11 @@ import { TranslateModule } from '@ngx-translate/core';
import { CoreIconComponent } from './icon/icon';
import { CoreIframeComponent } from './iframe/iframe';
+import { CoreInputErrorsComponent } from './input-errors/input-errors';
import { CoreLoadingComponent } from './loading/loading';
+import { CoreMarkRequiredComponent } from './mark-required/mark-required';
+import { CoreRecaptchaComponent } from './recaptcha/recaptcha';
+import { CoreRecaptchaModalComponent } from './recaptcha/recaptchamodal';
import { CoreShowPasswordComponent } from './show-password/show-password';
import { CoreEmptyBoxComponent } from './empty-box/empty-box';
import { CoreDirectivesModule } from '@app/directives/directives.module';
@@ -29,7 +33,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
declarations: [
CoreIconComponent,
CoreIframeComponent,
+ CoreInputErrorsComponent,
CoreLoadingComponent,
+ CoreMarkRequiredComponent,
+ CoreRecaptchaComponent,
+ CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
],
@@ -43,7 +51,11 @@ import { CorePipesModule } from '@app/pipes/pipes.module';
exports: [
CoreIconComponent,
CoreIframeComponent,
+ CoreInputErrorsComponent,
CoreLoadingComponent,
+ CoreMarkRequiredComponent,
+ CoreRecaptchaComponent,
+ CoreRecaptchaModalComponent,
CoreShowPasswordComponent,
CoreEmptyBoxComponent,
],
diff --git a/src/app/components/input-errors/core-input-errors.html b/src/app/components/input-errors/core-input-errors.html
new file mode 100644
index 000000000..9f216a964
--- /dev/null
+++ b/src/app/components/input-errors/core-input-errors.html
@@ -0,0 +1,16 @@
+
diff --git a/src/app/components/input-errors/input-errors.scss b/src/app/components/input-errors/input-errors.scss
new file mode 100644
index 000000000..de3f96567
--- /dev/null
+++ b/src/app/components/input-errors/input-errors.scss
@@ -0,0 +1,16 @@
+:host {
+ width: 100%;
+
+ .core-input-error-container {
+ .core-input-error {
+ padding: 4px;
+ color: var(--ion-color-danger);
+ font-size: 12px;
+ display: none;
+
+ &:first-child {
+ display: block;
+ }
+ }
+ }
+}
diff --git a/src/app/components/input-errors/input-errors.ts b/src/app/components/input-errors/input-errors.ts
new file mode 100644
index 000000000..1bad391ab
--- /dev/null
+++ b/src/app/components/input-errors/input-errors.ts
@@ -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 { Component, Input, OnChanges, SimpleChange } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { Translate } from '@singletons/core.singletons';
+
+/**
+ * Component to show errors if an input isn't valid.
+ *
+ * @description
+ * The purpose of this component is to make easier and consistent the validation of forms.
+ *
+ * It should be applied next to the input element (ion-input, ion-select, ...). In case of ion-checkbox, it should be in another
+ * item, placing it in the same item as the checkbox will cause problems.
+ *
+ * Please notice that the inputs need to have a FormControl to make it work. That FormControl needs to be passed to this component.
+ *
+ * If this component is placed in the same ion-item as a ion-label or ion-input, then it should have the attribute "item-content",
+ * otherwise Ionic will remove it.
+ *
+ * Example usage:
+ *
+ *
+ * {{ 'core.login.username' | translate }}
+ *
+ *
+ *
+ */
+@Component({
+ selector: 'core-input-errors',
+ templateUrl: 'core-input-errors.html',
+ styleUrls: ['input-errors.scss'],
+})
+export class CoreInputErrorsComponent implements OnChanges {
+
+ @Input() control?: FormControl;
+ @Input() errorMessages?: Record;
+ @Input() errorText?: string; // Set other non automatic errors.
+ errorKeys: string[] = [];
+
+ /**
+ * Initialize some common errors if they aren't set.
+ */
+ protected initErrorMessages(): void {
+ this.errorMessages = this.errorMessages || {};
+
+ this.errorMessages.required = this.errorMessages.required || Translate.instance.instant('core.required');
+ this.errorMessages.email = this.errorMessages.email || Translate.instance.instant('core.login.invalidemail');
+ this.errorMessages.date = this.errorMessages.date || Translate.instance.instant('core.login.invaliddate');
+ this.errorMessages.datetime = this.errorMessages.datetime || Translate.instance.instant('core.login.invaliddate');
+ this.errorMessages.datetimelocal = this.errorMessages.datetimelocal || Translate.instance.instant('core.login.invaliddate');
+ this.errorMessages.time = this.errorMessages.time || Translate.instance.instant('core.login.invalidtime');
+ this.errorMessages.url = this.errorMessages.url || Translate.instance.instant('core.login.invalidurl');
+
+ // Set empty values by default, the default error messages will be built in the template when needed.
+ this.errorMessages.max = this.errorMessages.max || '';
+ this.errorMessages.min = this.errorMessages.min || '';
+ }
+
+ /**
+ * Component being changed.
+ */
+ ngOnChanges(changes: { [name: string]: SimpleChange }): void {
+ if ((changes.control || changes.errorMessages) && this.control) {
+ this.initErrorMessages();
+
+ this.errorKeys = this.errorMessages ? Object.keys(this.errorMessages) : [];
+ }
+ if (changes.errorText) {
+ this.errorText = changes.errorText.currentValue;
+ }
+ }
+
+}
diff --git a/src/app/components/mark-required/core-mark-required.html b/src/app/components/mark-required/core-mark-required.html
new file mode 100644
index 000000000..bf5cd4897
--- /dev/null
+++ b/src/app/components/mark-required/core-mark-required.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/app/components/mark-required/mark-required.scss b/src/app/components/mark-required/mark-required.scss
new file mode 100644
index 000000000..def9d3ecb
--- /dev/null
+++ b/src/app/components/mark-required/mark-required.scss
@@ -0,0 +1,8 @@
+:host {
+ .core-input-required-asterisk {
+ font-size: 8px;
+ --padding-start: 4px;
+ line-height: 100%;
+ vertical-align: top;
+ }
+}
diff --git a/src/app/components/mark-required/mark-required.ts b/src/app/components/mark-required/mark-required.ts
new file mode 100644
index 000000000..c62e30bd6
--- /dev/null
+++ b/src/app/components/mark-required/mark-required.ts
@@ -0,0 +1,78 @@
+// (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, OnInit, AfterViewInit, ElementRef } from '@angular/core';
+
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreUtils } from '@services/utils/utils';
+import { Translate } from '@singletons/core.singletons';
+
+/**
+ * Directive to add a red asterisk for required input fields.
+ *
+ * @description
+ * For forms with required and not required fields, it is recommended to use this directive to mark the required ones.
+ *
+ * This directive should be applied in the label. Example:
+ *
+ * {{ 'core.login.username' | translate }}
+ */
+@Component({
+ selector: '[core-mark-required]',
+ templateUrl: 'core-mark-required.html',
+ styleUrls: ['mark-required.scss'],
+})
+export class CoreMarkRequiredComponent implements OnInit, AfterViewInit {
+
+ // eslint-disable-next-line @angular-eslint/no-input-rename
+ @Input('core-mark-required') coreMarkRequired: boolean | string = true;
+
+ protected element: HTMLElement;
+ requiredLabel?: string;
+
+ constructor(
+ element: ElementRef,
+ ) {
+ this.element = element.nativeElement;
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.requiredLabel = Translate.instance.instant('core.required');
+ this.coreMarkRequired = CoreUtils.instance.isTrueOrOne(this.coreMarkRequired);
+ }
+
+ /**
+ * Called after the view is initialized.
+ */
+ ngAfterViewInit(): void {
+ if (this.coreMarkRequired) {
+ // Add the "required" to the aria-label.
+ const ariaLabel = this.element.getAttribute('aria-label') ||
+ CoreTextUtils.instance.cleanTags(this.element.innerHTML, true);
+ if (ariaLabel) {
+ this.element.setAttribute('aria-label', ariaLabel + ' ' + this.requiredLabel);
+ }
+ } else {
+ // Remove the "required" from the aria-label.
+ const ariaLabel = this.element.getAttribute('aria-label');
+ if (ariaLabel) {
+ this.element.setAttribute('aria-label', ariaLabel.replace(' ' + this.requiredLabel, ''));
+ }
+ }
+ }
+
+}
diff --git a/src/app/components/recaptcha/core-recaptcha.html b/src/app/components/recaptcha/core-recaptcha.html
new file mode 100644
index 000000000..d094e17dc
--- /dev/null
+++ b/src/app/components/recaptcha/core-recaptcha.html
@@ -0,0 +1,13 @@
+
+
+
+
+ {{ 'core.resourcedisplayopen' | translate }}
+
+
+ {{ 'core.answered' | translate }}
+
+
+ {{ 'core.login.recaptchaexpired' | translate }}
+
+
diff --git a/src/app/components/recaptcha/core-recaptchamodal.html b/src/app/components/recaptcha/core-recaptchamodal.html
new file mode 100644
index 000000000..e49d8989b
--- /dev/null
+++ b/src/app/components/recaptcha/core-recaptchamodal.html
@@ -0,0 +1,14 @@
+
+
+ {{ 'core.login.security_question' | translate }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/components/recaptcha/recaptcha.ts b/src/app/components/recaptcha/recaptcha.ts
new file mode 100644
index 000000000..8edc8b2b0
--- /dev/null
+++ b/src/app/components/recaptcha/recaptcha.ts
@@ -0,0 +1,85 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, Input, OnInit } from '@angular/core';
+
+import { CoreLang } from '@services/lang';
+import { CoreSites } from '@services/sites';
+import { CoreTextUtils } from '@services/utils/text';
+import { ModalController } from '@singletons/core.singletons';
+import { CoreRecaptchaModalComponent } from './recaptchamodal';
+
+/**
+ * Component that allows answering a recaptcha.
+ */
+@Component({
+ selector: 'core-recaptcha',
+ templateUrl: 'core-recaptcha.html',
+})
+export class CoreRecaptchaComponent implements OnInit {
+
+ @Input() model?: Record; // The model where to store the recaptcha response.
+ @Input() publicKey?: string; // The site public key.
+ @Input() modelValueName = 'recaptcharesponse'; // Name of the model property where to store the response.
+ @Input() siteUrl?: string; // The site URL. If not defined, current site.
+
+ expired = false;
+
+ protected lang?: string;
+
+ constructor() {
+ this.initLang();
+ }
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.siteUrl = this.siteUrl || CoreSites.instance.getCurrentSite()?.getURL();
+ }
+
+ /**
+ * Initialize the lang property.
+ */
+ protected async initLang(): Promise {
+ this.lang = await CoreLang.instance.getCurrentLanguage();
+ }
+
+ /**
+ * Open the recaptcha modal.
+ */
+ async answerRecaptcha(): Promise {
+ // Set the iframe src. We use an iframe because reCaptcha V2 doesn't work with file:// protocol.
+ const src = CoreTextUtils.instance.concatenatePaths(this.siteUrl!, 'webservice/recaptcha.php?lang=' + this.lang);
+
+ // Modal to answer the recaptcha.
+ // This is because the size of the recaptcha is dynamic, so it could cause problems if it was displayed inline.
+
+ const modal = await ModalController.instance.create({
+ component: CoreRecaptchaModalComponent,
+ cssClass: 'core-modal-fullscreen',
+ componentProps: {
+ recaptchaUrl: src,
+ },
+ });
+
+ await modal.present();
+
+ const result = await modal.onWillDismiss();
+
+ this.expired = result.data.expired;
+ this.model![this.modelValueName] = result.data.value;
+ }
+
+}
diff --git a/src/app/components/recaptcha/recaptchamodal.ts b/src/app/components/recaptcha/recaptchamodal.ts
new file mode 100644
index 000000000..5a56bc2a0
--- /dev/null
+++ b/src/app/components/recaptcha/recaptchamodal.ts
@@ -0,0 +1,121 @@
+// (C) Copyright 2015 Moodle Pty Ltd.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Component, Input, OnDestroy } from '@angular/core';
+
+import { ModalController } from '@singletons/core.singletons';
+
+/**
+ * Component to display a the recaptcha in a modal.
+ */
+@Component({
+ selector: 'core-recaptcha-modal',
+ templateUrl: 'core-recaptchamodal.html',
+})
+export class CoreRecaptchaModalComponent implements OnDestroy {
+
+ @Input() recaptchaUrl?: string;
+
+ expired = false;
+ value = '';
+
+ protected messageListenerFunction: (event: MessageEvent) => Promise;
+
+ constructor() {
+ // Listen for messages from the iframe.
+ this.messageListenerFunction = this.onIframeMessage.bind(this);
+ window.addEventListener('message', this.messageListenerFunction);
+ }
+
+ /**
+ * Close modal.
+ */
+ closeModal(): void {
+ ModalController.instance.dismiss({
+ expired: this.expired,
+ value: this.value,
+ });
+ }
+
+ /**
+ * The iframe with the recaptcha was loaded.
+ *
+ * @param iframe Iframe element.
+ */
+ loaded(iframe: HTMLIFrameElement): void {
+ // Search the iframe content.
+ const contentWindow = iframe?.contentWindow;
+
+ if (contentWindow) {
+ try {
+ // Set the callbacks we're interested in.
+ contentWindow['recaptchacallback'] = this.onRecaptchaCallback.bind(this);
+ contentWindow['recaptchaexpiredcallback'] = this.onRecaptchaExpiredCallback.bind(this);
+ } catch (error) {
+ // Cannot access the window.
+ }
+ }
+ }
+
+ /**
+ * Treat an iframe message event.
+ *
+ * @param event Event.
+ * @return Promise resolved when done.
+ */
+ protected async onIframeMessage(event: MessageEvent): Promise {
+ if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'recaptcha') {
+ return;
+ }
+
+ switch (event.data.action) {
+ case 'callback':
+ this.onRecaptchaCallback(event.data.value);
+ break;
+ case 'expired':
+ this.onRecaptchaExpiredCallback();
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Recapcha callback called.
+ *
+ * @param value Value received.
+ */
+ protected onRecaptchaCallback(value: string): void {
+ this.expired = false;
+ this.value = value;
+ this.closeModal();
+ }
+
+ /**
+ * Recapcha expired callback called.
+ */
+ protected onRecaptchaExpiredCallback(): void {
+ this.expired = true;
+ this.value = '';
+ }
+
+ /**
+ * Component destroyed.
+ */
+ ngOnDestroy(): void {
+ window.removeEventListener('message', this.messageListenerFunction);
+ }
+
+}