All files / app/identity/change-email change-email.component.ts

100% Statements 24/24
100% Branches 5/5
100% Functions 2/2
100% Lines 24/24

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                                                                                            1x                                             24x   24x         24x 24x 24x   24x                 24x   24x 24x     24x                 4x       4x 1x     3x 3x   3x 3x 3x 2x 1x   2x 2x 2x        
/* eslint-disable import-x/max-dependencies -- 11 dependencies */
import { AsyncPipe } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
} from '@angular/core';
import type { Signal, WritableSignal } from '@angular/core';
import { EmailAuthProvider, reauthenticateWithCredential, verifyBeforeUpdateEmail } 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 { 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 type { SendVerifyEmailStatuses } from '../confirm-email/send-confirm-email';
import { getErrorCode } from '../error-code';
import { createEmailControl, createPasswordControl, PASSWORDS } from '../identity-forms';
import { confirmMatch, confirmMatchFormErrors } from '../validators/confirm-match';
 
/** Collects the new email with confirmation and the current password. */
type ChangeEmailFormGroup = FormGroup<{
  email1: FormControl<string | null>;
  email2: FormControl<string | null>;
  password: FormControl<string | null>;
}>;
 
/**
 * Form for user to change their email address with Firebase Authentication. The new email address
 * must be verified by clicking a link and applying an oobCode before it actually updates.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    AsyncPipe,
    AuthErrorMessagesComponent,
    ReactiveFormsModule,
    SpinnerComponent,
  ],
  selector: 'app-change-email',
  templateUrl: './change-email.component.html',
})
export class ChangeEmailComponent {
  /** Errors specific to the first new email field. */
  public readonly $email1CntrlErrors: Signal<ValidationErrors | undefined>;
  /** Aria-invalid attribute for the first new email field. */
  public readonly $email1CntrlInvalid: Signal<boolean>;
  /** Errors specific to the second new email field. */
  public readonly $email2CntrlErrors: Signal<ValidationErrors | undefined>;
  /** Aria-invalid attribute for the second new email field. */
  public readonly $email2CntrlInvalid: Signal<boolean>;
  /** Firebase response error code. */
  public readonly $errorCode: WritableSignal<string>;
  /** Aria-invalid attribute for the form validation. */
  public readonly $formEmailsInvalid: Signal<boolean>;
  /** Errors specific to the current password field. */
  public readonly $passwordCntrlErrors: Signal<ValidationErrors | undefined>;
  /** Aria-invalid attribute for the current password field. */
  public readonly $passwordCntrlInvalid: Signal<boolean>;
  /** Cycles through the process of sending a verification email to set the new email address for the User. */
  public readonly $verificationStatus: WritableSignal<SendVerifyEmailStatuses>;
  public readonly changeEmailForm: ChangeEmailFormGroup;
  public readonly email1Cntrl: FormControl<string | null>;
  public readonly email2Cntrl: FormControl<string | null>;
  /** 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$;
 
  constructor() {
    ({ $errors: this.$email1CntrlErrors, $invalid: this.$email1CntrlInvalid, control: this.email1Cntrl } = createEmailControl());
    ({ $errors: this.$email2CntrlErrors, $invalid: this.$email2CntrlInvalid, control: this.email2Cntrl } = createEmailControl());
    ({ $errors: this.$passwordCntrlErrors, $invalid: this.$passwordCntrlInvalid, control: this.passwordCntrl } = createPasswordControl());
 
    this.changeEmailForm = new FormGroup(
      {
        email1: this.email1Cntrl,
        email2: this.email2Cntrl,
        password: this.passwordCntrl,
      },
      confirmMatch('email1', 'email2'),
    );
 
    this.$formEmailsInvalid = confirmMatchFormErrors(this.changeEmailForm, this.email1Cntrl, this.email2Cntrl);
 
    this.$errorCode = signal<string>('');
    this.$verificationStatus = signal<SendVerifyEmailStatuses>('unsent');
 
    // Not handling non-logged in users because the Route guards should.
    this.user$ = inject(USER$);
  }
 
  /**
   * Re-authenticates the current user using the submitted password then sends an email to the new
   * address to confirm ownership. Clicking the link in the email will apply the oobCode using
   * VerifyEmailComponent.
   */
  public async onSubmit(user: User): Promise<void> {
    const { email1, password } = this.changeEmailForm.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.changeEmailForm.invalid || !email1 || !password || !user.email) {
      throw new Error('Invalid form submitted');
    }
 
    this.$errorCode.set(''); // Clear out any existing errors
    this.$verificationStatus.set('sending');
 
    try {
      const emailCreds = EmailAuthProvider.credential(user.email, password);
      const credentials = await reauthenticateWithCredential(user, emailCreds);
      await verifyBeforeUpdateEmail(credentials.user, email1);
      this.$verificationStatus.set('sent');
    } catch (err: unknown) {
      const code = getErrorCode(err);
      this.$errorCode.set(code);
      this.$verificationStatus.set('unsent');
    }
  }
}