Skip to content

Commit 4683a20

Browse files
authored
feat(react/auth): add useApplyActionCodeMutation (#152)
* feat(react/auth): add useApplyActionCodeMutation * _ * chore: export useApplyActionCodeMutation
1 parent ecb445f commit 4683a20

File tree

4 files changed

+197
-29
lines changed

4 files changed

+197
-29
lines changed

packages/react/src/auth/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// useMultiFactorUserUnenrollMutation (MultiFactorUser)
1010
// useMultiFactorUserGetSessionMutation (MultiFactorUser)
1111
// useMultiFactorResolverResolveSignInMutation (MultiFactorResolver)
12-
// useApplyActionCodeMutation
12+
export { useApplyActionCodeMutation } from "./useApplyActionCodeMutation";
1313
// useCheckActionCodeMutation
1414
// useConfirmPasswordResetMutation
1515
// useCreateUserWithEmailAndPasswordMutation
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { act, renderHook, waitFor } from "@testing-library/react";
2+
import {
3+
createUserWithEmailAndPassword,
4+
sendEmailVerification,
5+
} from "firebase/auth";
6+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
7+
import { auth, expectFirebaseError, wipeAuth } from "~/testing-utils";
8+
import { useApplyActionCodeMutation } from "./useApplyActionCodeMutation";
9+
import { waitForVerificationCode } from "./utils";
10+
import { queryClient, wrapper } from "../../utils";
11+
12+
describe("useApplyActionCodeMutation", () => {
13+
const email = "[email protected]";
14+
const password = "TanstackQueryFirebase#123";
15+
16+
beforeEach(async () => {
17+
queryClient.clear();
18+
await wipeAuth();
19+
await createUserWithEmailAndPassword(auth, email, password);
20+
});
21+
22+
afterEach(async () => {
23+
vi.clearAllMocks();
24+
await auth.signOut();
25+
});
26+
27+
test("successfully applies email verification action code", async () => {
28+
await sendEmailVerification(auth.currentUser!);
29+
const oobCode = await waitForVerificationCode(email);
30+
31+
const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
32+
wrapper,
33+
});
34+
35+
await act(async () => {
36+
await result.current.mutateAsync(oobCode!);
37+
});
38+
39+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
40+
});
41+
42+
test("handles invalid action code", async () => {
43+
const invalidCode = "invalid-action-code";
44+
45+
const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
46+
wrapper,
47+
});
48+
49+
await act(async () => {
50+
try {
51+
await result.current.mutateAsync(invalidCode);
52+
} catch (error) {
53+
expectFirebaseError(error, "auth/invalid-action-code");
54+
}
55+
});
56+
57+
await waitFor(() => expect(result.current.isError).toBe(true));
58+
expect(result.current.error).toBeDefined();
59+
expectFirebaseError(result.current.error, "auth/invalid-action-code");
60+
});
61+
62+
test("handles empty action code", async () => {
63+
const { result } = renderHook(() => useApplyActionCodeMutation(auth), {
64+
wrapper,
65+
});
66+
67+
await act(async () => {
68+
try {
69+
await result.current.mutateAsync("");
70+
} catch (error) {
71+
expectFirebaseError(error, "auth/invalid-req-type");
72+
}
73+
});
74+
75+
await waitFor(() => expect(result.current.isError).toBe(true));
76+
expect(result.current.error).toBeDefined();
77+
expectFirebaseError(result.current.error, "auth/invalid-req-type");
78+
});
79+
80+
test("executes onSuccess callback", async () => {
81+
await sendEmailVerification(auth.currentUser!);
82+
const oobCode = await waitForVerificationCode(email);
83+
const onSuccess = vi.fn();
84+
85+
const { result } = renderHook(
86+
() => useApplyActionCodeMutation(auth, { onSuccess }),
87+
{ wrapper }
88+
);
89+
90+
await act(async () => {
91+
await result.current.mutateAsync(oobCode!);
92+
});
93+
94+
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
95+
});
96+
97+
test("executes onError callback", async () => {
98+
const invalidCode = "invalid-action-code";
99+
const onError = vi.fn();
100+
101+
const { result } = renderHook(
102+
() => useApplyActionCodeMutation(auth, { onError }),
103+
{ wrapper }
104+
);
105+
106+
await act(async () => {
107+
try {
108+
await result.current.mutateAsync(invalidCode);
109+
} catch (error) {
110+
expectFirebaseError(error, "auth/invalid-action-code");
111+
}
112+
});
113+
114+
await waitFor(() => expect(onError).toHaveBeenCalled());
115+
expect(onError.mock.calls[0][0]).toBeDefined();
116+
expectFirebaseError(result.current.error, "auth/invalid-action-code");
117+
});
118+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type UseMutationOptions, useMutation } from "@tanstack/react-query";
2+
import { type Auth, type AuthError, applyActionCode } from "firebase/auth";
3+
4+
type AuthUseMutationOptions<
5+
TData = unknown,
6+
TError = Error,
7+
TVariables = void
8+
> = Omit<UseMutationOptions<TData, TError, TVariables>, "mutationFn">;
9+
10+
export function useApplyActionCodeMutation(
11+
auth: Auth,
12+
options?: AuthUseMutationOptions<void, AuthError, string>
13+
) {
14+
return useMutation<void, AuthError, string>({
15+
...options,
16+
mutationFn: (oobCode) => {
17+
return applyActionCode(auth, oobCode);
18+
},
19+
});
20+
}

packages/react/src/auth/utils.ts

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,30 @@ import fs from "node:fs";
22
import path from "node:path";
33

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

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

2724
for (const line of lines) {
28-
const match = line.match(resetLinkPattern);
29-
if (match) {
30-
// Extract oobCode from the reset link
31-
const url = match[0].match(/http:\/\/127\.0\.0\.1:9099\/.*?$/)?.[0];
32-
if (url) {
33-
const oobCode = new URL(url).searchParams.get("oobCode");
34-
return oobCode;
25+
if (logPattern.test(line)) {
26+
const code = extractCodeFn(line);
27+
if (code) {
28+
return code;
3529
}
3630
}
3731
}
@@ -44,21 +38,25 @@ async function getPasswordResetCodeFromLogs(
4438
}
4539

4640
/**
47-
* Waits for the password reset code to appear in the logs
48-
* @param email The email address for which the password reset was requested
49-
* @param timeout Maximum time to wait in milliseconds
50-
* @param interval Interval between checks in milliseconds
51-
* @returns The password reset code or null if timeout is reached
41+
* Waits for a specific code to appear in the logs.
42+
* @param email The email address for which the code was requested.
43+
* @param logPattern A regular expression pattern to match the log entry.
44+
* @param extractCodeFn A function to extract the code from the relevant log line.
45+
* @param timeout Maximum time to wait in milliseconds.
46+
* @param interval Interval between checks in milliseconds.
47+
* @returns The extracted code or null if timeout is reached.
5248
*/
53-
async function waitForPasswordResetCode(
49+
async function waitForCode(
5450
email: string,
51+
logPattern: RegExp,
52+
extractCodeFn: (line: string) => string | null,
5553
timeout = 5000,
56-
interval = 100,
54+
interval = 100
5755
): Promise<string | null> {
5856
const startTime = Date.now();
5957

6058
while (Date.now() - startTime < timeout) {
61-
const code = await getPasswordResetCodeFromLogs(email);
59+
const code = await getCodeFromLogs(email, logPattern, extractCodeFn);
6260
if (code) {
6361
return code;
6462
}
@@ -68,4 +66,36 @@ async function waitForPasswordResetCode(
6866
return null;
6967
}
7068

71-
export { getPasswordResetCodeFromLogs, waitForPasswordResetCode };
69+
/**
70+
* Extracts the oobCode from a log line.
71+
* @param line The log line containing the oobCode link.
72+
* @returns The oobCode or null if not found.
73+
*/
74+
function extractOobCode(line: string): string | null {
75+
const url = line.match(/http:\/\/127\.0\.0\.1:9099\/emulator\/action\?.*?$/)?.[0];
76+
return url ? new URL(url).searchParams.get("oobCode") : null;
77+
}
78+
79+
export async function waitForPasswordResetCode(
80+
email: string,
81+
timeout = 5000,
82+
interval = 100
83+
): Promise<string | null> {
84+
const logPattern = new RegExp(
85+
`To reset the password for ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`,
86+
"i"
87+
);
88+
return waitForCode(email, logPattern, extractOobCode, timeout, interval);
89+
}
90+
91+
export async function waitForVerificationCode(
92+
email: string,
93+
timeout = 5000,
94+
interval = 100
95+
): Promise<string | null> {
96+
const logPattern = new RegExp(
97+
`To verify the email address ${email.replace(".", "\\.")}.*?http://127\\.0\\.0\\.1:9099.*`,
98+
"i"
99+
);
100+
return waitForCode(email, logPattern, extractOobCode, timeout, interval);
101+
}

0 commit comments

Comments
 (0)