Skip to content

Commit

Permalink
Merge pull request #652 from crystal4000/feat/Authentication/Login
Browse files Browse the repository at this point in the history
Feat/authentication/login
  • Loading branch information
Prudent Bird authored Jul 24, 2024
2 parents 08879a4 + 3716387 commit 7abf700
Show file tree
Hide file tree
Showing 11 changed files with 785 additions and 63 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
Expand Down
32 changes: 32 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/app/(auth-routes)/login/magic-link/link-sent/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import MagicLinkSuccess from "./page";

type ModuleType = typeof import("./page");

vi.mock("./page", async () => {
const actualModule: ModuleType = await vi.importActual("./page");
return {
default: actualModule.default,
handleOpenEmail: vi.fn(),
};
});

describe("magicLinkSuccess", () => {
it("renders the success message", () => {
expect.hasAssertions();
render(<MagicLinkSuccess />);
expect(screen.getByText("Sent! Check your email.")).toBeInTheDocument();
});

it("displays the check icon", () => {
expect.hasAssertions();
render(<MagicLinkSuccess />);
const checkIcon = screen.getByTestId("check-icon");
expect(checkIcon).toBeInTheDocument();
expect(checkIcon).toHaveClass("h-20 w-20 text-center text-white");
});

it("shows the email address in the instructions", () => {
expect.hasAssertions();
render(<MagicLinkSuccess />);
expect(screen.getByText(/[email protected]/)).toBeInTheDocument();
});

it('renders the "Open Email" button', () => {
expect.hasAssertions();
render(<MagicLinkSuccess />);
expect(
screen.getByRole("button", { name: "Open Email" }),
).toBeInTheDocument();
});
});
45 changes: 45 additions & 0 deletions src/app/(auth-routes)/login/magic-link/link-sent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { Check } from "lucide-react";

import { Button } from "~/components/ui/button";

export const handleOpenEmail = () => {
window.location.href = "mailto:";
};
const MagicLinkSuccess = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-0 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="font-inter text-neutralColor-dark-2 mb-5 text-center text-2xl font-medium leading-tight">
Sent! Check your email.
</h1>

<div className="mx-auto mt-5 flex h-24 w-24 items-center justify-center rounded-full bg-success">
<Check
className="mx-auto h-20 w-20 text-center text-white"
data-testid="check-icon"
/>
</div>

<p className="font-inter text-neutralColor-dark-2 mt-8 text-center text-base font-normal leading-6">
We have sent an email to [email protected]. It contains
instructions on how to get started.
</p>

<Button
variant="outline"
type="button"
onClick={handleOpenEmail}
className="mt-10 h-12 w-full rounded-md border border-primary bg-white px-4 py-3 text-primary hover:bg-orange-600 hover:text-white focus:outline-none"
>
Open Email
</Button>
</div>
</div>
</div>
);
};

export default MagicLinkSuccess;
75 changes: 75 additions & 0 deletions src/app/(auth-routes)/login/magic-link/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { useToast } from "~/components/ui/use-toast";
import LoginMagicLink from "./page";

vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));

vi.mock("~/components/ui/use-toast", () => ({
useToast: vi.fn(),
}));

describe("loginMagicLink", () => {
const mockPush = vi.fn();
const mockToast = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({ push: mockPush });
(useToast as jest.Mock).mockReturnValue({ toast: mockToast });
});

it("renders the login form", () => {
expect.hasAssertions();
render(<LoginMagicLink />);
expect(screen.getByText("Login With Magic Link")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("Enter Email Address"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Get Magic Link" }),
).toBeInTheDocument();
});

it("displays an error message for invalid email", async () => {
expect.hasAssertions();
render(<LoginMagicLink />);
const emailInput = screen.getByPlaceholderText("Enter Email Address");
const submitButton = screen.getByRole("button", { name: "Get Magic Link" });

fireEvent.change(emailInput, { target: { value: "invalid-email" } });
fireEvent.click(submitButton);

// eslint-disable-next-line testing-library/await-async-utils
vi.waitFor(() => {
const emailError = screen.queryByTestId("email-error");
expect(emailError).toBeInTheDocument();
});
});

it("submits the form with valid email and redirects", async () => {
expect.hasAssertions();
render(<LoginMagicLink />);
const emailInput = screen.getByPlaceholderText("Enter Email Address");
const submitButton = screen.getByRole("button", { name: "Get Magic Link" });

fireEvent.change(emailInput, { target: { value: "[email protected]" } });
fireEvent.click(submitButton);

// eslint-disable-next-line testing-library/await-async-utils
vi.waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/login/magic-link/link-sent");
});
});

it("renders the terms and privacy policy links", () => {
expect.hasAssertions();
render(<LoginMagicLink />);
expect(screen.getByText("Terms of Service")).toBeInTheDocument();
expect(screen.getByText("Privacy Policy")).toBeInTheDocument();
});
});
142 changes: 142 additions & 0 deletions src/app/(auth-routes)/login/magic-link/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { useToast } from "~/components/ui/use-toast";

const formSchema = z.object({
email: z.string().email("Please enter a valid email address"),
});

type FormValues = z.infer<typeof formSchema>;

const getInputClassName = (hasError: boolean, isValid: boolean) => {
const baseClasses =
"font-inter w-full rounded-md border px-3 py-3 text-sm font-normal leading-[21.78px] transition duration-150 ease-in-out focus:outline-none focus:ring-1 focus:ring-opacity-50";

if (hasError) {
return `${baseClasses} border-red-500 focus:border-red-500 focus:ring-red-500 text-red-900`;
} else if (isValid) {
return `${baseClasses} border-orange-500 focus:border-orange-500 focus:ring-orange-500 text-neutralColor-dark-2`;
}
return `${baseClasses} border-gray-300 focus:border-orange-500 focus:ring-orange-500 text-neutralColor-dark-2`;
};

const LoginMagicLink: React.FC = () => {
const router = useRouter();
const { toast } = useToast();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});

const onSubmit = async () => {
try {
await new Promise((resolve) => setTimeout(resolve, 1000));
router.push("/login/magic-link/link-sent");
} catch (error: unknown) {
toast({
title: "Login failed",
description:
error instanceof Error ? error.message : "An unknown error occurred",
variant: "destructive",
});
}
};

return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-0 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h1 className="font-inter text-neutralColor-dark-2 mb-5 text-center text-2xl font-semibold leading-tight">
Login With Magic Link
</h1>
</div>

<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mt-8 space-y-6"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<Label
htmlFor="email"
className="text-neutralColor-dark-2 sr-only"
>
Email
</Label>
<FormControl>
<Input
id="email"
type="email"
placeholder="Enter Email Address"
className={getInputClassName(
!!form.formState.errors.email,
form.formState.isValid,
)}
{...field}
/>
</FormControl>
<FormMessage
className="mt-1 text-sm"
data-testid="email-error"
/>
</FormItem>
)}
/>

<Button
type="submit"
variant="default"
size="default"
className="h-12 w-full rounded-md bg-primary px-4 py-3 text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
>
Get Magic Link
</Button>
</form>
</Form>

<p className="mt-2 text-center text-xs text-gray-500">
<ShieldCheck className="mr-1 hidden h-4 w-4 text-gray-400 sm:inline-block" />
By logging in, you agree to the{" "}
<a
href="#"
className="text-sm font-bold text-primary hover:text-orange-500"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="#"
className="text-sm font-bold text-primary hover:text-orange-500"
>
Privacy Policy
</a>
</p>
</div>
</div>
);
};

export default LoginMagicLink;
Loading

0 comments on commit 7abf700

Please sign in to comment.