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 @@

- - + + Action Description + + + + + Member name (trailing bold text) + + + +
+ + + +
-
- -
+

- 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.

To implement this component, use the code highlighted in yellow.

-

1. Template

-

- 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>
- -

2. Form Configuration

+

1. Form Configuration

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.

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)]],
 });
-

3. Handling Submit & Responses

+

2. Opening and Handling the Dialog

- 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(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;
  • - passwordControlName: - string. The name of the form control for the password - input. (default 'passwordControl') + parentForm: + UntypedFormGroup. (Required) The parent + form group containing the controls.
  • - codeControlName: - string. The name of the form control for the 6-digit - authentication code. (default 'twoFactorCodeControl') + actionDescription: + string. The contextual text for what the user is doing + (e.g. 'unlink the alternate sign in account').
  • - recoveryControlName: - string. The name of the form control for the 10-character - recovery code. (default 'twoFactorRecoveryCodeControl') + memberName: string. + The target of the action to be bolded (e.g. 'Google').
  • showPasswordField: @@ -177,17 +203,22 @@

    5. Backend Implementation

  • showTwoFactorField: boolean. Whether to display the 2FA/Recovery input fields. - (default false) + (default true)
  • - showAlert: boolean. - Whether to display the notice alert indicating 2FA is active or password - verification is needed. (default false) + passwordControlName: + string. Custom form control name. (default + 'password')
  • - showHelpText: - boolean. Whether to display the helper links (e.g. "Use a - recovery code instead") below the inputs. (default true) + codeControlName: + string. Custom form control name. (default + 'twoFactorCode') +
  • +
  • + recoveryControlName: + string. Custom form control name. (default + 'twoFactorRecoveryCode')
diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.scss b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.scss index e6cf071bd7..1a1b977e3f 100644 --- a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.scss +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.scss @@ -3,10 +3,6 @@ section { gap: 1.25rem; } -app-two-factor-auth-from { - margin-top: 0.5rem; -} - pre { background: #1e1e1e; color: #fff; @@ -39,7 +35,3 @@ pre { font-size: 12px; } } - -form { - font-size: 14px; -} diff --git a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.ts b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.ts index 6035f306bf..ae9ed30320 100644 --- a/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.ts +++ b/projects/orcid-ui-docs/src/app/pages/orcid-registry-ui/auth-challenge-page.component.ts @@ -16,6 +16,8 @@ import '@angular/localize/init' import { MatCheckboxModule } from '@angular/material/checkbox' import { DocumentationPageComponent } from '../../components/documentation-page/documentation-page.component' import { MatDialog } from '@angular/material/dialog' +import { MatButtonModule } from '@angular/material/button' +import { takeUntil } from 'rxjs/operators' @Component({ selector: 'auth-challenge-page', @@ -25,10 +27,10 @@ import { MatDialog } from '@angular/material/dialog' FormsModule, MatSelectModule, MatFormFieldModule, + MatButtonModule, MatInputModule, MatCheckboxModule, MatIconModule, - AuthChallengeComponent, DocumentationPageComponent, ReactiveFormsModule, ], @@ -37,11 +39,10 @@ import { MatDialog } from '@angular/material/dialog' }) export class AuthChallengePageComponent implements OnInit { showPasswordField = true - showTwoFactorField = false - actionDescription: String - memberName: String + showTwoFactorField = true + actionDescription = 'perform this action on' + memberName = 'Example Account' form: UntypedFormGroup - data: any constructor(private _fb: UntypedFormBuilder, private _dialog: MatDialog) {} @@ -52,18 +53,38 @@ export class AuthChallengePageComponent implements OnInit { null, [Validators.minLength(10), Validators.maxLength(10)], ], - password: ['', Validators.required], + password: [null, Validators.required], }) } openDialog() { const dialogRef = this._dialog.open(AuthChallengeComponent, { data: { + parentForm: this.form, actionDescription: this.actionDescription, memberName: this.memberName, showPasswordField: this.showPasswordField, showTwoFactorField: this.showTwoFactorField, }, }) + + // Mock the submission to show developers how the error handling works! + dialogRef.componentInstance.submitAttempt + .pipe(takeUntil(dialogRef.afterClosed())) + .subscribe(() => { + // Simulate a 1-second backend delay + setTimeout(() => { + // Fake a backend response complaining about a bad password + dialogRef.componentInstance.processBackendResponse({ + success: false, + invalidPassword: true, + invalidTwoFactorCode: false, + }) + }, 1000) + }) + + dialogRef.afterClosed().subscribe(() => { + this.form.reset() + }) } } diff --git a/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.html b/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.html index 61930c7cc8..a787ac0d2b 100644 --- a/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.html +++ b/src/app/account-settings/components/settings-security-alternate-sign-in/settings-security-alternate-sign-in.component.html @@ -1,4 +1,23 @@ + @if (success) { + +
+ The alternate sign in account {{ + deletedAccount + }} has been unlinked +
+
+ } @if (cancel) { + +
+ The alternate sign in account {{ + deletedAccount + }} has not been unlinked +
+
+ }

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