Skip to content

Commit

Permalink
Merge pull request #56 from emiliosheinz/feat/email-signin
Browse files Browse the repository at this point in the history
feat: email magic link sign in
  • Loading branch information
emiliosheinz authored May 20, 2024
2 parents b34b1ad + a254d59 commit 81d6c0f
Show file tree
Hide file tree
Showing 17 changed files with 494 additions and 234 deletions.
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/sos-pet"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="nextauth-secret"

# Next Auth Providers
# Next Auth Google Sign-In
GOOGLE_CLIENT_ID="google-client-id"
GOOGLE_CLIENT_SECRET="google-client-secret"
# Next Auth Email magic link Sign-In
# https://resend.com/changelog/smtp-service
EMAIL_HOST="smtp.resend.com"
EMAIL_PORT="465"
EMAIL_USER="resend"
EMAIL_PASSWORD="resend-api-key"
EMAIL_FROM="[email protected]"

# Google Maps
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="google-maps-api-key"
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"next": "^14.2.1",
"next-auth": "^4.24.6",
"next-themes": "^0.3.0",
"nodemailer": "^6.9.13",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.4",
Expand All @@ -55,6 +56,7 @@
"devDependencies": {
"@types/eslint": "^8.56.2",
"@types/node": "^20.11.20",
"@types/nodemailer": "^6.4.15",
"@types/react": "^18.2.57",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^7.1.1",
Expand Down
72 changes: 72 additions & 0 deletions src/app/signin/_components/EmailProviderForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2 } from "lucide-react";
import { signIn } from "next-auth/react";
import { useState } 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";

const formSchema = z.object({
email: z.string().email("Por favor, insira um e-mail válido"),
});

export function EmailProviderForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: "" },
});
const [isLoading, setIsLoading] = useState(false);

const onSubmit = async ({ email }: z.infer<typeof formSchema>) => {
setIsLoading(true);
await signIn("email", { email });
setIsLoading(false);
};

return (
<Form {...form}>
<form
className="mt-5 flex flex-col gap-5"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="relative my-5">
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white pb-1 text-lg tracking-widest text-neutral-500">
ou
</span>
<hr />
</div>
<FormField
name="email"
control={form.control}
disabled={isLoading}
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<Loader2 className="animate-spin" />
) : (
"Entrar com e-mail"
)}
</Button>
</form>
</Form>
);
}
30 changes: 30 additions & 0 deletions src/app/signin/_components/SignInProviderButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { type ClientSafeProvider, signIn } from "next-auth/react";
import Image from "next/image";

type SignInProviderButtonProps = {
provider: ClientSafeProvider;
callbackUrl: string;
};

export function SignInProviderButton({
provider,
callbackUrl,
}: SignInProviderButtonProps) {
return (
<button
type="button"
className="inline-flex h-10 w-full items-center justify-center whitespace-nowrap rounded-md border border-neutral-200 bg-white px-4 py-2 text-sm font-medium text-neutral-900 ring-offset-white transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
onClick={() => signIn(provider.id, { callbackUrl })}
>
<Image
src={`/${provider.id}.svg`}
alt={`Icone de ${provider.name}`}
width={32}
height={32}
/>
Entrar com {provider.name}
</button>
);
}
30 changes: 0 additions & 30 deletions src/app/signin/_components/SigninProviderButton.tsx

This file was deleted.

9 changes: 9 additions & 0 deletions src/app/signin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type PropsWithChildren } from "react";

export default function SignInVerifyLayout({ children }: PropsWithChildren) {
return (
<div className="m-auto flex w-full max-w-lg flex-col items-center justify-center gap-5 p-5 pt-28">
{children}
</div>
);
}
88 changes: 59 additions & 29 deletions src/app/signin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,77 @@
import { getProviders } from "next-auth/react";
import {
type ClientSafeProvider,
type LiteralUnion,
getProviders,
} from "next-auth/react";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import { SigninProviderButton } from "./_components/SigninProviderButton";
import { SignInProviderButton } from "./_components/SignInProviderButton";
import Image from "next/image";
import { Suspense } from "react";
import { Loader2 } from "lucide-react";
import { EmailProviderForm } from "./_components/EmailProviderForm";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { FiAlertTriangle } from "react-icons/fi";

type SigninPageProps = {
type SignInPageProps = {
searchParams: Record<string, string>;
};

export default async function SigninPage({ searchParams }: SigninPageProps) {
const session = await getServerAuthSession();
const splitProviders = (
providers: Awaited<ReturnType<typeof getProviders>>,
): [ClientSafeProvider | null, ClientSafeProvider[]] | [null, null] => {
if (!providers) return [null, null];
const email = providers.email;
const other = Object.values(providers).filter(
(provider) => provider.id !== "email",
);
return [email, other];
};

export default async function SignInPage({ searchParams }: SignInPageProps) {
const [session, providers] = await Promise.all([
getServerAuthSession(),
getProviders(),
]);

if (session) {
redirect(searchParams.callbackUrl ?? "/");
}

const providers = [{ id: "google", name: "Google" }];
const [emailProvider, otherProviders] = splitProviders(providers);
const hasError = !!searchParams.error;

return (
<div className="flex flex-col items-center justify-center gap-5 pt-28">
<Suspense fallback={<Loader2 className="size-8 animate-spin" />}>
<Image src="/logo-horizontal.svg" alt="Logo" width={150} height={100} />
<p className="max-w-md text-center">
Em decorrência das enchentes que afetaram o estado do Rio Grande do
Sul, estamos gerenciando as necessidades de abrigos de animais vítimas
do desastre.
<br></br>
<br></br>
Antes de <b>cadastrar um abrigo</b> você precisa <b>fazer login</b>{" "}
com uma das opções abaixo:
</p>
<div className="w-full max-w-md p-5">
{Object.values(providers).map((provider) => (
<SigninProviderButton
key={provider.id}
provider={provider}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
))}
</div>
</Suspense>
</div>
<Suspense fallback={<Loader2 className="size-8 animate-spin" />}>
<Image src="/logo-horizontal.svg" alt="Logo" width={150} height={100} />
<p className="text-center">
Em decorrência das enchentes que afetaram o estado do Rio Grande do Sul,
estamos gerenciando as necessidades de abrigos de animais vítimas do
desastre.
<br></br>
<br></br>
Antes de <b>cadastrar um abrigo</b> você precisa <b>fazer login</b> com
uma das opções abaixo:
</p>
{hasError && (
<Alert variant="destructive">
<FiAlertTriangle className="h-4 w-4" />
<AlertTitle>Erro ao tentar realizar o login.</AlertTitle>
<AlertDescription>
Por favor, tente novamente com outra opção disponível.
</AlertDescription>
</Alert>
)}
<div className="w-full">
{otherProviders?.map((provider) => (
<SignInProviderButton
key={provider.id}
provider={provider}
callbackUrl={searchParams.callbackUrl ?? "/"}
/>
))}
{!!emailProvider && <EmailProviderForm />}
</div>
</Suspense>
);
}
19 changes: 19 additions & 0 deletions src/app/signin/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FaRegCheckCircle } from "react-icons/fa";

export default function SignInVerifyPage() {
return (
<>
<div className="flex flex-col items-center justify-center gap-5 pt-28">
<FaRegCheckCircle className="text-6xl text-green-500" />
<h1 className="text-center text-2xl font-bold">
E-mail enviado com sucesso
</h1>
<p className="max-w-md text-center">
Enviamos um e-mail com um link para você verificar sua conta. Por
favor, <b>verifique sua caixa de entrada</b> para continuar com o
processo de login.
</p>
</div>
</>
);
}
51 changes: 30 additions & 21 deletions src/components/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,41 @@ import Image from "next/image";
import { Nav } from "./nav";
import { Sidebar } from "./sidebar";
import Link from "next/link";
import { Button } from "../ui/button";

export function Header() {
const renderMainSection = () => (
<div className="flex w-full flex-1 items-center gap-14">
<div className="text-xl font-bold">
<Link href="/">
<Image
src="/logo-horizontal.svg"
alt="Logo"
width={130}
height={25}
/>
</Link>
</div>
<ul className="hidden space-x-4 lg:flex">
<li>
<Button asChild variant="link">
<Link href="/">Home</Link>
</Button>
</li>
<li>
<Button asChild variant="link">
<Link href="/about">Sobre</Link>
</Button>
</li>
</ul>
</div>
);

return (
<header className="bg-white py-4 text-black">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-4 lg:justify-between">
<div className="mx-auto flex max-w-7xl items-center gap-3 px-2 lg:justify-between">
<Sidebar />
<div className="flex w-full flex-1 items-center gap-14">
<div className="text-xl font-bold">
<a href="/">
<Image
src="/logo-horizontal.svg"
alt="Logo"
width={150}
height={100}
/>
</a>
</div>
<ul className="hidden space-x-8 lg:flex">
<li>
<Link href="/">Home</Link>
</li>
<li>
<Link href="/about">Sobre</Link>
</li>
</ul>
</div>
{renderMainSection()}
<Nav />
</div>
</header>
Expand Down
Loading

0 comments on commit 81d6c0f

Please sign in to comment.