diff --git a/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html b/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html index f8cc4052c3..c7ed6ca89d 100644 --- a/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html +++ b/projects/orcid-registry-ui/src/lib/components/auth-challenge/auth-challenge.component.html @@ -278,7 +278,7 @@
Customize the subform to preview different configurations:
Customize the dialog to preview different configurations:
- This component is a sub-form that must be placed inside a parent - [formGroup]. It manages the UI switching and validation logic - between the password field, 2FA code, and recovery code inputs. + This component is a MatDialog. It manages the UI + switching and validation logic between the password field, 2FA code, and + recovery code inputs. You must pass a parent [formGroup] into + it via the dialog data so it can attach its controls.
[formGroup]
To implement this component, use the code highlighted in yellow.
- Place the component inside your existing form. You can toggle the - visibility of the password or 2FA fields using the boolean inputs. -
<form [formGroup]="form" (ngSubmit)="save()"> - <!-- Other inputs... --> - - <app-auth-challenge - [showPasswordField]="true" - [showTwoFactorField]="true" - passwordControlName="password" - codeControlName="twoFactorCode" - recoveryControlName="twoFactorRecoveryCode" - [showAlert]="true"> - </app-auth-challenge> - -</form>
Initialize the controls in your parent component. Note that Validators.required for the 2FA fields is managed - automatically by the child component. + automatically by the dialog component based on the fields being shown.
Validators.required
this.form = this.fb.group({ - // ... other controls - password: ['', Validators.required], + // ... other controls like 'id' + password: [null, Validators.required], twoFactorCode: [null, [Validators.minLength(6), Validators.maxLength(6)]], twoFactorRecoveryCode: [null, [Validators.minLength(10), Validators.maxLength(10)]], });
- Use @ViewChild to access the component. This allows you to - delegate backend error mapping (invalid password, invalid codes) and focus - management. + Use the following method to open the dialog, listen for the verify button + click, trigger your backend request, and handle the final success/cancel + state. You can copy and paste this directly into your component.
@ViewChild
@ViewChild(AuthChallengeComponent) authChallengeComponent: AuthChallengeComponent; - -save() { - if (this.form.valid) { - this.service.update(this.form.value).subscribe(response => { - // Pass backend response to child to handle errors (password/2fa) or focus - this.authChallengeComponent?.processBackendResponse(response); - - // Used when we don't know if the user - // has 2fa enabled (e.g. signin) - if (response.twoFactorEnabled && !response.invalidPassword) { - this.twoFactorEnabled = true; - } + openAuthChallenge() { + // 1. Open the dialog + const dialogRef = this._matDialog.open<AuthChallengeComponent>( + AuthChallengeComponent, + { + data: { + parentForm: this.form, + actionDescription: 'unlink the alternate sign in account', + memberName: 'Google', + showTwoFactorField: this.twoFactorState, // true or false + } as AuthChallengeFormData, + } + ); + + // 2. Listen for the verify button click inside the dialog + dialogRef.componentInstance.submitAttempt + .pipe( + takeUntil(dialogRef.afterClosed()), + switchMap(() => this.myService.delete(this.form.value).pipe(first())) + ) + .subscribe({ + next: (response: any) => { + if (response.success) { + // Close the dialog and pass true for success + dialogRef.close(true); + } else { + // Pass backend response back to the dialog to display errors + dialogRef.componentInstance.loading = false; + dialogRef.componentInstance.processBackendResponse(response); + } + }, }); - } + + // 3. Listen for when the dialog actually closes + dialogRef.afterClosed().subscribe((success) => { + this.form.reset(); + + if (success) { + // Action completed successfully + this.success = true; + } else { + // User canceled or closed the dialog + this.cancel = true; + } + }); } - 4. Interface Definition + + 3. Interface Definitions The endpoint payload/response object needs to extend the - AuthChallenge interface/pojo. + AuthChallenge interface. The dialog + configuration uses the + AuthChallengeFormData interface. export interface AuthChallenge { + success?: boolean; invalidPassword?: boolean; invalidTwoFactorCode?: boolean; invalidTwoFactorRecoveryCode?: boolean; + password?: string; twoFactorCode?: string; twoFactorRecoveryCode?: string; twoFactorEnabled?: boolean; +} + +export interface AuthChallengeFormData { + actionDescription?: string; + showPasswordField?: boolean; + showTwoFactorField?: boolean; + codeControlName?: string; + recoveryControlName?: string; + passwordControlName?: string; + parentForm?: UntypedFormGroup; + memberName?: string; } - 5. Backend Implementation - - The backend validation consists of two steps: verifying the password and - validating the 2FA status. - - - 1. Password Check: First, retrieve the user profile and - validate the submitted password against the stored encrypted password. If - it does not match, set the invalidPassword flag and return - the form. - + 4. Backend Implementation - 2. 2FA Check: If the password is valid, use the - TwoFactorAuthenticationManager. If no codes are provided, it - will assume a two-step flow (enabling the twoFactorEnabled - flag). Otherwise, it validates the provided codes. + The backend validation consists of verifying the password and the 2FA + status. If both pass, set success to true. - ProfileEntity profile = profileEntityCacheManager.retrieve(getCurrentUserOrcid()); + ProfileEntity profile = profileEntityCacheManager.retrieve(getCurrentUserOrcid()); -// 1. Validate Password +// 1. Validate Password if (form.getPassword() == null || !encryptionManager.hashMatches(form.getPassword(), profile.getEncryptedPassword())) { form.setInvalidPassword(true); return form; } -// 2. Validate 2FA -if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(orcid, form)) { +// 2. Validate 2FA +if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(getCurrentUserOrcid(), form)) { return form; -} +} + +// 3. Mark as successful +form.setSuccess(true); +return form;
openAuthChallenge() { + // 1. Open the dialog + const dialogRef = this._matDialog.open<AuthChallengeComponent>( + AuthChallengeComponent, + { + data: { + parentForm: this.form, + actionDescription: 'unlink the alternate sign in account', + memberName: 'Google', + showTwoFactorField: this.twoFactorState, // true or false + } as AuthChallengeFormData, + } + ); + + // 2. Listen for the verify button click inside the dialog + dialogRef.componentInstance.submitAttempt + .pipe( + takeUntil(dialogRef.afterClosed()), + switchMap(() => this.myService.delete(this.form.value).pipe(first())) + ) + .subscribe({ + next: (response: any) => { + if (response.success) { + // Close the dialog and pass true for success + dialogRef.close(true); + } else { + // Pass backend response back to the dialog to display errors + dialogRef.componentInstance.loading = false; + dialogRef.componentInstance.processBackendResponse(response); + } + }, }); - } + + // 3. Listen for when the dialog actually closes + dialogRef.afterClosed().subscribe((success) => { + this.form.reset(); + + if (success) { + // Action completed successfully + this.success = true; + } else { + // User canceled or closed the dialog + this.cancel = true; + } + }); }
The endpoint payload/response object needs to extend the - AuthChallenge interface/pojo. + AuthChallenge interface. The dialog + configuration uses the + AuthChallengeFormData interface.
AuthChallenge
AuthChallengeFormData
export interface AuthChallenge { + success?: boolean; invalidPassword?: boolean; invalidTwoFactorCode?: boolean; invalidTwoFactorRecoveryCode?: boolean; + password?: string; twoFactorCode?: string; twoFactorRecoveryCode?: string; twoFactorEnabled?: boolean; +} + +export interface AuthChallengeFormData { + actionDescription?: string; + showPasswordField?: boolean; + showTwoFactorField?: boolean; + codeControlName?: string; + recoveryControlName?: string; + passwordControlName?: string; + parentForm?: UntypedFormGroup; + memberName?: string; }
- The backend validation consists of two steps: verifying the password and - validating the 2FA status. -
- 1. Password Check: First, retrieve the user profile and - validate the submitted password against the stored encrypted password. If - it does not match, set the invalidPassword flag and return - the form. -
invalidPassword
- 2. 2FA Check: If the password is valid, use the - TwoFactorAuthenticationManager. If no codes are provided, it - will assume a two-step flow (enabling the twoFactorEnabled - flag). Otherwise, it validates the provided codes. + The backend validation consists of verifying the password and the 2FA + status. If both pass, set success to true.
TwoFactorAuthenticationManager
twoFactorEnabled
success
ProfileEntity profile = profileEntityCacheManager.retrieve(getCurrentUserOrcid()); + ProfileEntity profile = profileEntityCacheManager.retrieve(getCurrentUserOrcid()); -// 1. Validate Password +// 1. Validate Password if (form.getPassword() == null || !encryptionManager.hashMatches(form.getPassword(), profile.getEncryptedPassword())) { form.setInvalidPassword(true); return form; } -// 2. Validate 2FA -if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(orcid, form)) { +// 2. Validate 2FA +if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(getCurrentUserOrcid(), form)) { return form; -} +} + +// 3. Mark as successful +form.setSuccess(true); +return form;
ProfileEntity profile = profileEntityCacheManager.retrieve(getCurrentUserOrcid()); -// 1. Validate Password +// 1. Validate Password if (form.getPassword() == null || !encryptionManager.hashMatches(form.getPassword(), profile.getEncryptedPassword())) { form.setInvalidPassword(true); return form; } -// 2. Validate 2FA -if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(orcid, form)) { +// 2. Validate 2FA +if (!twoFactorAuthenticationManager.validateTwoFactorAuthForm(getCurrentUserOrcid(), form)) { return form; -}
passwordControlName
string
'passwordControl'
parentForm
UntypedFormGroup
codeControlName
'twoFactorCodeControl'
actionDescription
'unlink the alternate sign in account'
recoveryControlName
'twoFactorRecoveryCodeControl'
memberName
'Google'
showPasswordField
showTwoFactorField
boolean
false
true
showAlert
'password'
showHelpText
'twoFactorCode'
'twoFactorRecoveryCode'
You can sign into ORCID using any of the institutional accounts you have linked to your ORCID record. diff --git a/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.ts b/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.ts index 673f98334b..b069ebaf8f 100644 --- a/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.ts +++ b/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.ts @@ -1,18 +1,25 @@ import { Component, EventEmitter, + Input, OnDestroy, OnInit, Output, } from '@angular/core' import { MatDialog } from '@angular/material/dialog' -import { Observable, Subject } from 'rxjs' -import { takeUntil, tap } from 'rxjs/operators' +import { BehaviorSubject, Observable, Subject } from 'rxjs' +import { first, switchMap, takeUntil, tap } from 'rxjs/operators' import { PlatformInfoService } from 'src/app/cdk/platform-info' import { AccountSecurityAlternateSignInService } from 'src/app/core/account-security-alternate-sign-in/account-security-alternate-sign-in.service' import { SocialAccount } from 'src/app/types/account-alternate-sign-in.endpoint' +import { AuthChallengeComponent } from '@orcid/registry-ui' -import { DialogSecurityAlternateAccountDeleteComponent } from '../dialog-security-alternate-account-delete/dialog-security-alternate-account-delete.component' +import { AuthChallengeFormData } from '../../../types/common.endpoint' +import { + UntypedFormBuilder, + UntypedFormGroup, + Validators, +} from '@angular/forms' @Component({ selector: 'app-settings-security-alternate-sign-in', @@ -23,25 +30,41 @@ import { DialogSecurityAlternateAccountDeleteComponent } from '../dialog-securit export class SettingsSecurityAlternateSignInComponent implements OnInit, OnDestroy { + @Input() twoFactorState: boolean @Output() loading = new EventEmitter() accounts$: Observable + form: UntypedFormGroup + success = false + cancel = false + deletedAccount = '' displayedColumns = ['provider', 'email', 'granted', 'actions'] $destroy = new Subject() isMobile: any + refreshAccounts$ = new BehaviorSubject(undefined) + authChallengeLabel = $localize`:@@accountSettings.security.unlinkTheAlternateSignInAccount:unlink the alternate sign in account` constructor( private _accountSettingAlternate: AccountSecurityAlternateSignInService, + private _fb: UntypedFormBuilder, private _matDialog: MatDialog, private _platform: PlatformInfoService ) {} ngOnInit(): void { - this.loading.next(true) - this.accounts$ = this._accountSettingAlternate.get().pipe( - tap(() => { - this.loading.next(false) - }) + this.form = this._fb.group({ + id: [null, Validators.required], + password: [null, Validators.required], + twoFactorCode: [null, [Validators.minLength(6), Validators.maxLength(6)]], + twoFactorRecoveryCode: [ + null, + [Validators.minLength(10), Validators.maxLength(10)], + ], + }) + this.accounts$ = this.refreshAccounts$.pipe( + tap(() => this.loading.next(true)), + switchMap(() => this._accountSettingAlternate.get()), + tap(() => this.loading.next(false)) ) this._platform .get() @@ -50,20 +73,57 @@ export class SettingsSecurityAlternateSignInComponent this.isMobile = platform.columns4 || platform.columns8 }) } - delete(deleteAcc: SocialAccount) { - this.loading.next(true) - this._matDialog - .open(DialogSecurityAlternateAccountDeleteComponent, { data: deleteAcc }) - .afterClosed() - .subscribe((value) => { - this.loading.next(false) - if (value) { - this._accountSettingAlternate.delete(deleteAcc.id).subscribe(() => { - this.accounts$ = this._accountSettingAlternate.get() - }) - } + openAuthChallenge(memberName: string) { + const dialogRef = this._matDialog.open( + AuthChallengeComponent, + { + data: { + parentForm: this.form, + actionDescription: this.authChallengeLabel, + memberName: memberName, + showTwoFactorField: this.twoFactorState, + } as AuthChallengeFormData, + } + ) + + dialogRef.componentInstance.submitAttempt + .pipe( + takeUntil(dialogRef.afterClosed()), + switchMap(() => + this._accountSettingAlternate.delete(this.form.value).pipe(first()) + ) + ) + .subscribe({ + next: (response: any) => { + if (response.success) { + this.refreshAccounts$.next() + dialogRef.close(true) + } else { + dialogRef.componentInstance.loading = false + dialogRef.componentInstance.processBackendResponse(response) + } + }, }) + + dialogRef.afterClosed().subscribe((success) => { + this.deletedAccount = memberName + this.form.reset() + if (success) { + this.success = true + } else { + this.cancel = true + } + }) + } + + delete(deleteAcc: SocialAccount) { + this.success = false + this.cancel = false + this.form.patchValue({ + id: deleteAcc.id, + }) + this.openAuthChallenge(deleteAcc.idpName) } ngOnDestroy(): void { this.$destroy.next() diff --git a/src/app/account-settings/components/settings-security/settings-security.component.html b/src/app/account-settings/components/settings-security/settings-security.component.html index b5b6c3422b..6788378e25 100644 --- a/src/app/account-settings/components/settings-security/settings-security.component.html +++ b/src/app/account-settings/components/settings-security/settings-security.component.html @@ -39,6 +39,7 @@ Security [loading]="settingSecurityAlternateAccountsLoading" > diff --git a/src/app/core/account-security-alternate-sign-in/account-security-alternate-sign-in.service.ts b/src/app/core/account-security-alternate-sign-in/account-security-alternate-sign-in.service.ts index bd4649d280..3cf928f0e2 100644 --- a/src/app/core/account-security-alternate-sign-in/account-security-alternate-sign-in.service.ts +++ b/src/app/core/account-security-alternate-sign-in/account-security-alternate-sign-in.service.ts @@ -5,7 +5,7 @@ import { catchError, map, retry, switchMap } from 'rxjs/operators' import { EMAIL_REGEXP } from 'src/app/constants' import { SocialAccount, - SocialAccountDeleteResponse, + SocialAccountDeleteData, SocialAccountId, } from 'src/app/types/account-alternate-sign-in.endpoint' import { Institutional } from 'src/app/types/institutional.endpoint' @@ -65,11 +65,11 @@ export class AccountSecurityAlternateSignInService { return account.displayname } } - delete(idToManage: SocialAccountId): Observable { + delete(data: SocialAccountDeleteData): Observable { return this._http - .post( + .post( runtimeEnvironment.API_WEB + `account/revokeSocialAccount.json`, - { idToManage }, + data, { headers: this.headers, } diff --git a/src/app/types/account-alternate-sign-in.endpoint.ts b/src/app/types/account-alternate-sign-in.endpoint.ts index 36ee47cbe5..796834e66f 100644 --- a/src/app/types/account-alternate-sign-in.endpoint.ts +++ b/src/app/types/account-alternate-sign-in.endpoint.ts @@ -1,3 +1,5 @@ +import { AuthChallenge } from './common.endpoint' + export interface SocialAccount { dateCreated: number lastModified: number @@ -26,7 +28,7 @@ export interface SocialAccountId { provideruserid: string } -export interface SocialAccountDeleteResponse { +export interface SocialAccountDeleteData extends AuthChallenge { errors?: any[] password?: string idToManage: SocialAccountId diff --git a/src/app/types/common.endpoint.ts b/src/app/types/common.endpoint.ts index a4e8167afe..9ff79af0e7 100644 --- a/src/app/types/common.endpoint.ts +++ b/src/app/types/common.endpoint.ts @@ -259,6 +259,7 @@ export interface DeactivationEndpoint { deactivationSuccessful?: boolean } export interface AuthChallenge { + success?: boolean invalidPassword?: boolean invalidTwoFactorCode?: boolean invalidTwoFactorRecoveryCode?: boolean