All files / app/identity/recover-email recover-email.service.ts

91.3% Statements 21/23
57.14% Branches 4/7
100% Functions 7/7
90.9% Lines 20/22

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                                                                      1x           1x 3x                   3x 3x     1x     1x 1x       2x   2x                               3x 2x 1x   1x         1x     1x                 1x 1x 1x 1x                
import { inject, Injectable } from '@angular/core';
import {
  applyActionCode,
  Auth,
  checkActionCode,
  sendPasswordResetEmail,
} from '@angular/fire/auth';
import {
  catchError,
  delayWhen,
  of,
  switchMap,
  timer,
} from 'rxjs';
import type { Observable } from 'rxjs';
 
import { getErrorCode } from '../error-code';
 
/** Combined model of email recovery results and sending password reset. */
export interface RecoverEmailResults extends ApplyResult {
  /** Firebase response error code, if any. */
  readonly errorCode?: string;
  /** Indicates if the password reset email was sent on succesful email recovery. */
  readonly passwordResetSent: boolean;
}
 
/** Results of email recovery. */
interface ApplyResult {
  /** User original email address to be recovered, from Firebase oobCode. */
  readonly restoredEmail: string | undefined;
  /** Results of applying the oobCode to recover the account's original email address. */
  readonly successful: boolean;
}
 
/** Sending the password reset email needs to wait until Firebase recognizes the email recovery. Milliseconds */
export const SEND_EMAIL_DELAY = 500;
 
/**
 * Handles both recovering email oobCodes and sending the password reset email afterwards.
 */
@Injectable({ providedIn: 'root' })
export class RecoverEmailService {
  private readonly _auth: Auth = inject(Auth);
 
  /**
   * Creates and Observable that when subscribed to will apply the action code to restore the user's
   * original email address. And if present will automatically send a password reset email to the
   * restored address in case of account compromise.
   *
   * @param delay - Not for production use! Only for use with testing.
   */
  public recoverEmail$(code: string | undefined, delay: number = SEND_EMAIL_DELAY): Observable<RecoverEmailResults> {
    return of(code).pipe(
      switchMap(async (oobCode: string | undefined): Promise<ApplyResult> => this._doActionCode(oobCode)),
      // Unfortunately it can take time for Firebase to recognize that the email has been restored
      // so we can send the password reset email.
      delayWhen((result: ApplyResult): Observable<number> => timer(result.restoredEmail ? delay : 0)),
      // Give the user the option to reset their password in case the account was compromised:
      switchMap(async (result: ApplyResult): Promise<RecoverEmailResults> => {
        const passwordResetSent = await this._sendPasswordResetEmail(result.restoredEmail);
        return { ...result, passwordResetSent };
      }),
      // Using `err` here trips promise/prefer-await-to-callbacks, but other names don't
      catchError((problem: unknown): Observable<RecoverEmailResults> => {
        console.error('RecoverEmailService', problem);
 
        return of({
          errorCode: getErrorCode(problem),
          passwordResetSent: false,
          restoredEmail: undefined,
          successful: false,
        });
      }),
    );
  }
 
  /**
   * Check that the oobCode is still valid, and then apply it.
   * @returns the restored email address and a success flag.
   * @throws Error if the oobCode is falsy or the firebase methods fail.
   */
  private async _doActionCode(oobCode: string | undefined): Promise<ApplyResult> {
    if (oobCode) {
      const info = await checkActionCode(this._auth, oobCode);
      const { email: restoredEmail } = info.data;
 
      await applyActionCode(this._auth, oobCode);
      // Account email reverted to restoredEmail
 
      // Problem with being pedantic with all types except undefined vs null is that sometimes you
      // need to get rid of null from the type.
      return { restoredEmail: restoredEmail ?? undefined, successful: true };
    }
 
    throw new Error('oobCode not found');
  }
 
  /**
   * Firebase types indicate that the email may not always be returned (Accounts without email addresses?)
   * If the email isn't truthy then just skip the reset.
   * If the send email fails for some reason, just return false.
   */
  private async _sendPasswordResetEmail(restoredEmail: string | undefined): Promise<boolean> {
    if (restoredEmail) {
      try {
        await sendPasswordResetEmail(this._auth, restoredEmail);
        return true;
      } catch (err: unknown) {
        console.error('RecoverEmailService#_sendPasswordResetEmail', err);
      }
    }
    return false;
  }
}