Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,6 @@ export class EmailVerificationCodeRepository extends BaseRepository<Table, Email
return result ? this.toOutput(result) : undefined;
}

async findByUserIdForUpdate(userId: string): Promise<EmailVerificationCodeOutput | undefined> {
const [result] = await this.cursor.select().from(this.table).where(eq(this.table.userId, userId)).for("update");

return result ? this.toOutput(result) : undefined;
}

async deleteByUserId(userId: string): Promise<void> {
await this.cursor.delete(this.table).where(eq(this.table.userId, userId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ describe(EmailVerificationCodeService.name, () => {
userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(recentRecord);

await expect(service.sendCode(user.id)).rejects.toThrow("Too many verification code requests");
await expect(service.sendCode(user.id)).rejects.toThrow("Please wait before requesting a new verification code");
});

it("throws 404 when user not found", async () => {
Expand Down Expand Up @@ -113,7 +113,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(record);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(record);

await service.verifyCode(user.id, code);

Expand All @@ -127,7 +127,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(record);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(record);

await expect(service.verifyCode(user.id, "999999")).rejects.toThrow("Invalid verification code");
expect(emailVerificationCodeRepository.incrementAttempts).toHaveBeenCalledWith(record.id);
Expand All @@ -139,7 +139,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(record);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(record);

await expect(service.verifyCode(user.id, "123456")).rejects.toThrow();
expect(emailVerificationCodeRepository.incrementAttempts).not.toHaveBeenCalled();
Expand All @@ -157,7 +157,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(record);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(record);

await expect(service.verifyCode(user.id, code)).rejects.toThrow("Verification code expired");
});
Expand All @@ -169,7 +169,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(record);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(record);
auth0Service.markEmailVerified.mockRejectedValue(new Error("Auth0 unavailable"));

await expect(service.verifyCode(user.id, code)).rejects.toThrow("Auth0 unavailable");
Expand All @@ -182,7 +182,7 @@ describe(EmailVerificationCodeService.name, () => {
const { service, emailVerificationCodeRepository, userRepository } = setup();

userRepository.findById.mockResolvedValue(user);
emailVerificationCodeRepository.findByUserIdForUpdate.mockResolvedValue(undefined);
emailVerificationCodeRepository.findByUserId.mockResolvedValue(undefined);

await expect(service.verifyCode(user.id, "123456")).rejects.toThrow();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { singleton } from "tsyringe";

import { EmailVerificationCodeRepository } from "@src/auth/repositories/email-verification-code/email-verification-code.repository";
import { Auth0Service } from "@src/auth/services/auth0/auth0.service";
import { WithTransaction } from "@src/core";
import { LoggerService } from "@src/core/providers/logging.provider";
import { NotificationService } from "@src/notifications/services/notification/notification.service";
import { emailVerificationCodeNotification } from "@src/notifications/services/notification-templates/email-verification-code-notification";
Expand Down Expand Up @@ -36,7 +35,7 @@ export class EmailVerificationCodeService {
const existing = await this.emailVerificationCodeRepository.findByUserId(userInternalId);
if (existing) {
const isWithinCooldown = new Date(existing.expiresAt).getTime() > Date.now() + CODE_EXPIRY_MS - COOLDOWN_MS;
assert(!isWithinCooldown, 429, "Too many verification code requests. Please try again later.");
assert(!isWithinCooldown, 429, "Please wait before requesting a new verification code.");
}

const code = randomInt(100000, 1000000).toString();
Expand All @@ -63,24 +62,7 @@ export class EmailVerificationCodeService {
}

async verifyCode(userInternalId: string, code: string): Promise<void> {
const auth0UserId = await this.verifyCodeInTransaction(userInternalId, code);

try {
await this.auth0Service.markEmailVerified(auth0UserId);
} catch (error) {
this.logger.error({ event: "EMAIL_VERIFIED_MARK_AUTH0_FAILED", userId: userInternalId, auth0UserId, error });
throw error;
}

this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId });
}

@WithTransaction()
private async verifyCodeInTransaction(userInternalId: string, code: string): Promise<string> {
const [user, record] = await Promise.all([
this.userRepository.findById(userInternalId),
this.emailVerificationCodeRepository.findByUserIdForUpdate(userInternalId)
]);
const [user, record] = await Promise.all([this.userRepository.findById(userInternalId), this.emailVerificationCodeRepository.findByUserId(userInternalId)]);
assert(user, 404, "User not found");
assert(user.userId, 400, "User has no Auth0 ID");
assert(record, 400, "No active verification code. Please request a new one.");
Expand All @@ -94,6 +76,13 @@ export class EmailVerificationCodeService {

await this.userRepository.updateById(userInternalId, { emailVerified: true });

return user.userId;
try {
await this.auth0Service.markEmailVerified(user.userId);
} catch (error) {
this.logger.error({ event: "EMAIL_VERIFIED_MARK_AUTH0_FAILED", userId: userInternalId, auth0UserId: user.userId, error });
throw error;
}

this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const OnboardingView: FC<OnboardingViewProps> = ({
...steps[OnboardingStepIndex.EMAIL_VERIFICATION],
component: (
<d.EmailVerificationContainer onComplete={() => onStepChange(OnboardingStepIndex.PAYMENT_METHOD)}>
{props => <d.EmailVerificationStep {...props} />}
{({ sendCode, verifyCode }) => <d.EmailVerificationStep sendCode={sendCode} verifyCode={verifyCode} />}
</d.EmailVerificationContainer>
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,79 +4,36 @@ import { describe, expect, it, vi } from "vitest";

import { VerifyEmailPage } from "./VerifyEmailPage";

import { act, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";

describe(VerifyEmailPage.name, () => {
it("calls verifyEmail with the email from search params", () => {
const { mockVerifyEmail } = setup({ email: "test@example.com" });
it("shows redirect loading text", () => {
setup();

expect(mockVerifyEmail).toHaveBeenCalledWith("test@example.com");
expect(screen.queryByText("Redirecting to email verification...")).toBeInTheDocument();
});

it("does not call verifyEmail when email param is missing", () => {
const { mockVerifyEmail } = setup({ email: null });
it("redirects to onboarding with email verification step", () => {
const { redirect } = setup();

expect(mockVerifyEmail).not.toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/signup?return-to=%2F");
});

it("shows loading text when verification is pending", () => {
setup({ email: "test@example.com", isPending: true });

expect(screen.queryByText("Just a moment while we finish verifying your email.")).toBeInTheDocument();
});

it("shows success message when email is verified", () => {
const { capturedOnSuccess } = setup({ email: "test@example.com" });

act(() => capturedOnSuccess?.(true));

expect(screen.queryByTestId("CheckCircleIcon")).toBeInTheDocument();
});

it("shows error message when email verification fails", () => {
const { capturedOnError } = setup({ email: "test@example.com" });

act(() => capturedOnError?.());

expect(screen.queryByText("Your email was not verified. Please try again.")).toBeInTheDocument();
});

it("shows error message when isVerified is null", () => {
setup({ email: "test@example.com" });

expect(screen.queryByText("Your email was not verified. Please try again.")).toBeInTheDocument();
});

function setup(input: { email?: string | null; isPending?: boolean }) {
const mockVerifyEmail = vi.fn();
let capturedOnSuccess: ((isVerified: boolean) => void) | undefined;
let capturedOnError: (() => void) | undefined;

const mockUseVerifyEmail = vi.fn().mockImplementation((options: { onSuccess?: (v: boolean) => void; onError?: () => void }) => {
capturedOnSuccess = options.onSuccess;
capturedOnError = options.onError;
return { mutate: mockVerifyEmail, isPending: input.isPending || false };
});

const mockUseWhen = vi.fn().mockImplementation((condition: unknown, run: () => void) => {
if (condition) {
run();
}
});
function setup(input: { onboardingUrl?: string } = {}) {
const redirect = vi.fn();

const dependencies = {
useSearchParams: vi.fn().mockReturnValue(new URLSearchParams(input.email ? `email=${input.email}` : "")),
useVerifyEmail: mockUseVerifyEmail,
useWhen: mockUseWhen,
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Loading: ({ text }: { text: string }) => <div>{text}</div>,
NextSeo: () => null,
UrlService: {
onboarding: vi.fn(() => "/signup")
}
onboarding: vi.fn(() => input.onboardingUrl ?? "/signup?return-to=%2F")
},
redirect
} as unknown as ComponentProps<typeof VerifyEmailPage>["dependencies"];

render(<VerifyEmailPage dependencies={dependencies} />);

return { mockVerifyEmail, capturedOnSuccess, capturedOnError };
return { redirect };
}
});
Original file line number Diff line number Diff line change
@@ -1,93 +1,35 @@
import React, { useCallback, useState } from "react";
import { AutoButton } from "@akashnetwork/ui/components";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import { ArrowRight } from "iconoir-react";
import { useSearchParams } from "next/navigation";
import React, { useEffect } from "react";
import { NextSeo } from "next-seo";

import Layout, { Loading } from "@src/components/layout/Layout";
import { OnboardingStepIndex } from "@src/components/onboarding/OnboardingContainer/OnboardingContainer";
import { useWhen } from "@src/hooks/useWhen";
import { useVerifyEmail } from "@src/queries/useVerifyEmailQuery";
import { ONBOARDING_STEP_KEY } from "@src/services/storage/keys";
import { UrlService } from "@src/utils/urlUtils";

const DEPENDENCIES = {
useSearchParams,
useVerifyEmail,
useWhen,
Layout,
Loading,
UrlService
NextSeo,
UrlService,
redirect: (url: string) => {
window.localStorage.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.EMAIL_VERIFICATION.toString());
window.location.replace(url);
}
};

type VerifyEmailPageProps = {
dependencies?: typeof DEPENDENCIES;
};

type VerificationResultProps = {
isVerified: boolean;
dependencies: Pick<typeof DEPENDENCIES, "UrlService">;
};

function VerificationResult({ isVerified, dependencies: d }: VerificationResultProps) {
const gotoOnboarding = useCallback(() => {
window.localStorage?.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.PAYMENT_METHOD.toString());
window.location.href = d.UrlService.onboarding({ returnTo: "/" });
}, [d.UrlService]);

return (
<div className="mt-10 text-center">
{isVerified ? (
<>
<CheckCircleIcon className="mb-2 h-16 w-16 text-green-500" />
<h5>
Your email was verified.
<br />
You can continue using the application.
</h5>
<AutoButton
onClick={gotoOnboarding}
text={
<>
Continue <ArrowRight className="ml-4" />
</>
}
timeout={5000}
/>
</>
) : (
<>
<ErrorOutlineIcon className="mb-2 h-16 w-16 text-red-500" />
<h5>Your email was not verified. Please try again.</h5>
</>
)}
</div>
);
}

export function VerifyEmailPage({ dependencies: d = DEPENDENCIES }: VerifyEmailPageProps) {
const email = d.useSearchParams().get("email");
const [isVerified, setIsVerified] = useState<boolean | null>(null);
const { mutate: verifyEmail, isPending: isVerifying } = d.useVerifyEmail({ onSuccess: setIsVerified, onError: () => setIsVerified(false) });

d.useWhen(email, () => {
if (email) {
verifyEmail(email);
}
});
useEffect(() => {
d.redirect(d.UrlService.onboarding({ returnTo: "/" }));
}, [d]);
Comment thread
baktun14 marked this conversation as resolved.

return (
<d.Layout>
<NextSeo title="Verifying your email" />
{isVerifying ? (
<d.Loading text="Just a moment while we finish verifying your email." />
) : (
<>
<VerificationResult isVerified={isVerified === true} dependencies={d} />
</>
)}
<d.NextSeo title="Email Verification" />
<d.Loading text="Redirecting to email verification..." />
</d.Layout>
);
}
Loading
Loading