MOBILE-4059 login: Contact support in credentials

When login attempts failed multiple times, suggest contacting site support
main
Noel De Martin 2022-10-05 12:26:56 +02:00
parent 0545d3a8c8
commit 28fd894aab
19 changed files with 309 additions and 27 deletions

View File

@ -1932,6 +1932,8 @@
"core.login.errorexampleurl": "local_moodlemobileapp",
"core.login.errorqrnoscheme": "local_moodlemobileapp",
"core.login.errorupdatesite": "local_moodlemobileapp",
"core.login.exceededloginattempts": "local_moodlemobileapp",
"core.login.exceededloginattemptssupportsubject": "local_moodlemobileapp",
"core.login.faqcannotconnectanswer": "local_moodlemobileapp",
"core.login.faqcannotconnectquestion": "local_moodlemobileapp",
"core.login.faqcannotfindmysiteanswer": "local_moodlemobileapp",

View File

@ -26,11 +26,11 @@ describe('CoreIconComponent', () => {
expect(fixture.nativeElement.innerHTML.trim()).not.toHaveLength(0);
const icon = fixture.nativeElement.querySelector('ion-icon');
const name = icon.getAttribute('name') || icon.getAttribute('ng-reflect-name') || '';
const name = icon?.getAttribute('name') || icon?.getAttribute('ng-reflect-name') || '';
expect(icon).not.toBeNull();
expect(name).toEqual('fa-thumbs-up');
expect(icon.getAttribute('role')).toEqual('presentation');
expect(icon?.getAttribute('role')).toEqual('presentation');
});
});

View File

@ -33,7 +33,7 @@ describe('CoreIframeComponent', () => {
const iframe = nativeElement.querySelector('iframe');
expect(iframe).not.toBeNull();
expect(iframe.src).toEqual('https://moodle.org/');
expect(iframe?.src).toEqual('https://moodle.org/');
});
});

View File

@ -27,7 +27,7 @@ describe('CoreUserAvatarComponent', () => {
const image = nativeElement.querySelector('img');
expect(image).not.toBeNull();
expect(image.src).toEqual(document.location.href + 'assets/img/user-avatar.png');
expect(image?.src).toEqual(document.location.href + 'assets/img/user-avatar.png');
});
});

View File

@ -67,7 +67,7 @@ describe('CoreFormatTextDirective', () => {
// Assert
const text = fixture.nativeElement.querySelector('core-format-text');
expect(text).not.toBeNull();
expect(text.innerHTML).toEqual(sentence);
expect(text?.innerHTML).toEqual(sentence);
});
it('should format text', async () => {
@ -83,7 +83,7 @@ describe('CoreFormatTextDirective', () => {
// Assert
const text = nativeElement.querySelector('core-format-text');
expect(text).not.toBeNull();
expect(text.textContent).toEqual('Formatted text');
expect(text?.textContent).toEqual('Formatted text');
expect(CoreFilter.formatText).toHaveBeenCalledTimes(1);
expect(CoreFilter.formatText).toHaveBeenCalledWith(
@ -115,7 +115,7 @@ describe('CoreFormatTextDirective', () => {
// Assert
const text = nativeElement.querySelector('core-format-text');
expect(text).not.toBeNull();
expect(text.textContent).toEqual('Formatted text');
expect(text?.textContent).toEqual('Formatted text');
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledTimes(1);
expect(CoreFilterHelper.getFiltersAndFormatText).toHaveBeenCalledWith(
@ -153,7 +153,7 @@ describe('CoreFormatTextDirective', () => {
// Assert
const image = nativeElement.querySelector('img');
expect(image).not.toBeNull();
expect(image.src).toEqual('file://local-path/');
expect(image?.src).toEqual('file://local-path/');
expect(CoreSites.getSite).toHaveBeenCalledWith(site.getId());
expect(CoreFilepool.getSrcByUrl).toHaveBeenCalledTimes(1);
@ -171,7 +171,7 @@ describe('CoreFormatTextDirective', () => {
);
const anchor = nativeElement.querySelector('a');
anchor.click();
anchor?.click();
// Assert
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);

View File

@ -31,7 +31,7 @@ describe('CoreLinkDirective', () => {
const anchor = fixture.nativeElement.querySelector('a');
expect(anchor).not.toBeNull();
expect(anchor.href).toEqual('https://moodle.org/');
expect(anchor?.href).toEqual('https://moodle.org/');
});
it('should capture clicks', async () => {
@ -46,7 +46,7 @@ describe('CoreLinkDirective', () => {
const anchor = nativeElement.querySelector('a');
anchor.click();
anchor?.click();
// Assert
expect(CoreContentLinksHelper.handleLink).toHaveBeenCalledTimes(1);

View File

@ -27,7 +27,7 @@ import { Directive, ElementRef, OnDestroy, OnInit } from '@angular/core';
})
export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestroy {
protected element: HTMLIonButtonElement;
protected element: HTMLIonButtonElement | HTMLElement;
protected mutationObserver: MutationObserver;
constructor(element: ElementRef<HTMLIonButtonElement>) {
@ -52,7 +52,11 @@ export class CoreUpdateNonReactiveAttributesDirective implements OnInit, OnDestr
* @inheritdoc
*/
async ngOnInit(): Promise<void> {
await this.element.componentOnReady();
if ('componentOnReady' in this.element) {
// This may be necessary if this is somehow called but Ionic's directives arent. This happens, for example,
// in some tests such as the credentials page.
await this.element.componentOnReady();
}
this.mutationObserver.observe(this.element, { attributes: true, attributeFilter: ['aria-label'] });
}

View File

@ -18,9 +18,11 @@ import { CoreLoginSiteOnboardingComponent } from './site-onboarding/site-onboard
import { CoreLoginSiteHelpComponent } from './site-help/site-help';
import { CoreLoginSitesComponent } from './sites/sites';
import { CoreLoginMethodsComponent } from './login-methods/login-methods';
import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts';
@NgModule({
declarations: [
CoreLoginExceededAttemptsComponent,
CoreLoginSiteOnboardingComponent,
CoreLoginSiteHelpComponent,
CoreLoginSitesComponent,
@ -30,6 +32,7 @@ import { CoreLoginMethodsComponent } from './login-methods/login-methods';
CoreSharedModule,
],
exports: [
CoreLoginExceededAttemptsComponent,
CoreLoginSiteOnboardingComponent,
CoreLoginSiteHelpComponent,
CoreLoginSitesComponent,

View File

@ -0,0 +1,13 @@
<ion-card *ngIf="canContactSupport" class="core-danger-card">
<ion-item>
<ion-icon name="fas-exclamation-triangle" slot="start" aria-hidden="true"></ion-icon>
<ion-label>
<p>
<ng-content></ng-content>
</p>
<ion-button fill="outline" color="medium" (click)="contactSupport()">
{{ 'core.contactsupport' | translate }}
</ion-button>
</ion-label>
</ion-item>
</ion-card>

View File

@ -0,0 +1,9 @@
:host {
ion-button {
margin-left: 0;
margin-right: 0;
margin-top: 16px;
}
}

View File

@ -0,0 +1,53 @@
// (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 { CoreSiteConfig } from '@classes/site';
import { CoreUserSupport } from '@features/user/services/support';
@Component({
selector: 'core-login-exceeded-attempts',
templateUrl: 'exceeded-attempts.html',
styleUrls: ['./exceeded-attempts.scss'],
})
export class CoreLoginExceededAttemptsComponent implements OnInit {
@Input() siteUrl!: string;
@Input() siteConfig!: CoreSiteConfig;
@Input() supportSubject?: string;
canContactSupport = false;
/**
* @inheritdoc
*/
ngOnInit(): void {
this.canContactSupport = CoreUserSupport.canContactSupport(this.siteConfig);
}
/**
* Contact site support.
*/
async contactSupport(): Promise<void> {
if (!this.siteConfig) {
throw new Error('Can\'t contact support without config');
}
await CoreUserSupport.contact({
supportPageUrl: CoreUserSupport.getSupportPageUrl(this.siteConfig, this.siteUrl),
subject: this.supportSubject,
});
}
}

View File

@ -27,6 +27,8 @@
"errorexampleurl": "The URL https://campus.example.edu is only an example URL, it's not a real site. <strong>Please use the URL of your school or organization's site.</strong>",
"errorqrnoscheme": "This URL isn't a valid login URL.",
"errorupdatesite": "An error occurred while updating the site's token.",
"exceededloginattempts": "Need help logging in? Try recovering your password or contact your site support",
"exceededloginattemptssupportsubject": "I can't log in",
"faqcannotconnectanswer": "Please, contact your site administrator.",
"faqcannotconnectquestion": "I typed my site address correctly but I still can't connect.",
"faqcannotfindmysiteanswer": "Have you typed the name correctly? It's also possible that your site is not included in our public sites directory. If you still can't find it, please enter your site address instead.",

View File

@ -30,6 +30,11 @@
<p class="core-siteurl">{{siteUrl}}</p>
</div>
<core-login-exceeded-attempts *ngIf="loginAttempts >= 3" [siteConfig]="siteConfig" [siteUrl]="siteUrl"
[supportSubject]="'core.login.exceededloginattemptssupportsubject' | translate">
{{ 'core.login.exceededloginattempts' | translate }}
</core-login-exceeded-attempts>
<form [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #credentialsForm>
<ion-item *ngIf="siteChecked && !isBrowserSSO">
<ion-label class="sr-only">{{ 'core.login.username' | translate }}</ion-label>

View File

@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreLoginComponentsModule } from '@features/login/components/components.module';
import { CoreLoginCredentialsPage } from './credentials';
const routes: Routes = [
@ -29,6 +30,7 @@ const routes: Routes = [
imports: [
RouterModule.forChild(routes),
CoreSharedModule,
CoreLoginComponentsModule,
],
declarations: [
CoreLoginCredentialsPage,

View File

@ -54,8 +54,9 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
isFixedUrlSet = false;
showForgottenPassword = true;
showScanQR = false;
loginAttempts = 0;
siteConfig?: CoreSitePublicConfigResponse;
protected siteConfig?: CoreSitePublicConfigResponse;
protected eventThrown = false;
protected viewLeft = false;
protected siteId?: string;
@ -274,6 +275,8 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy {
} else if (error.errorcode == 'forcepasswordchangenotice') {
// Reset password field.
this.credForm.controls.password.reset();
} else if (error.errorcode === 'invalidlogin') {
this.loginAttempts++;
}
} finally {
modal.dismiss();

View File

@ -36,6 +36,11 @@
</ion-label>
</ion-item>
</ion-card>
<core-login-exceeded-attempts *ngIf="reconnectAttempts >= 3" [siteConfig]="siteConfig" [siteUrl]="siteUrl"
[supportSubject]="'core.login.exceededloginattemptssupportsubject' | translate">
{{ 'core.login.exceededloginattempts' | translate }}
</core-login-exceeded-attempts>
</div>
<form *ngIf="!isOAuth" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #reconnectForm>
<ion-item class="ion-text-wrap core-username item-interactive">

View File

@ -55,8 +55,9 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
siteId!: string;
showScanQR = false;
showLoading = true;
reconnectAttempts = 0;
siteConfig?: CoreSitePublicConfigResponse;
protected siteConfig?: CoreSitePublicConfigResponse;
protected viewLeft = false;
protected eventThrown = false;
protected redirectData?: CoreRedirectPayload;
@ -243,6 +244,8 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
} else if (error.errorcode == 'forcepasswordchangenotice') {
// Reset password field.
this.credForm.controls.password.reset();
} else if (error.errorcode == 'invalidlogin') {
this.reconnectAttempts++;
}
} finally {
modal.dismiss();

View File

@ -0,0 +1,81 @@
// (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 { CoreSharedModule } from '@/core/shared.module';
import { findElement, mockSingleton, renderPageComponent, requireElement } from '@/testing/utils';
import { CoreSiteError } from '@classes/errors/siteerror';
import { CoreLoginComponentsModule } from '@features/login/components/components.module';
import { CoreLoginCredentialsPage } from '@features/login/pages/credentials/credentials';
import { CoreSites } from '@services/sites';
describe('Credentials page', () => {
it('renders', async () => {
// Arrange.
const siteUrl = 'https://campus.example.edu';
// Act.
const fixture = await renderPageComponent(CoreLoginCredentialsPage, {
routeParams: { siteUrl },
imports: [
CoreSharedModule,
CoreLoginComponentsModule,
],
});
// Assert.
expect(findElement(fixture, '.core-siteurl', siteUrl)).not.toBeNull();
});
it('suggests contacting support after multiple failed attempts', async () => {
// Arrange.
mockSingleton(CoreSites, {
getUserToken: () => {
throw new CoreSiteError({
message: '',
errorcode: 'invalidlogin',
});
},
});
const fixture = await renderPageComponent(CoreLoginCredentialsPage, {
routeParams: {
siteUrl: 'https://campus.example.edu',
siteConfig: { supportpage: '' },
},
imports: [
CoreSharedModule,
CoreLoginComponentsModule,
],
});
// Act.
const form = requireElement<HTMLFormElement>(fixture, 'form');
const formControls = fixture.componentInstance.credForm.controls;
formControls['username'].setValue('student');
formControls['password'].setValue('secret');
for (let i = 0; i < 3; i++) {
form.submit();
await fixture.whenRenderingDone();
await fixture.whenStable();
}
// Assert.
expect(findElement(fixture, 'ion-label', 'core.login.exceededloginattempts')).not.toBeNull();
});
});

View File

@ -27,9 +27,13 @@ import { CoreExternalContentDirectiveStub } from './stubs/directives/core-extern
import { CoreNetwork } from '@services/network';
import { CorePlatform } from '@services/platform';
import { CoreDB } from '@services/db';
import { CoreNavigator } from '@services/navigator';
import { CoreNavigator, CoreNavigatorService } from '@services/navigator';
import { CoreDomUtils } from '@services/utils/dom';
import { TranslateService } from '@ngx-translate/core';
import { TranslateService, TranslateStore } from '@ngx-translate/core';
import { CoreIonLoadingElement } from '@classes/ion-loading';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { DefaultUrlSerializer, UrlSerializer } from '@angular/router';
import { CoreUtils } from '@services/utils/utils';
abstract class WrapperComponent<U> {
@ -44,8 +48,6 @@ const textUtils = new CoreTextUtilsProvider();
const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unknown>][] = [
[Translate, mock({ instant: key => key })],
[CoreDB, mock({ getDB: () => mock() })],
[CoreNetwork, mock({ onChange: () => new Observable() })],
[CoreDomUtils, mock({ showModalLoading: () => Promise.resolve(mock({}, ['dismiss'])) })],
[CoreNavigator, mock({ navigateToSitePath: () => Promise.resolve(true) })],
[ApplicationInit, mock({
donePromise: Promise.resolve(),
@ -57,9 +59,19 @@ const DEFAULT_SERVICE_SINGLETON_MOCKS: [CoreSingletonProxy, Record<string, unkno
ready: () => Promise.resolve(),
resume: new Subject<void>(),
})],
[CoreNetwork, mock({
isOnline: () => true,
onChange: () => new Observable(),
})],
[CoreDomUtils, mock({
showModalLoading: () => Promise.resolve(mock<CoreIonLoadingElement>({ dismiss: jest.fn() })),
})],
[CoreUtils, mock({
nextTick: () => Promise.resolve(),
})],
];
async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<ComponentFixture<T>> {
async function renderAngularComponent<T>(component: Type<T>, config: RenderConfig): Promise<TestingComponentFixture<T>> {
config.declarations.push(component);
TestBed.configureTestingModule({
@ -68,11 +80,15 @@ async function renderAngularComponent<T>(component: Type<T>, config: RenderConfi
...config.declarations,
],
providers: [
...getDefaultProviders(),
...getDefaultProviders(config),
...config.providers,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
imports: [BrowserModule],
imports: [
BrowserModule,
BrowserAnimationsModule,
...config.imports,
],
});
testBedInitialized = true;
@ -107,7 +123,7 @@ function getDefaultDeclarations(): unknown[] {
];
}
function getDefaultProviders(): unknown[] {
function getDefaultProviders(config: RenderConfig): unknown[] {
const serviceProviders = DEFAULT_SERVICE_SINGLETON_MOCKS.map(
([singleton, mockInstance]) => ({
provide: singleton.injectionToken,
@ -117,6 +133,19 @@ function getDefaultProviders(): unknown[] {
return [
...serviceProviders,
{
provide: TranslateStore,
useFactory: () => {
const store = new TranslateStore();
store.translations = {
en: config.translations ?? {},
};
return store;
},
},
{ provide: UrlSerializer, useClass: DefaultUrlSerializer },
{ provide: CORE_SITE_SCHEMAS, multiple: true, useValue: [] },
];
}
@ -142,9 +171,54 @@ function createNewServiceInstance(injectionToken: Exclude<ServiceInjectionToken,
export interface RenderConfig {
declarations: unknown[];
providers: unknown[];
imports: unknown[];
translations?: Record<string, string>;
}
export type WrapperComponentFixture<T> = ComponentFixture<WrapperComponent<T>>;
export interface RenderPageConfig extends RenderConfig {
routeParams: Record<string, unknown>;
}
export type TestingComponentFixture<T = unknown> = Omit<ComponentFixture<T>, 'nativeElement'> & { nativeElement: Element };
export type WrapperComponentFixture<T = unknown> = TestingComponentFixture<WrapperComponent<T>>;
export function findElement<E = HTMLElement>(
fixture: TestingComponentFixture,
selector: string,
content?: string | RegExp,
): E | null {
const elements = fixture.nativeElement.querySelectorAll(selector);
const matches = typeof content === 'string'
? (textContent: string | null) => textContent?.includes(content)
: (textContent: string | null) => textContent?.match(content ?? '');
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (content && !matches(element.textContent)) {
continue;
}
return element as unknown as E;
}
return null;
}
export function requireElement<E = HTMLElement>(
fixture: TestingComponentFixture,
selector: string,
content?: string | RegExp,
): E {
const element = findElement<E>(fixture, selector, content);
if (!element) {
throw Error(`Could not find '${selector}' element`);
}
return element;
}
/**
* Mock a certain class, converting its methods to Mock functions and overriding the specified properties and methods.
@ -159,7 +233,7 @@ export function mock<T>(
): T {
// If overrides is an object, apply them to the instance.
if (!Array.isArray(overrides)) {
Object.assign(instance, overrides);
Object.assign(instance as Record<string, unknown>, overrides);
}
// Convert instance functions to jest functions.
@ -200,7 +274,7 @@ export function mockSingleton<T>(
const instance = getServiceInstance(singleton.injectionToken) as T;
const mockInstance = mock(instance, methods);
Object.assign(mockInstance, properties);
Object.assign(mockInstance as Record<string, unknown>, properties);
singleton.setInstance(mockInstance);
@ -225,14 +299,36 @@ export function getServiceInstance(injectionToken: ServiceInjectionToken): Recor
?? {};
}
export async function renderComponent<T>(component: Type<T>, config: Partial<RenderConfig> = {}): Promise<ComponentFixture<T>> {
export async function renderComponent<T>(
component: Type<T>,
config: Partial<RenderConfig> = {},
): Promise<TestingComponentFixture<T>> {
return renderAngularComponent(component, {
declarations: [],
providers: [],
imports: [],
...config,
});
}
export async function renderPageComponent<T>(
component: Type<T>,
config: Partial<RenderPageConfig> = {},
): Promise<TestingComponentFixture<T>> {
mockSingleton(CoreNavigator, mock<CoreNavigatorService>({
getRequiredRouteParam<T>(name: string) {
if (!config.routeParams?.[name]) {
throw new Error();
}
return config.routeParams?.[name] as T;
},
getRouteParam: <T>(name: string) => config.routeParams?.[name] as T | undefined,
}));
return renderComponent(component, config);
}
export async function renderTemplate<T>(
component: Type<T>,
template: string,
@ -246,6 +342,7 @@ export async function renderTemplate<T>(
{
declarations: [],
providers: [],
imports: [],
...config,
},
);