Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react/auth): add useApplyActionCodeMutation #152

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// useMultiFactorUserUnenrollMutation (MultiFactorUser)
// useMultiFactorUserGetSessionMutation (MultiFactorUser)
// useMultiFactorResolverResolveSignInMutation (MultiFactorResolver)
// useApplyActionCodeMutation
export { useApplyActionCodeMutation } from "./useApplyActionCodeMutation";
// useCheckActionCodeMutation
// useConfirmPasswordResetMutation
// useCreateUserWithEmailAndPasswordMutation
Expand Down
118 changes: 118 additions & 0 deletions packages/react/src/auth/useApplyActionCodeMutation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import {
createUserWithEmailAndPassword,
sendEmailVerification,
} from "firebase/auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { auth, expectFirebaseError, wipeAuth } from "~/testing-utils";
import { useApplyActionCodeMutation } from "./useApplyActionCodeMutation";
import { waitForVerificationCode } from "./utils";
import { queryClient, wrapper } from "../../utils";

describe("useApplyActionCodeMutation", () => {
const email = "[email protected]";
const password = "TanstackQueryFirebase#123";

beforeEach(async () => {
queryClient.clear();
await wipeAuth();
await createUserWithEmailAndPassword(auth, email, password);
});

afterEach(async () => {
vi.clearAllMocks();
await auth.signOut();
});

test("successfully applies email verification action code", async () => {
await sendEmailVerification(auth.currentUser!);
const oobCode = await waitForVerificationCode(email);

const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
wrapper,
});

await act(async () => {
await result.current.mutateAsync(oobCode!);
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
});

test("handles invalid action code", async () => {
const invalidCode = "invalid-action-code";

const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
wrapper,
});

await act(async () => {
try {
await result.current.mutateAsync(invalidCode);
} catch (error) {
expectFirebaseError(error, "auth/invalid-action-code");
}
});

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
expectFirebaseError(result.current.error, "auth/invalid-action-code");
});

test("handles empty action code", async () => {
const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
wrapper,
});

await act(async () => {
try {
await result.current.mutateAsync("");
} catch (error) {
expectFirebaseError(error, "auth/invalid-req-type");
}
});

await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
expectFirebaseError(result.current.error, "auth/invalid-req-type");
});

test("executes onSuccess callback", async () => {
await sendEmailVerification(auth.currentUser!);
const oobCode = await waitForVerificationCode(email);
const onSuccess = vi.fn();

const { result } = renderHook(
() => useApplyActionCodeMutation(auth, { onSuccess }),
{ wrapper }
);

await act(async () => {
await result.current.mutateAsync(oobCode!);
});

await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});

test("executes onError callback", async () => {
const invalidCode = "invalid-action-code";
const onError = vi.fn();

const { result } = renderHook(
() => useApplyActionCodeMutation(auth, { onError }),
{ wrapper }
);

await act(async () => {
try {
await result.current.mutateAsync(invalidCode);
} catch (error) {
expectFirebaseError(error, "auth/invalid-action-code");
}
});

await waitFor(() => expect(onError).toHaveBeenCalled());
expect(onError.mock.calls[0][0]).toBeDefined();
expectFirebaseError(result.current.error, "auth/invalid-action-code");
});
});
20 changes: 20 additions & 0 deletions packages/react/src/auth/useApplyActionCodeMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type UseMutationOptions, useMutation } from "@tanstack/react-query";
import { type Auth, type AuthError, applyActionCode } from "firebase/auth";

type AuthUseMutationOptions<
TData = unknown,
TError = Error,
TVariables = void
> = Omit<UseMutationOptions<TData, TError, TVariables>, "mutationFn">;

export function useApplyActionCodeMutation(
auth: Auth,
options?: AuthUseMutationOptions<void, AuthError, string>
) {
return useMutation<void, AuthError, string>({
...options,
mutationFn: (oobCode) => {
return applyActionCode(auth, oobCode);
},
});
}
86 changes: 58 additions & 28 deletions packages/react/src/auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,30 @@ import fs from "node:fs";
import path from "node:path";

/**
* Reads the Firebase emulator debug log and extracts the password reset code
* @param email The email address for which the password reset was requested
* @returns The password reset code (oobCode) or null if not found
* Reads the Firebase emulator debug log and extracts a specific code from the logs.
* @param email The email address for which the code was requested.
* @param logPattern A regular expression pattern to match the log entry.
* @param extractCodeFn A function to extract the code from the relevant log line.
* @returns The extracted code or null if not found.
*/
async function getPasswordResetCodeFromLogs(
async function getCodeFromLogs(
email: string,
logPattern: RegExp,
extractCodeFn: (line: string) => string | null
): Promise<string | null> {
try {
// Read the firebase-debug.log file
const logPath = path.join(process.cwd(), "firebase-debug.log");
const logContent = await fs.promises.readFile(logPath, "utf8");

// Find the most recent password reset link for the given email
// Reverse lines to start with the most recent logs
const lines = logContent.split("\n").reverse();
const resetLinkPattern = new RegExp(
`To reset the password for ${email.replace(
".",
"\\.",
)}.*?http://127\\.0\\.0\\.1:9099.*`,
"i",
);

for (const line of lines) {
const match = line.match(resetLinkPattern);
if (match) {
// Extract oobCode from the reset link
const url = match[0].match(/http:\/\/127\.0\.0\.1:9099\/.*?$/)?.[0];
if (url) {
const oobCode = new URL(url).searchParams.get("oobCode");
return oobCode;
if (logPattern.test(line)) {
const code = extractCodeFn(line);
if (code) {
return code;
}
}
}
Expand All @@ -44,21 +38,25 @@ async function getPasswordResetCodeFromLogs(
}

/**
* Waits for the password reset code to appear in the logs
* @param email The email address for which the password reset was requested
* @param timeout Maximum time to wait in milliseconds
* @param interval Interval between checks in milliseconds
* @returns The password reset code or null if timeout is reached
* Waits for a specific code to appear in the logs.
* @param email The email address for which the code was requested.
* @param logPattern A regular expression pattern to match the log entry.
* @param extractCodeFn A function to extract the code from the relevant log line.
* @param timeout Maximum time to wait in milliseconds.
* @param interval Interval between checks in milliseconds.
* @returns The extracted code or null if timeout is reached.
*/
async function waitForPasswordResetCode(
async function waitForCode(
email: string,
logPattern: RegExp,
extractCodeFn: (line: string) => string | null,
timeout = 5000,
interval = 100,
interval = 100
): Promise<string | null> {
const startTime = Date.now();

while (Date.now() - startTime < timeout) {
const code = await getPasswordResetCodeFromLogs(email);
const code = await getCodeFromLogs(email, logPattern, extractCodeFn);
if (code) {
return code;
}
Expand All @@ -68,4 +66,36 @@ async function waitForPasswordResetCode(
return null;
}

export { getPasswordResetCodeFromLogs, waitForPasswordResetCode };
/**
* Extracts the oobCode from a log line.
* @param line The log line containing the oobCode link.
* @returns The oobCode or null if not found.
*/
function extractOobCode(line: string): string | null {
const url = line.match(/http:\/\/127\.0\.0\.1:9099\/emulator\/action\?.*?$/)?.[0];
return url ? new URL(url).searchParams.get("oobCode") : null;
}

export async function waitForPasswordResetCode(
email: string,
timeout = 5000,
interval = 100
): Promise<string | null> {
const logPattern = new RegExp(
`To reset the password for ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`,
"i"
);
return waitForCode(email, logPattern, extractOobCode, timeout, interval);
}

export async function waitForVerificationCode(
email: string,
timeout = 5000,
interval = 100
): Promise<string | null> {
const logPattern = new RegExp(
`To verify the email address ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`,
"i"
);
return waitForCode(email, logPattern, extractOobCode, timeout, interval);
}
Loading