MOBILE-4680 login: Show the current oauth method to the top on reconnect

main
Pau Ferrer Ocaña 2024-12-19 13:05:09 +01:00
parent ae1c719f19
commit 6c132bd736
13 changed files with 219 additions and 65 deletions

View File

@ -2168,6 +2168,7 @@
"core.login.missingfirstname": "moodle", "core.login.missingfirstname": "moodle",
"core.login.missinglastname": "moodle", "core.login.missinglastname": "moodle",
"core.login.mobileservicesnotenabled": "local_moodlemobileapp", "core.login.mobileservicesnotenabled": "local_moodlemobileapp",
"core.login.morewaystologin": "local_moodlemobileapp",
"core.login.mustconfirm": "moodle", "core.login.mustconfirm": "moodle",
"core.login.newaccount": "moodle", "core.login.newaccount": "moodle",
"core.login.notloggedin": "local_moodlemobileapp", "core.login.notloggedin": "local_moodlemobileapp",

View File

@ -195,6 +195,7 @@ export class CoreSite extends CoreAuthenticatedSite {
* Check if the user authenticated in the site using an OAuth method. * Check if the user authenticated in the site using an OAuth method.
* *
* @returns Whether the user authenticated in the site using an OAuth method. * @returns Whether the user authenticated in the site using an OAuth method.
* @deprecated since 5.0. Use getOAuthId instead.
*/ */
isOAuth(): boolean { isOAuth(): boolean {
return this.oauthId != null && this.oauthId !== undefined; return this.oauthId != null && this.oauthId !== undefined;

View File

@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module'; import { CoreSharedModule } from '@/core/shared.module';
import { CoreLoginMethodsComponent } from './login-methods/login-methods'; import { CoreLoginMethodsComponent } from './login-methods/login-methods';
import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts'; import { CoreLoginExceededAttemptsComponent } from '@features/login/components/exceeded-attempts/exceeded-attempts';
import { CoreLoginIdentityProviderComponent } from './identity-provider/identity-provider';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -24,6 +25,7 @@ import { CoreLoginExceededAttemptsComponent } from '@features/login/components/e
], ],
imports: [ imports: [
CoreSharedModule, CoreSharedModule,
CoreLoginIdentityProviderComponent,
], ],
exports: [ exports: [
CoreLoginExceededAttemptsComponent, CoreLoginExceededAttemptsComponent,

View File

@ -0,0 +1,7 @@
<ion-button class="ion-text-wrap ion-margin core-oauth-provider" (click)="openOAuth()" [ariaLabel]="provider.name" expand="block"
fill="outline">
@if (provider.iconurl) {
<img [src]="provider.iconurl" alt="" width="32" height="32" slot="start" aria-hidden="true" (error)="provider.iconurl = ''" />
}
<ion-label>{{ provider.name }}</ion-label>
</ion-button>

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 } from '@angular/core';
import { CoreSiteIdentityProvider } from '@classes/sites/unauthenticated-site';
import { CoreLoginHelper } from '@features/login/services/login-helper';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreRedirectPayload } from '@services/navigator';
@Component({
selector: 'core-identity-provider',
templateUrl: 'identity-provider.html',
standalone: true,
imports: [
CoreSharedModule,
],
})
export class CoreLoginIdentityProviderComponent {
@Input({ required: true }) provider!: CoreSiteIdentityProvider;
@Input() launchurl = '';
@Input() siteUrl = '';
@Input() redirectData?: CoreRedirectPayload;
/**
* The button has been clicked.
*/
async openOAuth(): Promise<void> {
const result = await CoreLoginHelper.openBrowserForOAuthLogin(
this.siteUrl,
this.provider,
this.launchurl,
this.redirectData,
);
if (!result) {
CoreDomUtils.showErrorModal('Invalid data.');
}
}
}

View File

@ -21,9 +21,6 @@
<!-- Identity providers. --> <!-- Identity providers. -->
<ion-list *ngIf="identityProviders.length" class="core-login-identity-providers"> <ion-list *ngIf="identityProviders.length" class="core-login-identity-providers">
<h2 class="item-heading">{{ 'core.login.potentialidps' | translate }}</h2> <h2 class="item-heading">{{ 'core.login.potentialidps' | translate }}</h2>
<ion-button [fill]="'outline'" *ngFor="let provider of identityProviders" class="ion-text-wrap ion-margin core-oauth-provider" <core-identity-provider *ngFor="let provider of identityProviders" [provider]="provider" [launchurl]="siteConfig?.launchurl"
(click)="oauthClicked(provider)" [ariaLabel]="provider.name" expand="block"> [redirectData]="redirectData" [siteUrl]="siteUrl" />
<img *ngIf="provider.iconurl" [src]="provider.iconurl" alt="" width="32" height="32" slot="start" aria-hidden="true">
<ion-label>{{ provider.name }}</ion-label>
</ion-button>
</ion-list> </ion-list>

View File

@ -12,14 +12,13 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { toBoolean } from '@/core/transforms/boolean';
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreSite } from '@classes/sites/site';
import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/sites/unauthenticated-site'; import { CoreSiteIdentityProvider, CoreSitePublicConfigResponse } from '@classes/sites/unauthenticated-site';
import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper'; import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper';
import { CoreRedirectPayload } from '@services/navigator'; import { CoreRedirectPayload } from '@services/navigator';
import { CoreSites } from '@services/sites';
import { CoreSitesFactory } from '@services/sites-factory'; import { CoreSitesFactory } from '@services/sites-factory';
import { CoreDomUtils } from '@services/utils/dom';
@Component({ @Component({
selector: 'core-login-methods', selector: 'core-login-methods',
@ -28,10 +27,10 @@ import { CoreDomUtils } from '@services/utils/dom';
}) })
export class CoreLoginMethodsComponent implements OnInit { export class CoreLoginMethodsComponent implements OnInit {
@Input({ transform: toBoolean }) reconnect = false;
@Input() siteUrl = ''; @Input() siteUrl = '';
@Input() siteConfig?: CoreSitePublicConfigResponse; @Input() siteConfig?: CoreSitePublicConfigResponse;
@Input() redirectData?: CoreRedirectPayload; @Input() redirectData?: CoreRedirectPayload;
@Input() site?: CoreSite; // Defined when the user is reconnecting.
@Input() showLoginForm = true; @Input() showLoginForm = true;
isBrowserSSO = false; isBrowserSSO = false;
@ -39,16 +38,20 @@ export class CoreLoginMethodsComponent implements OnInit {
loginMethods: CoreLoginMethod[] = []; loginMethods: CoreLoginMethod[] = [];
identityProviders: CoreSiteIdentityProvider[] = []; identityProviders: CoreSiteIdentityProvider[] = [];
protected currentLoginProvider?: CoreSiteIdentityProvider;
protected isReady = new CorePromisedValue<void>();
/** /**
* @inheritdoc * @inheritdoc
*/ */
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
if (this.reconnect) { if (this.site) {
this.siteUrl = this.site.getURL();
this.loginMethods = await CoreLoginHelper.getLoginMethods(); this.loginMethods = await CoreLoginHelper.getLoginMethods();
const currentSite = CoreSites.getCurrentSite();
const defaultMethod = await CoreLoginHelper.getDefaultLoginMethod(); const defaultMethod = await CoreLoginHelper.getDefaultLoginMethod();
if (currentSite?.isLoggedOut() && defaultMethod) { if (this.site.isLoggedOut() && defaultMethod) {
await defaultMethod.action(); await defaultMethod.action();
} }
} }
@ -59,25 +62,29 @@ export class CoreLoginMethodsComponent implements OnInit {
// Identity providers won't be shown if login on browser. // Identity providers won't be shown if login on browser.
if (!this.isBrowserSSO) { if (!this.isBrowserSSO) {
this.identityProviders = await CoreLoginHelper.getValidIdentityProvidersForSite( this.identityProviders = await CoreLoginHelper.getValidIdentityProvidersForSite(
CoreSitesFactory.makeUnauthenticatedSite(this.siteUrl, this.siteConfig), this.site ?? CoreSitesFactory.makeUnauthenticatedSite(this.siteUrl, this.siteConfig),
); );
} }
if (this.reconnect) { if (this.site) {
this.showScanQR = CoreLoginHelper.displayQRInSiteScreen(); this.showScanQR = CoreLoginHelper.displayQRInSiteScreen();
// The identity provider set in the site will be shown at the top.
const oAuthId = this.site.getOAuthId();
this.currentLoginProvider = CoreLoginHelper.findIdentityProvider(this.identityProviders, oAuthId);
} }
// If still false or credentials screen. // If still false or credentials screen.
if (!this.reconnect || !this.showScanQR) { if (!this.site || !this.showScanQR) {
this.showScanQR = await CoreLoginHelper.displayQRInCredentialsScreen(this.siteConfig.tool_mobile_qrcodetype); this.showScanQR = await CoreLoginHelper.displayQRInCredentialsScreen(this.siteConfig.tool_mobile_qrcodetype);
} }
} }
this.isReady.resolve();
} }
/** /**
* Show instructions and scan QR code. * Show instructions and scan QR code.
*
* @returns Promise resolved when done.
*/ */
async showInstructionsAndScanQR(): Promise<void> { async showInstructionsAndScanQR(): Promise<void> {
try { try {
@ -90,21 +97,33 @@ export class CoreLoginMethodsComponent implements OnInit {
} }
/** /**
* An OAuth button was clicked. * Get the current login, removing the identity provider from the list.
* *
* @param provider The provider that was clicked. * @returns Current login.
*/ */
async oauthClicked(provider: CoreSiteIdentityProvider): Promise<void> { async extractCurrentLogin(): Promise<CoreLoginMethodsCurrentLogin | undefined> {
const result = await CoreLoginHelper.openBrowserForOAuthLogin( await this.isReady;
this.siteUrl,
provider,
this.siteConfig?.launchurl,
this.redirectData,
);
if (!result) { if (!this.currentLoginProvider) {
CoreDomUtils.showErrorModal('Invalid data.'); return;
} }
// Remove the identity provider from the array.
this.identityProviders = this.identityProviders.filter((provider) =>
provider.url !== this.currentLoginProvider?.url);
const showOther = !!(this.showLoginForm || this.isBrowserSSO) &&
!!(this.loginMethods.length || this.identityProviders.length || this.showScanQR);
return {
provider: this.currentLoginProvider,
showOther,
};
} }
} }
export type CoreLoginMethodsCurrentLogin = {
provider: CoreSiteIdentityProvider;
showOther: boolean;
};

View File

@ -71,6 +71,7 @@
"missingfirstname": "Missing given name", "missingfirstname": "Missing given name",
"missinglastname": "Missing last name", "missinglastname": "Missing last name",
"mobileservicesnotenabled": "Mobile services are not enabled on the site.", "mobileservicesnotenabled": "Mobile services are not enabled on the site.",
"morewaystologin": "More ways to log in",
"mustconfirm": "You need to confirm your account", "mustconfirm": "You need to confirm your account",
"newaccount": "New account", "newaccount": "New account",
"notloggedin": "You need to be logged in.", "notloggedin": "You need to be logged in.",

View File

@ -19,6 +19,7 @@ import { CoreSharedModule } from '@/core/shared.module';
import { CoreLoginComponentsModule } from '@features/login/components/components.module'; import { CoreLoginComponentsModule } from '@features/login/components/components.module';
import { CoreLoginReconnectPage } from '@features/login/pages/reconnect/reconnect'; import { CoreLoginReconnectPage } from '@features/login/pages/reconnect/reconnect';
import { CoreSiteLogoComponent } from '@/core/components/site-logo/site-logo'; import { CoreSiteLogoComponent } from '@/core/components/site-logo/site-logo';
import { CoreLoginIdentityProviderComponent } from './components/identity-provider/identity-provider';
const routes: Routes = [ const routes: Routes = [
{ {
@ -33,6 +34,7 @@ const routes: Routes = [
CoreSharedModule, CoreSharedModule,
CoreLoginComponentsModule, CoreLoginComponentsModule,
CoreSiteLogoComponent, CoreSiteLogoComponent,
CoreLoginIdentityProviderComponent,
], ],
declarations: [ declarations: [
CoreLoginReconnectPage, CoreLoginReconnectPage,

View File

@ -51,41 +51,62 @@
</div> </div>
<div class="core-login-methods"> <div class="core-login-methods">
<form *ngIf="showLoginForm && !isBrowserSSO" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" @if (currentLogin && currentLogin.provider) {
#reconnectForm> <core-identity-provider [provider]="currentLogin.provider" [launchurl]="siteConfig?.launchurl" [redirectData]="redirectData"
<ion-item class="ion-margin-bottom" lines="inset"> [siteUrl]="site.siteUrl" />
<ion-input class="core-ioninput-password" name="password" type="password" @if (currentLogin.showOther) {
placeholder="{{ 'core.login.password' | translate }}" formControlName="password" [clearOnEdit]="false" <ion-accordion-group>
autocomplete="current-password" enterkeyhint="go" required="true" <ion-accordion toggleIconSlot="start">
[attr.aria-label]="'core.login.password' | translate"> <ion-item class="ion-text-wrap" slot="header">
<ion-input-password-toggle slot="end" showIcon="fas-eye" hideIcon="fas-eye-slash" /> <ion-label>
</ion-input> <p class="item-heading">{{ 'core.login.morewaystologin' | translate }}</p>
</ion-item> </ion-label>
<ion-button type="submit" expand="block" [disabled]="!credForm.valid" </ion-item>
class="ion-margin core-login-login-button ion-text-wrap">
{{ 'core.login.loginbutton' | translate }}
</ion-button>
<!-- Forgotten password option. --> <div slot="content">
<ion-button *ngIf="showForgottenPassword" expand="block" fill="clear" <ng-template *ngTemplateOutlet="loginMethods" />
class="core-login-forgotten-password core-button-as-link ion-text-wrap" (click)="forgottenPassword()"> </div>
{{ 'core.login.forgotaccount' | translate }} </ion-accordion>
</ion-button> </ion-accordion-group>
</form> }
} @else {
<ng-container *ngIf="isBrowserSSO"> <ng-template *ngTemplateOutlet="loginMethods" />
<ion-button expand="block" (click)="openBrowserSSO()" }
class="ion-margin core-login-login-inbrowser-button ion-text-wrap">
{{ 'core.login.loginbutton' | translate }}
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true" />
</ion-button>
<p class="text-center core-login-inbrowser">{{ 'core.openinbrowserdescription' | translate }}</p>
</ng-container>
<!-- Additional Login methods -->
<core-login-methods *ngIf="siteConfig" [siteConfig]="siteConfig" [reconnect]="true" [siteUrl]="site.siteUrl"
[redirectData]="redirectData" [showLoginForm]="showLoginForm" />
</div> </div>
</div> </div>
</core-loading> </core-loading>
</ion-content> </ion-content>
<ng-template #loginMethods>
<form *ngIf="showLoginForm && !isBrowserSSO" [formGroup]="credForm" (ngSubmit)="login($event)" class="core-login-form" #reconnectForm>
<ion-item class="ion-margin-bottom" lines="inset">
<ion-input class="core-ioninput-password" name="password" type="password" placeholder="{{ 'core.login.password' | translate }}"
formControlName="password" [clearOnEdit]="false" autocomplete="current-password" enterkeyhint="go" required="true"
[attr.aria-label]="'core.login.password' | translate">
<ion-input-password-toggle slot="end" showIcon="fas-eye" hideIcon="fas-eye-slash" />
</ion-input>
</ion-item>
<ion-button type="submit" expand="block" [disabled]="!credForm.valid" class="ion-margin core-login-login-button ion-text-wrap">
{{ 'core.login.loginbutton' | translate }}
</ion-button>
<!-- Forgotten password option. -->
<ion-button *ngIf="showForgottenPassword" expand="block" fill="clear"
class="core-login-forgotten-password core-button-as-link ion-text-wrap" (click)="forgottenPassword()">
{{ 'core.login.forgotaccount' | translate }}
</ion-button>
</form>
<ng-container *ngIf="isBrowserSSO">
<ion-button expand="block" (click)="openBrowserSSO()" class="ion-margin core-login-login-inbrowser-button ion-text-wrap">
{{ 'core.login.loginbutton' | translate }}
<ion-icon name="fas-up-right-from-square" slot="end" aria-hidden="true" />
</ion-button>
<p class="text-center core-login-inbrowser">{{ 'core.openinbrowserdescription' | translate }}</p>
</ng-container>
<!-- Additional Login methods -->
<core-login-methods *ngIf="siteConfig" [site]="site" [siteConfig]="siteConfig" [siteUrl]="site.siteUrl" [redirectData]="redirectData"
[showLoginForm]="showLoginForm" />
</ng-template>

View File

@ -34,6 +34,7 @@ import { CoreSitePublicConfigResponse } from '@classes/sites/unauthenticated-sit
import { ALWAYS_SHOW_LOGIN_FORM_CHANGED, FORGOTTEN_PASSWORD_FEATURE_NAME } from '@features/login/constants'; import { ALWAYS_SHOW_LOGIN_FORM_CHANGED, FORGOTTEN_PASSWORD_FEATURE_NAME } from '@features/login/constants';
import { CoreKeyboard } from '@singletons/keyboard'; import { CoreKeyboard } from '@singletons/keyboard';
import { CoreLoadings } from '@services/loadings'; import { CoreLoadings } from '@services/loadings';
import { CoreLoginMethodsComponent, CoreLoginMethodsCurrentLogin } from '@features/login/components/login-methods/login-methods';
/** /**
* Page to enter the user password to reconnect to a site. * Page to enter the user password to reconnect to a site.
@ -46,6 +47,17 @@ import { CoreLoadings } from '@services/loadings';
export class CoreLoginReconnectPage implements OnInit, OnDestroy { export class CoreLoginReconnectPage implements OnInit, OnDestroy {
@ViewChild('reconnectForm') formElement?: ElementRef; @ViewChild('reconnectForm') formElement?: ElementRef;
@ViewChild(CoreLoginMethodsComponent) set loginMethods(loginMethods: CoreLoginMethodsComponent) {
if (loginMethods && !this.currentLogin) {
loginMethods.extractCurrentLogin().then(login => {
this.currentLogin = login;
return;
}).catch(() => {
// Ignore errors.
});
}
}
credForm: FormGroup; credForm: FormGroup;
site!: CoreSite; site!: CoreSite;
@ -53,6 +65,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
showForgottenPassword = true; showForgottenPassword = true;
showUserAvatar = false; showUserAvatar = false;
isBrowserSSO = false; isBrowserSSO = false;
currentLogin?: CoreLoginMethodsCurrentLogin;
isLoggedOut: boolean; isLoggedOut: boolean;
siteId!: string; siteId!: string;
siteInfo?: CoreSiteBasicInfo; siteInfo?: CoreSiteBasicInfo;
@ -252,16 +265,21 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
const modal = await CoreLoadings.show(); const modal = await CoreLoadings.show();
const url = this.site.getURL();
try { try {
// Start the authentication process. // Start the authentication process.
const data = await CoreSites.getUserToken(this.site.getURL(), this.username, password); const data = await CoreSites.getUserToken(url, this.username, password);
await CoreSites.updateSiteToken(this.site.getURL(), this.username, data.token, data.privateToken); await CoreSites.updateSiteToken(url, this.username, data.token, data.privateToken);
CoreForms.triggerFormSubmittedEvent(this.formElement, true); CoreForms.triggerFormSubmittedEvent(this.formElement, true);
// Unset oAuthID if it's set.
await CoreSites.removeSiteOauthId(this.siteId);
// Update site info too. // Update site info too.
await CoreSites.updateSiteInfoByUrl(this.site.getURL(), this.username); await CoreSites.updateSiteInfoByUrl(url, this.username);
// Reset fields so the data is not in the view anymore. // Reset fields so the data is not in the view anymore.
this.credForm.controls['password'].reset(); this.credForm.controls['password'].reset();
@ -271,7 +289,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy {
params: this.redirectData, params: this.redirectData,
}); });
} catch (error) { } catch (error) {
CoreLoginHelper.treatUserTokenError(this.site.getURL(), error, this.username, password); CoreLoginHelper.treatUserTokenError(url, error, this.username, password);
if (error.loggedout) { if (error.loggedout) {
this.cancel(); this.cancel();

View File

@ -402,6 +402,21 @@ export class CoreLoginHelperProvider {
return validProviders; return validProviders;
} }
/**
* Finds an identity provider from a list of providers based on the given OAuth ID.
*
* @param providers Array of identity providers.
* @param oauthId The OAuth ID to match against the providers' URLs.
* @returns The identity provider that matches the given OAuth ID, or undefined if no match is found.
*/
findIdentityProvider(providers: CoreSiteIdentityProvider[], oauthId?: number): CoreSiteIdentityProvider | undefined {
if (!oauthId) {
return;
}
return providers.find(provider => Number(CoreUrl.extractUrlParams(provider.url).id) === oauthId);
}
/** /**
* Go to the page to add a new site. * Go to the page to add a new site.
* If a fixed URL is configured, go to credentials instead. * If a fixed URL is configured, go to credentials instead.

View File

@ -1632,6 +1632,23 @@ export class CoreSitesProvider {
await Promise.all(promises); await Promise.all(promises);
} }
/**
* Removes the OAuth ID for a given site.
*
* @param siteId The ID of the site to update.
*/
async removeSiteOauthId(siteId: string): Promise<void> {
const site = await this.getSite(siteId);
site.setOAuthId(undefined);
const newData: Partial<SiteDBEntry> = {
oauthId: null,
};
await this.sitesTable.update(newData, { id: siteId });
}
/** /**
* Updates a site's info. * Updates a site's info.
* *