Skip to content

Commit 86e04a1

Browse files
authored
Merge pull request #124 from HassanBahati/ft-add-useVerifyPasswordResetCodeMutation
feat: add useVerifyPasswordResetCodeMutation hook
2 parents 6bf741d + 51ede00 commit 86e04a1

File tree

4 files changed

+187
-0
lines changed

4 files changed

+187
-0
lines changed

packages/react/src/auth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export { useSignInWithEmailAndPasswordMutation } from "./useSignInWithEmailAndPa
3030
export { useUpdateCurrentUserMutation } from "./useUpdateCurrentUserMutation";
3131
// useValidatePasswordMutation
3232
// useVerifyPasswordResetCodeMutation
33+
export { useVerifyPasswordResetCodeMutation } from "./useVerifyPasswordResetCodeMutation";
3334
// useDeleteUserMutation
3435
export { useDeleteUserMutation } from "./useDeleteUserMutation";
3536
// useLinkWithCredentialMutation
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from "react";
2+
import { describe, expect, test, beforeEach, afterEach, vi } from "vitest";
3+
import { renderHook, act, waitFor } from "@testing-library/react";
4+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5+
import { useVerifyPasswordResetCodeMutation } from "./useVerifyPasswordResetCodeMutation";
6+
import { auth, wipeAuth } from "~/testing-utils";
7+
import {
8+
createUserWithEmailAndPassword,
9+
sendPasswordResetEmail,
10+
} from "firebase/auth";
11+
import { waitForPasswordResetCode } from "./utils";
12+
13+
const queryClient = new QueryClient({
14+
defaultOptions: {
15+
queries: { retry: false },
16+
mutations: { retry: false },
17+
},
18+
});
19+
20+
const wrapper = ({ children }: { children: React.ReactNode }) => (
21+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
22+
);
23+
24+
describe("useVerifyPasswordResetCodeMutation", () => {
25+
const email = "[email protected]";
26+
const password = "TanstackQueryFirebase#123";
27+
28+
beforeEach(async () => {
29+
queryClient.clear();
30+
await wipeAuth();
31+
await createUserWithEmailAndPassword(auth, email, password);
32+
await sendPasswordResetEmail(auth, email);
33+
});
34+
35+
afterEach(async () => {
36+
vi.clearAllMocks();
37+
await auth.signOut();
38+
});
39+
40+
test("successfully verifies the reset code", async () => {
41+
const code = await waitForPasswordResetCode(email);
42+
43+
const { result } = renderHook(
44+
() => useVerifyPasswordResetCodeMutation(auth),
45+
{
46+
wrapper,
47+
}
48+
);
49+
50+
await act(async () => {
51+
code && (await result.current.mutateAsync(code));
52+
});
53+
54+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
55+
56+
expect(result.current.data).toBe(email);
57+
expect(result.current.variables).toBe(code);
58+
});
59+
60+
test("handles invalid reset code", async () => {
61+
const invalidCode = "invalid-reset-code";
62+
63+
const { result } = renderHook(
64+
() => useVerifyPasswordResetCodeMutation(auth),
65+
{ wrapper }
66+
);
67+
68+
await act(async () => {
69+
await result.current.mutate(invalidCode);
70+
});
71+
72+
await waitFor(() => expect(result.current.isError).toBe(true));
73+
74+
expect(result.current.error).toBeDefined();
75+
// TODO: Assert Firebase error for auth/invalid-action-code
76+
});
77+
78+
test("handles empty reset code", async () => {
79+
const emptyCode = "";
80+
81+
const { result } = renderHook(
82+
() => useVerifyPasswordResetCodeMutation(auth),
83+
{ wrapper }
84+
);
85+
86+
await act(async () => await result.current.mutate(emptyCode));
87+
88+
await waitFor(() => expect(result.current.isError).toBe(true));
89+
90+
expect(result.current.error).toBeDefined();
91+
// TODO: Assert Firebase error for auth/invalid-action-code
92+
});
93+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useMutation, type UseMutationOptions } from "@tanstack/react-query";
2+
import {
3+
type AuthError,
4+
verifyPasswordResetCode,
5+
type Auth,
6+
} from "firebase/auth";
7+
8+
type AuthUseMutationOptions<
9+
TData = unknown,
10+
TError = Error,
11+
TVariables = void
12+
> = Omit<UseMutationOptions<TData, TError, TVariables>, "mutationFn">;
13+
14+
export function useVerifyPasswordResetCodeMutation(
15+
auth: Auth,
16+
options?: AuthUseMutationOptions<string, AuthError, string>
17+
) {
18+
return useMutation<string, AuthError, string>({
19+
...options,
20+
mutationFn: (code: string) => verifyPasswordResetCode(auth, code),
21+
});
22+
}

packages/react/src/auth/utils.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import fs from "fs";
2+
import path from "path";
3+
4+
/**
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
8+
*/
9+
async function getPasswordResetCodeFromLogs(
10+
email: string
11+
): Promise<string | null> {
12+
try {
13+
// Read the firebase-debug.log file
14+
const logPath = path.join(process.cwd(), "firebase-debug.log");
15+
const logContent = await fs.promises.readFile(logPath, "utf8");
16+
17+
// Find the most recent password reset link for the given email
18+
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+
);
26+
27+
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;
35+
}
36+
}
37+
}
38+
39+
return null;
40+
} catch (error) {
41+
console.error("Error reading Firebase debug log:", error);
42+
return null;
43+
}
44+
}
45+
46+
/**
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
52+
*/
53+
async function waitForPasswordResetCode(
54+
email: string,
55+
timeout = 5000,
56+
interval = 100
57+
): Promise<string | null> {
58+
const startTime = Date.now();
59+
60+
while (Date.now() - startTime < timeout) {
61+
const code = await getPasswordResetCodeFromLogs(email);
62+
if (code) {
63+
return code;
64+
}
65+
await new Promise((resolve) => setTimeout(resolve, interval));
66+
}
67+
68+
return null;
69+
}
70+
71+
export { getPasswordResetCodeFromLogs, waitForPasswordResetCode };

0 commit comments

Comments
 (0)