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
6 changes: 6 additions & 0 deletions apps/deploy-web/env/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ NEXT_PUBLIC_UNLEASH_FRONTEND_API_TOKEN=

UNLEASH_SERVER_API_URL=
UNLEASH_SERVER_API_TOKEN=

NEXT_PUBLIC_TURNSTILE_ENABLED=true
# https://developers.cloudflare.com/turnstile/troubleshooting/testing/#dummy-sitekeys-and-secret-keys
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
E2E_TESTING_CLIENT_TOKEN=any-random-string
11 changes: 10 additions & 1 deletion apps/deploy-web/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import path from "path";

dotenv.config({ path: path.resolve(__dirname, "env/.env.test") });

if (!process.env.E2E_TESTING_CLIENT_TOKEN && !process.env.BASE_URL?.includes("localhost")) {
throw new Error(
"E2E_TESTING_CLIENT_TOKEN is a required env variable. Without it, tests will be blocked by captcha verification. Should be set to the same value as app's E2E_TESTING_CLIENT_TOKEN env variable."
);
}

/**
* See https://playwright.dev/docs/test-configuration.
*/
Expand All @@ -26,7 +32,10 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure",
video: "retain-on-failure",
actionTimeout: 15_000
actionTimeout: 15_000,
extraHTTPHeaders: {
"X-Testing-Client-Token": process.env.E2E_TESTING_CLIENT_TOKEN || ""
}
},

/* Configure projects for major browsers */
Expand Down
78 changes: 74 additions & 4 deletions apps/deploy-web/src/components/auth/AuthPage/AuthPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { type RefObject, useState } from "react";
import type { Tabs } from "@akashnetwork/ui/components";
import { mock } from "jest-mock-extended";
import type { ReadonlyURLSearchParams } from "next/navigation";
import type { NextRouter } from "next/router";

import type { TurnstileRef } from "@src/components/turnstile/Turnstile";
import type { AuthService } from "@src/services/auth/auth/auth.service";
import type { SignInForm, SignInFormValues } from "../SignInForm/SignInForm";
import type { SignUpForm, SignUpFormValues } from "../SignUpForm/SignUpForm";
Expand Down Expand Up @@ -110,7 +112,10 @@ describe(AuthPage.name, () => {
});

await waitFor(() => {
expect(authService.login).toHaveBeenCalledWith(credentials);
expect(authService.login).toHaveBeenCalledWith({
...credentials,
captchaToken: "test-captcha-token"
});
});
expect(authService.signup).not.toHaveBeenCalled();
await waitFor(() => {
Expand Down Expand Up @@ -168,7 +173,10 @@ describe(AuthPage.name, () => {
});

await waitFor(() => {
expect(authService.signup).toHaveBeenCalledWith(credentials);
expect(authService.signup).toHaveBeenCalledWith({
...credentials,
captchaToken: "test-captcha-token"
});
});
expect(authService.login).not.toHaveBeenCalled();
await waitFor(() => {
Expand Down Expand Up @@ -205,6 +213,48 @@ describe(AuthPage.name, () => {
});
});

describe("when ForgotPassword view is open", () => {
it("renders forgot password form when SignInForm notifies about its request", async () => {
const SignInFormMock = jest.fn((() => <span>SignInForm</span>) as typeof SignInForm);
const ForgotPasswordFormMock = jest.fn(() => <span>ForgotPasswordForm</span>);
const { router } = setup({
dependencies: {
SignInForm: SignInFormMock,
ForgotPasswordForm: ForgotPasswordFormMock
}
});

await act(() => {
SignInFormMock.mock.calls[0][0].onForgotPasswordClick?.();
});

await waitFor(() => {
expect(router.replace).toHaveBeenCalledTimes(1);
expect(screen.getByText("ForgotPasswordForm")).toBeInTheDocument();
});
});

it("submits forgot password form and displays success message", async () => {
const ForgotPasswordFormMock = jest.fn(ComponentMock as typeof DEPENDENCIES.ForgotPasswordForm);
const { authService } = setup({
searchParams: {
tab: "forgot-password"
},
dependencies: {
ForgotPasswordForm: ForgotPasswordFormMock
}
});

await act(() => {
ForgotPasswordFormMock.mock.calls[0][0].onSubmit({ email: "test@example.com" });
});

await waitFor(() => {
expect(authService.sendPasswordResetEmail).toHaveBeenCalledWith({ email: "test@example.com", captchaToken: "test-captcha-token" });
});
});
});

function setup(input: {
searchParams?: {
tab?: "login" | "signup" | "forgot-password";
Expand All @@ -214,7 +264,13 @@ describe(AuthPage.name, () => {
dependencies?: Partial<typeof DEPENDENCIES>;
}) {
const authService = mock<AuthService>();
const router = mock<NextRouter>();
let setRouterPageParams: (params: URLSearchParams) => void = () => {};
const router = mock<NextRouter>({
replace: jest.fn(url => {
setRouterPageParams?.(new URL(url as string, "http://localunittest:8080").searchParams);
return Promise.resolve(true);
})
});
const checkSession = jest.fn(async () => undefined);
const useUserMock: typeof DEPENDENCIES.useUser = () => ({
checkSession,
Expand All @@ -231,7 +287,20 @@ describe(AuthPage.name, () => {
if (input.searchParams?.returnTo) {
params.set("returnTo", input.searchParams.returnTo);
}
const useSearchParamsMock = () => params as ReadonlyURLSearchParams;
const useSearchParamsMock = () => {
const [pageParams, setPageParams] = useState(params);
setRouterPageParams = setPageParams;
return pageParams as ReadonlyURLSearchParams;
};

const TurnstileMock = jest.fn(({ turnstileRef }: { turnstileRef?: RefObject<TurnstileRef> }) => {
if (turnstileRef) {
(turnstileRef as { current: TurnstileRef }).current = {
renderAndWaitResponse: jest.fn().mockResolvedValue({ token: "test-captcha-token" })
};
}
return null;
});

render(
<TestContainerProvider services={{ authService: () => authService }}>
Expand All @@ -241,6 +310,7 @@ describe(AuthPage.name, () => {
useUser: useUserMock,
useSearchParams: useSearchParamsMock,
useRouter: () => router,
Turnstile: TurnstileMock,
...input.dependencies
}}
/>
Expand Down
55 changes: 42 additions & 13 deletions apps/deploy-web/src/components/auth/AuthPage/AuthPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { Separator, Tabs, TabsContent, TabsList, TabsTrigger } from "@akashnetwork/ui/components";
import { useMutation } from "@tanstack/react-query";
import { DollarSignIcon, RocketIcon, ZapIcon } from "lucide-react";
Expand All @@ -10,6 +10,8 @@ import { NextSeo } from "next-seo";

import { AkashConsoleLogo } from "@src/components/icons/AkashConsoleLogo";
import { RemoteApiError } from "@src/components/shared/RemoteApiError/RemoteApiError";
import type { TurnstileRef } from "@src/components/turnstile/Turnstile";
import { ClientOnlyTurnstile } from "@src/components/turnstile/Turnstile";
import { useServices } from "@src/context/ServicesProvider";
import { useUser } from "@src/hooks/useUser";
import { AuthLayout } from "../AuthLayout/AuthLayout";
Expand All @@ -28,10 +30,16 @@ export const DEPENDENCIES = {
SignUpForm,
RemoteApiError,
ForgotPasswordForm,
Turnstile: ClientOnlyTurnstile,
Tabs,
TabsContent,
TabsTrigger,
TabsList,
DollarSignIcon,
RocketIcon,
ZapIcon,
AkashConsoleLogo,
Separator,
useUser,
useSearchParams,
useRouter
Expand All @@ -42,11 +50,12 @@ interface Props {
}

export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
const { authService } = useServices();
const { authService, publicConfig } = useServices();
const router = d.useRouter();
const searchParams = d.useSearchParams();
const { checkSession } = d.useUser();
const [email, setEmail] = useState("");
const turnstileRef = useRef<TurnstileRef | null>(null);

const redirectToSocialLogin = useCallback(
async (provider: "github" | "google-oauth2") => {
Expand All @@ -57,11 +66,17 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
);
const signInOrSignUp = useMutation({
async mutationFn(input: Tagged<"signin", SignInFormValues> | Tagged<"signup", SignUpFormValues>) {
if (!turnstileRef.current) {
throw new Error("Captcha has not been rendered");
}
const returnUrl = searchParams.get("from") || searchParams.get("returnTo") || "/";

const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse();

if (input.type === "signin") {
await authService.login(input.value);
await authService.login({ ...input.value, captchaToken });
} else {
await authService.signup(input.value);
await authService.signup({ ...input.value, captchaToken });
}
await checkSession();
router.push(returnUrl);
Expand All @@ -74,17 +89,24 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
const tabId = value !== "login" && value !== "signup" && value !== "forgot-password" ? "login" : value;
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("tab", tabId);
signInOrSignUp.reset();
forgotPassword.reset();
resetMutations();
router.replace(`?${newSearchParams.toString()}`, undefined, { shallow: true });
},
[searchParams, router]
);
const forgotPassword = useMutation({
async mutationFn(input: { email: string }) {
await authService.sendPasswordResetEmail({ email: input.email });
if (!turnstileRef.current) {
throw new Error("Captcha has not been rendered");
}
const { token: captchaToken } = await turnstileRef.current.renderAndWaitResponse();
await authService.sendPasswordResetEmail({ email: input.email, captchaToken });
}
});
const resetMutations = useCallback(() => {
signInOrSignUp.reset();
forgotPassword.reset();
}, [signInOrSignUp, forgotPassword]);

return (
<d.AuthLayout
Expand All @@ -94,7 +116,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
<p>The fastest way to deploy an application on Akash.Network</p>
<div className="flex gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-[#E5E5E5] bg-white" style={{ color: "hsl(var(--background))" }}>
<ZapIcon />
<d.ZapIcon />
</div>
<div className="flex-1">
<h5 className="font-semibold">Generous Free Trial</h5>
Expand All @@ -103,7 +125,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-[#E5E5E5] bg-white" style={{ color: "hsl(var(--background))" }}>
<RocketIcon />
<d.RocketIcon />
</div>
<div className="flex-1">
<h5 className="font-semibold">Optimized for AI/ML</h5>
Expand All @@ -112,7 +134,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-[#E5E5E5] bg-white" style={{ color: "hsl(var(--background))" }}>
<DollarSignIcon />
<d.DollarSignIcon />
</div>
<div className="flex-1">
<h5 className="font-semibold">Cost Savings</h5>
Expand All @@ -126,7 +148,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
<d.NextSeo title="Log in or Sign up" />
<div className="w-full max-w-[576px] rounded-[var(--radius)] bg-[hsl(var(--background))] px-3 py-4 sm:px-6 lg:rounded-none">
<div>
<AkashConsoleLogo className="mb-4 lg:hidden" size={{ width: 291, height: 32 }} />
<d.AkashConsoleLogo className="mb-4 lg:hidden" size={{ width: 291, height: 32 }} />
<h1 className="text-xl font-bold leading-tight text-neutral-950 lg:text-4xl lg:leading-10 dark:text-[var(--foreground)]">
{(activeView === "forgot-password" && "Reset your password") || (
<div className="flex items-center">
Expand All @@ -142,9 +164,10 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
</p>
</div>

<div className="relative mt-6 w-full">
<div className="relative mt-4 w-full">
{(activeView === "forgot-password" && (
<>
test me here?
<d.RemoteApiError className="mb-5" error={forgotPassword.error} />
<d.ForgotPasswordForm
defaultEmail={email}
Expand Down Expand Up @@ -187,7 +210,7 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
<d.SocialAuth onSocialLogin={redirectToSocialLogin} />

<div className="relative flex items-center justify-center self-stretch py-2.5">
<Separator className="absolute inset-0 top-1/2" />
<d.Separator className="absolute inset-0 top-1/2" />
<div className="current relative top-[-1px] z-10 px-2" style={{ backgroundColor: "hsl(var(--background))" }}>
<span className="relative top-1/2 text-xs font-normal text-neutral-500 dark:text-neutral-400">Or continue with</span>
</div>
Expand All @@ -210,6 +233,12 @@ export function AuthPage({ dependencies: d = DEPENDENCIES }: Props = {}) {
</d.TabsContent>
</d.Tabs>
)}
<d.Turnstile
turnstileRef={turnstileRef}
enabled={publicConfig.NEXT_PUBLIC_TURNSTILE_ENABLED}
siteKey={publicConfig.NEXT_PUBLIC_TURNSTILE_SITE_KEY}
onDismissed={resetMutations}
/>
</div>
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,13 @@ export function SignInForm({ dependencies: d = DEPENDENCIES, ...props }: Props)
<>
<div>Password</div>
<div>
<d.Link className="text-xs text-current underline hover:no-underline" prefetch={false} href="#" onClick={onForgotPasswordClick}>
<d.Link
className="text-xs text-current underline hover:no-underline"
prefetch={false}
href="#"
onClick={onForgotPasswordClick}
tabIndex={-1}
>
Forgot password?
</d.Link>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ describe(TrendIndicator.name, () => {
function setup(props: TrendIndicatorProps<"totalUsdSpent", UsageHistory>) {
const defaultProps: TrendIndicatorProps<"totalUsdSpent", UsageHistory> = {
components: {
GraphUp: () => <div>Graph Up</div>,
GraphDown: () => <div>Graph Down</div>
GraphUp: (() => <span>Graph Up</span>) as unknown as Required<TrendIndicatorProps<"totalUsdSpent", UsageHistory>>["components"]["GraphUp"],
GraphDown: (() => <span>Graph Down</span>) as unknown as Required<TrendIndicatorProps<"totalUsdSpent", UsageHistory>>["components"]["GraphDown"]
},
isFetching: props.isFetching ?? false,
data: props.data ?? [
Expand Down
Loading
Loading