feat: add button to authentication modal to resend verification email (#12179)

This commit is contained in:
Hiep Le
2025-12-30 02:12:14 +07:00
committed by GitHub
parent d628e1f20a
commit 8ee1394e8c
17 changed files with 1073 additions and 29 deletions

View File

@@ -1,15 +1,34 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach } from "vitest";
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { emailService } from "#/api/email-service/email-service.api";
import { EmailVerificationModal } from "#/components/features/waitlist/email-verification-modal";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { renderWithProviders, createAxiosError } from "../../../../test-utils";
describe("EmailVerificationModal", () => {
const mockOnClose = vi.fn();
const resendEmailVerificationSpy = vi.spyOn(
emailService,
"resendEmailVerification",
);
const displaySuccessToastSpy = vi.spyOn(ToastHandlers, "displaySuccessToast");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should render the email verification message", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
@@ -19,10 +38,150 @@ describe("EmailVerificationModal", () => {
it("should render the TermsAndPrivacyNotice component", () => {
// Arrange & Act
render(<EmailVerificationModal onClose={vi.fn()} />);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
const termsSection = screen.getByTestId("terms-and-privacy-notice");
expect(termsSection).toBeInTheDocument();
});
it("should render resend verification button", () => {
// Arrange & Act
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Assert
expect(
screen.getByText("SETTINGS$RESEND_VERIFICATION"),
).toBeInTheDocument();
});
it("should call resendEmailVerification when the button is clicked", async () => {
// Arrange
const userId = "test_user_id";
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithRouter(
<EmailVerificationModal onClose={mockOnClose} userId={userId} />,
);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalledWith({
userId,
isAuthFlow: true,
});
});
});
it("should display success toast when resend succeeds", async () => {
// Arrange
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"SETTINGS$VERIFICATION_EMAIL_SENT",
);
});
});
it("should display rate limit error message when receiving 429 status", async () => {
// Arrange
const rateLimitError = createAxiosError(429, "Too Many Requests", {
detail: "Too many requests. Please wait 2 minutes before trying again.",
});
resendEmailVerificationSpy.mockRejectedValue(rateLimitError);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displayErrorToastSpy).toHaveBeenCalledWith(
"Too many requests. Please wait 2 minutes before trying again.",
);
});
});
it("should display generic error message when receiving non-429 error", async () => {
// Arrange
const genericError = createAxiosError(500, "Internal Server Error", {
error: "Internal server error",
});
resendEmailVerificationSpy.mockRejectedValue(genericError);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(displayErrorToastSpy).toHaveBeenCalledWith(
"SETTINGS$FAILED_TO_RESEND_VERIFICATION",
);
});
});
it("should disable button and show sending text while request is pending", async () => {
// Arrange
let resolvePromise: (value: { message: string }) => void;
const pendingPromise = new Promise<{ message: string }>((resolve) => {
resolvePromise = resolve;
});
resendEmailVerificationSpy.mockReturnValue(pendingPromise);
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
const sendingButton = screen.getByText("SETTINGS$SENDING");
expect(sendingButton).toBeInTheDocument();
expect(sendingButton).toBeDisabled();
});
// Cleanup
resolvePromise!({ message: "Email verification message sent" });
});
it("should re-enable button after request completes", async () => {
// Arrange
resendEmailVerificationSpy.mockResolvedValue({
message: "Email verification message sent",
});
renderWithRouter(<EmailVerificationModal onClose={mockOnClose} />);
// Act
const resendButton = screen.getByText("SETTINGS$RESEND_VERIFICATION");
await userEvent.click(resendButton);
// Assert
await waitFor(() => {
expect(resendEmailVerificationSpy).toHaveBeenCalled();
});
// After successful send, the button will be disabled due to cooldown
// So we just verify the mutation was called successfully
await waitFor(() => {
const button = screen.getByRole("button");
expect(button).toBeDisabled(); // Button is disabled during cooldown
expect(button).toHaveTextContent(/SETTINGS\$RESEND_VERIFICATION/);
});
});
});

View File

@@ -0,0 +1,35 @@
import { openHands } from "../open-hands-axios";
import {
ResendEmailVerificationParams,
ResendEmailVerificationResponse,
} from "./email.types";
/**
* Email Service API - Handles all email-related API endpoints
*/
export const emailService = {
/**
* Resend email verification to the user's registered email address
* @param userId - Optional user ID to send verification email for
* @param isAuthFlow - Whether this is part of the authentication flow
* @returns The response message indicating the email was sent
*/
resendEmailVerification: async ({
userId,
isAuthFlow,
}: ResendEmailVerificationParams): Promise<ResendEmailVerificationResponse> => {
const body: { user_id?: string; is_auth_flow?: boolean } = {};
if (userId) {
body.user_id = userId;
}
if (isAuthFlow !== undefined) {
body.is_auth_flow = isAuthFlow;
}
const { data } = await openHands.put<ResendEmailVerificationResponse>(
"/api/email/resend",
body,
{ withCredentials: true },
);
return data;
},
};

View File

@@ -0,0 +1,8 @@
export interface ResendEmailVerificationParams {
userId?: string | null;
isAuthFlow?: boolean;
}
export interface ResendEmailVerificationResponse {
message: string;
}

View File

@@ -4,15 +4,34 @@ import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-notice";
import { BrandButton } from "../settings/brand-button";
import { useEmailVerification } from "#/hooks/use-email-verification";
interface EmailVerificationModalProps {
onClose: () => void;
userId?: string | null;
}
export function EmailVerificationModal({
onClose,
userId,
}: EmailVerificationModalProps) {
const { t } = useTranslation();
const {
resendEmailVerification,
isResendingVerification: isResending,
isCooldownActive,
formattedCooldownTime,
} = useEmailVerification();
let resendButtonLabel: string;
if (isResending) {
resendButtonLabel = t(I18nKey.SETTINGS$SENDING);
} else if (isCooldownActive) {
resendButtonLabel = `${t(I18nKey.SETTINGS$RESEND_VERIFICATION)} (${formattedCooldownTime})`;
} else {
resendButtonLabel = t(I18nKey.SETTINGS$RESEND_VERIFICATION);
}
return (
<ModalBackdrop onClose={onClose}>
@@ -24,6 +43,20 @@ export function EmailVerificationModal({
</h1>
</div>
<div className="flex flex-col gap-3 w-full mt-4">
<BrandButton
type="button"
variant="primary"
onClick={() =>
resendEmailVerification({ userId, isAuthFlow: true })
}
isDisabled={isResending || isCooldownActive}
className="w-full font-semibold"
>
{resendButtonLabel}
</BrandButton>
</div>
<TermsAndPrivacyNotice />
</ModalBody>
</ModalBackdrop>

View File

@@ -0,0 +1,49 @@
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { emailService } from "#/api/email-service/email-service.api";
import {
displaySuccessToast,
displayErrorToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { ResendEmailVerificationParams } from "#/api/email-service/email.types";
interface UseResendEmailVerificationOptions {
onSuccess?: () => void;
}
export const useResendEmailVerification = (
options?: UseResendEmailVerificationOptions,
) => {
const { t } = useTranslation();
return useMutation({
mutationFn: (params: ResendEmailVerificationParams) =>
emailService.resendEmailVerification(params),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$VERIFICATION_EMAIL_SENT));
options?.onSuccess?.();
},
onError: (error: AxiosError) => {
// Check if it's a rate limit error (429)
if (error.response?.status === 429) {
// FastAPI returns errors in { detail: "..." } format
const errorData = error.response.data as
| { detail?: string }
| undefined;
const rateLimitMessage =
errorData?.detail ||
retrieveAxiosErrorMessage(error) ||
t(I18nKey.SETTINGS$FAILED_TO_RESEND_VERIFICATION);
displayErrorToast(rateLimitMessage);
} else {
// For other errors, show the generic error message
displayErrorToast(t(I18nKey.SETTINGS$FAILED_TO_RESEND_VERIFICATION));
}
},
});
};

View File

@@ -1,10 +1,12 @@
import React from "react";
import { useSearchParams } from "react-router";
import { useResendEmailVerification } from "#/hooks/mutation/use-resend-email-verification";
/**
* Hook to handle email verification logic from URL query parameters.
* Manages the email verification modal state and email verified state
* based on query parameters in the URL.
* Also provides functionality to resend email verification.
*
* @returns An object containing:
* - emailVerificationModalOpen: boolean state for modal visibility
@@ -12,6 +14,12 @@ import { useSearchParams } from "react-router";
* - emailVerified: boolean state for email verification status
* - setEmailVerified: function to control email verification status
* - hasDuplicatedEmail: boolean state for duplicate email error status
* - userId: string | null for the user ID from the redirect URL
* - resendEmailVerification: function to resend verification email
* - isResendingVerification: boolean indicating if resend is in progress
* - isCooldownActive: boolean indicating if cooldown is currently active
* - cooldownRemaining: number of milliseconds remaining in cooldown
* - formattedCooldownTime: string formatted as "M:SS" for display
*/
export function useEmailVerification() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -19,6 +27,26 @@ export function useEmailVerification() {
React.useState(false);
const [emailVerified, setEmailVerified] = React.useState(false);
const [hasDuplicatedEmail, setHasDuplicatedEmail] = React.useState(false);
const [userId, setUserId] = React.useState<string | null>(null);
const [lastSentTimestamp, setLastSentTimestamp] = React.useState<
number | null
>(null);
const [cooldownRemaining, setCooldownRemaining] = React.useState<number>(0);
const COOLDOWN_DURATION_MS = 30 * 1000; // 30 seconds
const formatCooldownTime = (ms: number): string => {
const seconds = Math.ceil(ms / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const resendEmailVerificationMutation = useResendEmailVerification({
onSuccess: () => {
setLastSentTimestamp(Date.now());
},
});
// Check for email verification query parameters
React.useEffect(() => {
@@ -27,6 +55,7 @@ export function useEmailVerification() {
);
const emailVerifiedParam = searchParams.get("email_verified");
const duplicatedEmailParam = searchParams.get("duplicated_email");
const userIdParam = searchParams.get("user_id");
let shouldUpdate = false;
if (emailVerificationRequired === "true") {
@@ -47,17 +76,61 @@ export function useEmailVerification() {
shouldUpdate = true;
}
if (userIdParam) {
setUserId(userIdParam);
searchParams.delete("user_id");
shouldUpdate = true;
}
// Clean up the URL by removing parameters if any were found
if (shouldUpdate) {
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, setSearchParams]);
// Update cooldown remaining time
React.useEffect(() => {
if (lastSentTimestamp === null) {
setCooldownRemaining(0);
return undefined;
}
let timeoutId: NodeJS.Timeout | null = null;
const updateCooldown = () => {
const elapsed = Date.now() - lastSentTimestamp!;
const remaining = Math.max(0, COOLDOWN_DURATION_MS - elapsed);
setCooldownRemaining(remaining);
if (remaining > 0) {
// Update every second while cooldown is active
timeoutId = setTimeout(updateCooldown, 1000);
}
};
updateCooldown();
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [lastSentTimestamp, COOLDOWN_DURATION_MS]);
const isCooldownActive = cooldownRemaining > 0;
const formattedCooldownTime = formatCooldownTime(cooldownRemaining);
return {
emailVerificationModalOpen,
setEmailVerificationModalOpen,
emailVerified,
setEmailVerified,
hasDuplicatedEmail,
userId,
resendEmailVerification: resendEmailVerificationMutation.mutate,
isResendingVerification: resendEmailVerificationMutation.isPending,
isCooldownActive,
cooldownRemaining,
formattedCooldownTime,
};
}

View File

@@ -98,6 +98,7 @@ export default function MainApp() {
setEmailVerificationModalOpen,
emailVerified,
hasDuplicatedEmail,
userId,
} = useEmailVerification();
// Auto-login if login method is stored in local storage
@@ -254,6 +255,7 @@ export default function MainApp() {
onClose={() => {
setEmailVerificationModalOpen(false);
}}
userId={userId}
/>
)}
{config.data?.APP_MODE === "oss" && consentFormIsOpen && (

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { openHands } from "#/api/open-hands-axios";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { useEmailVerification } from "#/hooks/use-email-verification";
// Email validation regex pattern
const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
@@ -115,11 +116,12 @@ function UserSettingsScreen() {
const [email, setEmail] = useState("");
const [originalEmail, setOriginalEmail] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [isResendingVerification, setIsResendingVerification] = useState(false);
const [isEmailValid, setIsEmailValid] = useState(true);
const queryClient = useQueryClient();
const pollingIntervalRef = useRef<number | null>(null);
const prevVerificationStatusRef = useRef<boolean | undefined>(undefined);
const { resendEmailVerification, isResendingVerification } =
useEmailVerification();
useEffect(() => {
if (settings?.email) {
@@ -185,18 +187,8 @@ function UserSettingsScreen() {
}
};
const handleResendVerification = async () => {
try {
setIsResendingVerification(true);
await openHands.put("/api/email/verify", {}, { withCredentials: true });
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$VERIFICATION_EMAIL_SENT"));
} catch (error) {
// eslint-disable-next-line no-console
console.error(t("SETTINGS$FAILED_TO_RESEND_VERIFICATION"), error);
} finally {
setIsResendingVerification(false);
}
const handleResendVerification = () => {
resendEmailVerification({});
};
const isEmailChanged = email !== originalEmail;

View File

@@ -74,3 +74,23 @@ export const createAxiosNotFoundErrorObject = () =>
config: {},
},
);
export const createAxiosError = (
status: number,
statusText: string,
data: unknown,
) =>
new AxiosError(
`Request failed with status code ${status}`,
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status,
statusText,
data,
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);