Skip to content

Commit e0eaacf

Browse files
committed
fix(onboarding): ensures proper returnTo after email validation
1 parent c74cbea commit e0eaacf

5 files changed

Lines changed: 327 additions & 84 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import type { ComponentProps } from "react";
2+
import React from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { EXCLUDED_PREFIXES, OnboardingRedirectEffect } from "./OnboardingRedirectEffect";
6+
7+
import { render } from "@testing-library/react";
8+
9+
describe("OnboardingRedirectEffect", () => {
10+
it("redirects to onboarding when user has userId but no wallet", () => {
11+
const { mockRouter } = setup({
12+
user: { userId: "user-1" },
13+
wallet: { hasManagedWallet: false, isWalletConnected: false },
14+
pathname: "/deployments"
15+
});
16+
17+
expect(mockRouter.replace).toHaveBeenCalledWith("/signup?return-to=%2Fdeployments");
18+
});
19+
20+
it("does not redirect when user has a managed wallet", () => {
21+
const { mockRouter } = setup({
22+
user: { userId: "user-1" },
23+
wallet: { hasManagedWallet: true, isWalletConnected: false },
24+
pathname: "/deployments"
25+
});
26+
27+
expect(mockRouter.replace).not.toHaveBeenCalled();
28+
});
29+
30+
it("does not redirect when wallet is connected", () => {
31+
const { mockRouter } = setup({
32+
user: { userId: "user-1" },
33+
wallet: { hasManagedWallet: false, isWalletConnected: true },
34+
pathname: "/deployments"
35+
});
36+
37+
expect(mockRouter.replace).not.toHaveBeenCalled();
38+
});
39+
40+
it("does not redirect when user has no userId", () => {
41+
const { mockRouter } = setup({
42+
user: {},
43+
wallet: { hasManagedWallet: false, isWalletConnected: false },
44+
pathname: "/deployments"
45+
});
46+
47+
expect(mockRouter.replace).not.toHaveBeenCalled();
48+
});
49+
50+
it("does not redirect when user is loading", () => {
51+
const { mockRouter } = setup({
52+
user: { userId: "user-1" },
53+
wallet: { hasManagedWallet: false, isWalletConnected: false },
54+
pathname: "/deployments",
55+
isUserLoading: true
56+
});
57+
58+
expect(mockRouter.replace).not.toHaveBeenCalled();
59+
});
60+
61+
it("does not redirect when wallet is loading", () => {
62+
const { mockRouter } = setup({
63+
user: { userId: "user-1" },
64+
wallet: { hasManagedWallet: false, isWalletConnected: false },
65+
pathname: "/deployments",
66+
isWalletLoading: true
67+
});
68+
69+
expect(mockRouter.replace).not.toHaveBeenCalled();
70+
});
71+
72+
it.each(EXCLUDED_PREFIXES)("does not redirect when pathname starts with %s", prefix => {
73+
const { mockRouter } = setup({
74+
user: { userId: "user-1" },
75+
wallet: { hasManagedWallet: false, isWalletConnected: false },
76+
pathname: prefix
77+
});
78+
79+
expect(mockRouter.replace).not.toHaveBeenCalled();
80+
});
81+
82+
it("passes returnTo with router.asPath", () => {
83+
const { mockRouter } = setup({
84+
user: { userId: "user-1" },
85+
wallet: { hasManagedWallet: false, isWalletConnected: false },
86+
pathname: "/deployments",
87+
asPath: "/deployments?page=2"
88+
});
89+
90+
expect(mockRouter.replace).toHaveBeenCalledWith("/signup?return-to=%2Fdeployments%3Fpage%3D2");
91+
});
92+
93+
function setup(input: {
94+
user?: { userId?: string };
95+
wallet?: { hasManagedWallet?: boolean; isWalletConnected?: boolean };
96+
pathname?: string;
97+
asPath?: string;
98+
isUserLoading?: boolean;
99+
isWalletLoading?: boolean;
100+
}) {
101+
const mockRouter = {
102+
pathname: input.pathname || "/",
103+
asPath: input.asPath || input.pathname || "/",
104+
replace: vi.fn()
105+
};
106+
107+
const dependencies = {
108+
useUser: vi.fn().mockReturnValue({
109+
user: input.user || {},
110+
isLoading: input.isUserLoading || false
111+
}),
112+
useWallet: vi.fn().mockReturnValue({
113+
hasManagedWallet: input.wallet?.hasManagedWallet || false,
114+
isWalletConnected: input.wallet?.isWalletConnected || false,
115+
isWalletLoading: input.isWalletLoading || false
116+
}),
117+
useRouter: vi.fn().mockReturnValue(mockRouter),
118+
UrlService: {
119+
onboarding: vi.fn(({ returnTo }: { returnTo?: string } = {}) => {
120+
if (returnTo) {
121+
return `/signup?return-to=${encodeURIComponent(returnTo)}`;
122+
}
123+
return "/signup";
124+
})
125+
}
126+
} as unknown as ComponentProps<typeof OnboardingRedirectEffect>["dependencies"];
127+
128+
render(<OnboardingRedirectEffect dependencies={dependencies} />);
129+
130+
return { mockRouter, dependencies };
131+
}
132+
});

apps/deploy-web/src/components/onboarding/OnboardingRedirectEffect/OnboardingRedirectEffect.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,23 @@ import { useWallet } from "@src/context/WalletProvider";
55
import { useUser } from "@src/hooks/useUser";
66
import { UrlService } from "@src/utils/urlUtils";
77

8-
const EXCLUDED_PREFIXES = ["/signup", "/login", "/api/"];
8+
export const EXCLUDED_PREFIXES = ["/signup", "/login", "/api/", "/user/verify-email"];
99

10-
export const OnboardingRedirectEffect = () => {
11-
const { user, isLoading: isUserLoading } = useUser();
12-
const { hasManagedWallet, isWalletConnected, isWalletLoading } = useWallet();
13-
const router = useRouter();
10+
const DEPENDENCIES = {
11+
useUser,
12+
useWallet,
13+
useRouter,
14+
UrlService
15+
};
16+
17+
type OnboardingRedirectEffectProps = {
18+
dependencies?: typeof DEPENDENCIES;
19+
};
20+
21+
export const OnboardingRedirectEffect = ({ dependencies: d = DEPENDENCIES }: OnboardingRedirectEffectProps) => {
22+
const { user, isLoading: isUserLoading } = d.useUser();
23+
const { hasManagedWallet, isWalletConnected, isWalletLoading } = d.useWallet();
24+
const router = d.useRouter();
1425

1526
useEffect(() => {
1627
const isExcluded = EXCLUDED_PREFIXES.some(prefix => router.pathname.startsWith(prefix));
@@ -20,9 +31,9 @@ export const OnboardingRedirectEffect = () => {
2031
}
2132

2233
if (user?.userId && !hasManagedWallet && !isWalletConnected) {
23-
router.replace(UrlService.onboarding({ returnTo: router.asPath }));
34+
router.replace(d.UrlService.onboarding({ returnTo: router.asPath }));
2435
}
25-
}, [isUserLoading, isWalletLoading, user?.userId, hasManagedWallet, isWalletConnected, router]);
36+
}, [isUserLoading, isWalletLoading, user?.userId, hasManagedWallet, isWalletConnected, router, d.UrlService]);
2637

2738
return null;
2839
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { ComponentProps } from "react";
2+
import React from "react";
3+
import { describe, expect, it, vi } from "vitest";
4+
5+
import { VerifyEmailPage } from "./VerifyEmailPage";
6+
7+
import { act, render, screen } from "@testing-library/react";
8+
9+
describe("VerifyEmailPage", () => {
10+
it("calls verifyEmail with the email from search params", () => {
11+
const { mockVerifyEmail } = setup({ email: "test@example.com" });
12+
13+
expect(mockVerifyEmail).toHaveBeenCalledWith("test@example.com");
14+
});
15+
16+
it("does not call verifyEmail when email param is missing", () => {
17+
const { mockVerifyEmail } = setup({ email: null });
18+
19+
expect(mockVerifyEmail).not.toHaveBeenCalled();
20+
});
21+
22+
it("shows loading text when verification is pending", () => {
23+
setup({ email: "test@example.com", isPending: true });
24+
25+
expect(screen.getByText("Just a moment while we finish verifying your email.")).toBeInTheDocument();
26+
});
27+
28+
it("shows success message when email is verified", () => {
29+
const { capturedOnSuccess } = setup({ email: "test@example.com" });
30+
31+
act(() => capturedOnSuccess?.(true));
32+
33+
expect(screen.getByTestId("CheckCircleIcon")).toBeInTheDocument();
34+
});
35+
36+
it("shows error message when email verification fails", () => {
37+
const { capturedOnError } = setup({ email: "test@example.com" });
38+
39+
act(() => capturedOnError?.());
40+
41+
expect(screen.getByText("Your email was not verified. Please try again.")).toBeInTheDocument();
42+
});
43+
44+
it("shows error message when isVerified is null", () => {
45+
setup({ email: "test@example.com" });
46+
47+
expect(screen.getByText("Your email was not verified. Please try again.")).toBeInTheDocument();
48+
});
49+
50+
function setup(input: { email?: string | null; isPending?: boolean }) {
51+
const mockVerifyEmail = vi.fn();
52+
let capturedOnSuccess: ((isVerified: boolean) => void) | undefined;
53+
let capturedOnError: (() => void) | undefined;
54+
55+
const mockUseVerifyEmail = vi.fn().mockImplementation((options: { onSuccess?: (v: boolean) => void; onError?: () => void }) => {
56+
capturedOnSuccess = options.onSuccess;
57+
capturedOnError = options.onError;
58+
return { mutate: mockVerifyEmail, isPending: input.isPending || false };
59+
});
60+
61+
const mockUseWhen = vi.fn().mockImplementation((condition: unknown, run: () => void) => {
62+
if (condition) {
63+
run();
64+
}
65+
});
66+
67+
const dependencies = {
68+
useSearchParams: vi.fn().mockReturnValue(new URLSearchParams(input.email ? `email=${input.email}` : "")),
69+
useVerifyEmail: mockUseVerifyEmail,
70+
useWhen: mockUseWhen,
71+
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
72+
Loading: ({ text }: { text: string }) => <div>{text}</div>,
73+
UrlService: {
74+
onboarding: vi.fn(() => "/signup")
75+
}
76+
} as unknown as ComponentProps<typeof VerifyEmailPage>["dependencies"];
77+
78+
render(<VerifyEmailPage dependencies={dependencies} />);
79+
80+
return { mockVerifyEmail, capturedOnSuccess, capturedOnError };
81+
}
82+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { useCallback, useState } from "react";
2+
import { AutoButton } from "@akashnetwork/ui/components";
3+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
4+
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
5+
import { ArrowRight } from "iconoir-react";
6+
import { useSearchParams } from "next/navigation";
7+
import { NextSeo } from "next-seo";
8+
9+
import Layout, { Loading } from "@src/components/layout/Layout";
10+
import { OnboardingStepIndex } from "@src/components/onboarding/OnboardingContainer/OnboardingContainer";
11+
import { useWhen } from "@src/hooks/useWhen";
12+
import { useVerifyEmail } from "@src/queries/useVerifyEmailQuery";
13+
import { ONBOARDING_STEP_KEY } from "@src/services/storage/keys";
14+
import { UrlService } from "@src/utils/urlUtils";
15+
16+
const DEPENDENCIES = {
17+
useSearchParams,
18+
useVerifyEmail,
19+
useWhen,
20+
Layout,
21+
Loading,
22+
UrlService
23+
};
24+
25+
type VerifyEmailPageProps = {
26+
dependencies?: typeof DEPENDENCIES;
27+
};
28+
29+
type VerificationResultProps = {
30+
isVerified: boolean;
31+
dependencies: Pick<typeof DEPENDENCIES, "UrlService">;
32+
};
33+
34+
function VerificationResult({ isVerified, dependencies: d }: VerificationResultProps) {
35+
const gotoOnboarding = useCallback(() => {
36+
window.localStorage?.setItem(ONBOARDING_STEP_KEY, OnboardingStepIndex.PAYMENT_METHOD.toString());
37+
window.location.href = d.UrlService.onboarding({ returnTo: "/" });
38+
}, [d.UrlService]);
39+
40+
return (
41+
<div className="mt-10 text-center">
42+
{isVerified ? (
43+
<>
44+
<CheckCircleIcon className="mb-2 h-16 w-16 text-green-500" />
45+
<h5>
46+
Your email was verified.
47+
<br />
48+
You can continue using the application.
49+
</h5>
50+
<AutoButton
51+
onClick={gotoOnboarding}
52+
text={
53+
<>
54+
Continue <ArrowRight className="ml-4" />
55+
</>
56+
}
57+
timeout={5000}
58+
/>
59+
</>
60+
) : (
61+
<>
62+
<ErrorOutlineIcon className="mb-2 h-16 w-16 text-red-500" />
63+
<h5>Your email was not verified. Please try again.</h5>
64+
</>
65+
)}
66+
</div>
67+
);
68+
}
69+
70+
export function VerifyEmailPage({ dependencies: d = DEPENDENCIES }: VerifyEmailPageProps) {
71+
const email = d.useSearchParams().get("email");
72+
const [isVerified, setIsVerified] = useState<boolean | null>(null);
73+
const { mutate: verifyEmail, isPending: isVerifying } = d.useVerifyEmail({ onSuccess: setIsVerified, onError: () => setIsVerified(false) });
74+
75+
d.useWhen(email, () => {
76+
if (email) {
77+
verifyEmail(email);
78+
}
79+
});
80+
81+
return (
82+
<d.Layout>
83+
<NextSeo title="Verifying your email" />
84+
{isVerifying ? (
85+
<d.Loading text="Just a moment while we finish verifying your email." />
86+
) : (
87+
<>
88+
<VerificationResult isVerified={isVerified === true} dependencies={d} />
89+
</>
90+
)}
91+
</d.Layout>
92+
);
93+
}

0 commit comments

Comments
 (0)