mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
feat: add button to authentication modal to resend verification email (#12179)
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
35
frontend/src/api/email-service/email-service.api.ts
Normal file
35
frontend/src/api/email-service/email-service.api.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
8
frontend/src/api/email-service/email.types.ts
Normal file
8
frontend/src/api/email-service/email.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ResendEmailVerificationParams {
|
||||
userId?: string | null;
|
||||
isAuthFlow?: boolean;
|
||||
}
|
||||
|
||||
export interface ResendEmailVerificationResponse {
|
||||
message: string;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
49
frontend/src/hooks/mutation/use-resend-email-verification.ts
Normal file
49
frontend/src/hooks/mutation/use-resend-email-verification.ts
Normal 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));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user