All files / app/identity/delete-account delete-account.component.ts

100% Statements 28/28
100% Branches 4/4
100% Functions 4/4
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                                                                                            1x                     15x   15x       15x       15x   15x           15x       15x 15x     15x             1x 1x                 4x       4x 1x     3x 3x   3x 3x 3x 1x 1x   2x 2x     3x             2x 2x 2x      
import { AsyncPipe } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
  viewChild,
} from '@angular/core';
import type { ElementRef, Signal, WritableSignal } from '@angular/core';
import { deleteUser, EmailAuthProvider, reauthenticateWithCredential } from '@angular/fire/auth';
import type { User } from '@angular/fire/auth';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import type { FormControl, ValidationErrors } from '@angular/forms';
import { Router } from '@angular/router';
 
import { USER$ } from '@app/core/user.token';
import type { MaybeUser$ } from '@app/core/user.token';
import { SpinnerComponent } from '@app/shared/spinner/spinner.component';
 
import { AuthErrorMessagesComponent } from '../auth-error-messages/auth-error-messages.component';
import { getErrorCode } from '../error-code';
import { createPasswordControl, PASSWORDS } from '../identity-forms';
 
/** Deleting a Firebase User requires a recent authentication. */
type DeleteAccountFormGroup = FormGroup<{
  password: FormControl<string | null>;
}>;
 
/** Template reference to HTML dialog element. */
type DialogRef = ElementRef<HTMLDialogElement>;
 
/**
 * Two step process to delete a User's account in Firebase Authentication. First button opens a
 * dialog where the User enters their password and confirms account deletion.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    AsyncPipe,
    AuthErrorMessagesComponent,
    ReactiveFormsModule,
    SpinnerComponent,
  ],
  selector: 'app-delete-account',
  templateUrl: './delete-account.component.html',
})
export class DeleteAccountComponent {
  /** Errors from Firebase, displayed after the dialog is closed. */
  public readonly $errorCode: WritableSignal<string>;
  /** Errors specific to the password field. */
  public readonly $passwordCntrlErrors: Signal<ValidationErrors | undefined>;
  /** Aria-invalid attribute for the password field. */
  public readonly $passwordCntrlInvalid: Signal<boolean>;
  /** Toggle showing view and the spinner. */
  public readonly $showForm: WritableSignal<boolean>;
  public readonly deleteAccountForm: DeleteAccountFormGroup;
  /** Used in error message for password maximum length. */
  public readonly maxPasswordLength: number = PASSWORDS.maxLength;
  /** Used in error message for password minimum length. */
  public readonly minPasswordLength: number = PASSWORDS.minLength;
  public readonly passwordCntrl: FormControl<string | null>;
  public readonly user$: MaybeUser$;
 
  private readonly _$confirmDialog: Signal<DialogRef> = viewChild.required<DialogRef>('confirmDialog');
  private readonly _router: Router;
 
  constructor() {
    this._router = inject(Router);
 
    ({
      $errors: this.$passwordCntrlErrors,
      $invalid: this.$passwordCntrlInvalid,
      control: this.passwordCntrl,
    } = createPasswordControl());
 
    this.deleteAccountForm = new FormGroup({
      password: this.passwordCntrl,
    });
 
    this.$errorCode = signal<string>('');
    this.$showForm = signal<boolean>(true);
 
    // Not handling non-logged in users because the Route guards should.
    this.user$ = inject(USER$);
  }
 
  /**
   * Closes the HTML Dialog element without deleting the account.
   */
  public closeDialog(): void {
    const dialogEl = this._$confirmDialog();
    dialogEl.nativeElement.close();
  }
 
  /**
   * Re-authenticates the User using their password from the form, and then deletes the User in
   * Firebase Authentication.
   */
  public async deleteAcount(user: User): Promise<void> {
    // The dialog automatically closes on submit. event.preventDefault() and event.stopPropagation() do not prevent that.
    const { password } = this.deleteAccountForm.value;
 
    // Validators prevent email1 or password being falsy, but TypeScript doesn't know that.
    // Additionally, all users are expected to have an email address.
    if (this.deleteAccountForm.invalid || !password || !user.email) {
      throw new Error('Invalid form submitted');
    }
 
    this.$showForm.set(false);
    this.$errorCode.set(''); // Clear out any existing errors
 
    try {
      const emailCreds = EmailAuthProvider.credential(user.email, password);
      const credentials = await reauthenticateWithCredential(user, emailCreds);
      await deleteUser(credentials.user);
      await this._router.navigateByUrl('/');
    } catch (err: unknown) {
      const code = getErrorCode(err);
      this.$errorCode.set(code);
    }
 
    this.$showForm.set(true);
  }
 
  /**
   * Opens the HTML Dialog containing the delete account form.
   */
  public openDialog(): void {
    const dialogEl = this._$confirmDialog();
    this.$errorCode.set(''); // Clear out any existing errors
    dialogEl.nativeElement.showModal();
  }
}