diff --git a/emails/verification.tsx b/emails/verification.tsx index 47f8ff8..ebc698a 100644 --- a/emails/verification.tsx +++ b/emails/verification.tsx @@ -1,15 +1,11 @@ -// * INFO: Right now, this template is not being used in the project - import { Body, Button, Column, Container, Head, - Hr, Html, Img, - Link, Preview, Row, Section, @@ -62,7 +58,7 @@ const VerificationTemp: React.FC> = ({ Sign In - This link expires in 24 hours and can only be used once. + This link expires in 3 minutes and can only be used once. @@ -70,11 +66,6 @@ const VerificationTemp: React.FC> = ({
ChadNext team
-
- - Developed by{" "} - Moinul Moin - diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 507247b..f77a668 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,20 +11,30 @@ datasource db { } model Session { - id String @id + id String @id @default(cuid()) + userId String + expiresAt DateTime + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} + +model EmailVerificationToken { + id String @id @default(cuid()) userId String + email String expiresAt DateTime user User @relation(references: [id], fields: [userId], onDelete: Cascade) } model User { - id String @id @unique @default(cuid()) - name String? - email String? @unique - picture String? - github_id Int @unique - sessions Session[] - projects Project[] + id String @id @unique @default(cuid()) + name String? + email String? @unique + emailVerified Boolean? @default(false) + picture String? + githubId Int? @unique + sessions Session[] + projects Project[] + emailVerificationTokens EmailVerificationToken[] stripeCustomerId String? @unique @map(name: "stripe_customer_id") stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") diff --git a/src/app/api/auth/email-verify/route.ts b/src/app/api/auth/email-verify/route.ts new file mode 100644 index 0000000..1a9fac4 --- /dev/null +++ b/src/app/api/auth/email-verify/route.ts @@ -0,0 +1,90 @@ +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; +import { cookies } from "next/headers"; +import { type NextRequest } from "next/server"; +import { isWithinExpirationDate } from "oslo"; +import db from "~/lib/db"; +import { lucia } from "~/lib/lucia"; + +export const GET = async (req: NextRequest) => { + const url = new URL(req.url); + const verificationToken = url.searchParams.get("token"); + + if (!verificationToken) { + return new Response("Invalid token", { + status: 400, + }); + } + + try { + const [token] = await db.$transaction([ + db.emailVerificationToken.findFirst({ + where: { + id: verificationToken!, + }, + }), + db.emailVerificationToken.delete({ + where: { + id: verificationToken!, + }, + }), + ]); + + if (!token || !isWithinExpirationDate(token.expiresAt)) { + return new Response("Token expired", { + status: 400, + }); + } + const user = await db.user.findFirst({ + where: { + id: token.userId, + }, + }); + if (!user || user.email !== token.email) { + return new Response("Invalid token", { + status: 400, + }); + } + + await lucia.invalidateUserSessions(user.id); + await db.user.upsert({ + where: { + id: user.id, + emailVerified: false, + }, + update: { + emailVerified: true, + }, + create: {}, + }); + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + }, + }); + } catch (error) { + console.log(error); + if (error instanceof PrismaClientKnownRequestError) { + if ( + error.code === "P2025" || + error.meta?.cause === "Record to delete does not exist." + ) { + return new Response("Token already used", { + status: 404, + }); + } + } + + return new Response("Something went wrong.", { + status: 500, + }); + } +}; diff --git a/src/app/api/auth/login/github/callback/route.ts b/src/app/api/auth/login/github/callback/route.ts index f3eb59b..2f503b4 100644 --- a/src/app/api/auth/login/github/callback/route.ts +++ b/src/app/api/auth/login/github/callback/route.ts @@ -1,9 +1,9 @@ import { OAuth2RequestError } from "arctic"; import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; -import { github, lucia } from "~/lib/auth"; import db from "~/lib/db"; -import { sendMail } from "~/lib/resend"; +import { github, lucia } from "~/lib/lucia"; +import { sendWelcomeEmail } from "~/lib/resend"; export const GET = async (request: NextRequest) => { const url = new URL(request.url); @@ -26,7 +26,7 @@ export const GET = async (request: NextRequest) => { const githubUser: GitHubUser = await githubUserResponse.json(); const existingUser = await db.user.findUnique({ where: { - github_id: githubUser.id, + githubId: githubUser.id, }, }); @@ -48,13 +48,13 @@ export const GET = async (request: NextRequest) => { const newUser = await db.user.create({ data: { - github_id: githubUser.id, + githubId: githubUser.id, name: githubUser.name, email: githubUser.email, picture: githubUser.avatar_url, }, }); - sendMail({ toMail: newUser.email!, data: { name: newUser.name! } }); + sendWelcomeEmail({ toMail: newUser.email!, userName: newUser.name! }); const session = await lucia.createSession(newUser.id, {}); const sessionCookie = lucia.createSessionCookie(session.id); cookies().set( diff --git a/src/app/api/auth/login/github/route.ts b/src/app/api/auth/login/github/route.ts index b1fe636..c1c5fc1 100644 --- a/src/app/api/auth/login/github/route.ts +++ b/src/app/api/auth/login/github/route.ts @@ -1,6 +1,6 @@ import { generateState } from "arctic"; -import { github } from "~/lib/auth"; import { cookies } from "next/headers"; +import { github } from "~/lib/lucia"; export const GET = async () => { const state = generateState(); diff --git a/src/app/api/auth/login/magic-link/route.ts b/src/app/api/auth/login/magic-link/route.ts new file mode 100644 index 0000000..17ee847 --- /dev/null +++ b/src/app/api/auth/login/magic-link/route.ts @@ -0,0 +1,43 @@ +import db from "~/lib/db"; +import { sendVerificationEmail } from "~/lib/resend"; +import { appUrl } from "~/lib/utils"; +import { createEmailVerificationToken } from "~/server/auth"; + +export const POST = async (req: Request) => { + const body = await req.json(); + + try { + const user = await db.user.upsert({ + where: { + email: body.email, + }, + update: {}, + create: { + email: body.email, + emailVerified: false, + }, + }); + + const verificationToken = await createEmailVerificationToken( + user.id, + body.email + ); + const verificationUrl = + appUrl + "/api/auth/email-verify?token=" + verificationToken; + await sendVerificationEmail({ + toMail: body.email, + verificationUrl, + userName: user.name?.split(" ")[0] || "", + }); + + return new Response(null, { + status: 200, + }); + } catch (error) { + console.log(error); + + return new Response(null, { + status: 500, + }); + } +}; diff --git a/src/app/api/stripe/route.ts b/src/app/api/stripe/route.ts index 427a015..a07a5b9 100644 --- a/src/app/api/stripe/route.ts +++ b/src/app/api/stripe/route.ts @@ -1,8 +1,8 @@ import { z } from "zod"; import { proPlan } from "~/config/subscription"; -import { validateRequest } from "~/lib/auth"; import { stripe } from "~/lib/stripe"; import { getUserSubscriptionPlan } from "~/lib/subscription"; +import { validateRequest } from "~/server/auth"; const billingUrl = process.env.NEXT_PUBLIC_APP_URL + "/dashboard/billing"; diff --git a/src/app/api/uploadthing/core.ts b/src/app/api/uploadthing/core.ts index 2746549..b6106f6 100644 --- a/src/app/api/uploadthing/core.ts +++ b/src/app/api/uploadthing/core.ts @@ -1,5 +1,5 @@ import { createUploadthing, type FileRouter } from "uploadthing/next"; -import { validateRequest } from "~/lib/auth"; +import { validateRequest } from "~/server/auth"; const f = createUploadthing(); diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx index 51f17df..c74229e 100644 --- a/src/app/dashboard/billing/page.tsx +++ b/src/app/dashboard/billing/page.tsx @@ -1,9 +1,9 @@ import { AlertTriangleIcon } from "lucide-react"; import { BillingForm } from "~/components/billing-form"; import { Alert, AlertDescription } from "~/components/ui/alert"; -import { validateRequest } from "~/lib/auth"; import { stripe } from "~/lib/stripe"; import { getUserSubscriptionPlan } from "~/lib/subscription"; +import { validateRequest } from "~/server/auth"; export const revalidate = 0; export const dynamic = "force-dynamic"; diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index dcfc5f2..3fac267 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import SidebarNav from "~/components/layout/sidebar-nav"; -import { validateRequest } from "~/lib/auth"; +import { validateRequest } from "~/server/auth"; interface DashboardLayoutProps { children: React.ReactNode; diff --git a/src/app/dashboard/projects/action.ts b/src/app/dashboard/projects/action.ts index cb18160..dc11389 100644 --- a/src/app/dashboard/projects/action.ts +++ b/src/app/dashboard/projects/action.ts @@ -3,9 +3,9 @@ import { type Project } from "@prisma/client"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { validateRequest } from "~/lib/auth"; import db from "~/lib/db"; import { getUserSubscriptionPlan } from "~/lib/subscription"; +import { validateRequest } from "~/server/auth"; interface Payload { name: string; diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 223e653..c015344 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,6 +1,6 @@ import { type User } from "lucia"; import { type Metadata } from "next"; -import { validateRequest } from "~/lib/auth"; +import { validateRequest } from "~/server/auth"; import SettingsForm from "./settings-form"; export const metadata: Metadata = { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b12ca68..71615ca 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import AuthForm from "~/components/layout/auth-form"; import { Card } from "~/components/ui/card"; -import { validateRequest } from "~/lib/auth"; +import { validateRequest } from "~/server/auth"; export default async function Signin() { const { session } = await validateRequest(); diff --git a/src/components/layout/auth-form.tsx b/src/components/layout/auth-form.tsx index abc8aff..7d9db73 100644 --- a/src/components/layout/auth-form.tsx +++ b/src/components/layout/auth-form.tsx @@ -1,16 +1,102 @@ "use client"; +import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; import { Button, buttonVariants } from "~/components/ui/button"; import { cn } from "~/lib/utils"; import Icons from "../shared/icons"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { toast } from "../ui/use-toast"; + +const userAuthSchema = z.object({ + email: z.string().email("Please enter a valid email address."), +}); + +type FormData = z.infer; export default function AuthForm() { const [isLoading, setIsLoading] = useState(false); + const [isGithubLoading, setIsGithubLoading] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(userAuthSchema), + }); + + async function onSubmit(data: FormData) { + setIsLoading(true); + + try { + const res = await fetch("/api/auth/login/magic-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + setIsLoading(false); + + if (!res.ok) { + return toast({ + title: "Failed to send Magic link!", + description: "Please try again later.", + variant: "destructive", + }); + } + + reset(); + toast({ + title: "Magic Link sent!", + description: "Please check your mail inbox", + }); + } catch (error) { + console.log(error); + } + } return (
- {isLoading ? ( +
+
+
+ + + {errors?.email && ( +

+ {errors?.email.message} +

+ )} +
+ +
+
+
+ / +
+ {isGithubLoading ? ( @@ -18,7 +104,7 @@ export default function AuthForm() { setIsLoading(true)} + onClick={() => setIsGithubLoading(true)} > Continue with diff --git a/src/components/layout/header/index.tsx b/src/components/layout/header/index.tsx index c4eb540..88ee115 100644 --- a/src/components/layout/header/index.tsx +++ b/src/components/layout/header/index.tsx @@ -1,5 +1,5 @@ import { type User } from "lucia"; -import { validateRequest } from "~/lib/auth"; +import { validateRequest } from "~/server/auth"; import Navbar from "./navbar"; export default async function Header() { diff --git a/src/components/layout/user-nav.tsx b/src/components/layout/user-nav.tsx index 91ba0ae..fd1b393 100644 --- a/src/components/layout/user-nav.tsx +++ b/src/components/layout/user-nav.tsx @@ -12,7 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "~/components/ui/dropdown-menu"; -import { logout } from "~/server/logout"; +import { logout } from "~/server/auth"; import { Button } from "../ui/button"; export default function UserNav({ user }: { user: User }) { diff --git a/src/components/sections/pricing.tsx b/src/components/sections/pricing.tsx index 9fdc34c..6df600d 100644 --- a/src/components/sections/pricing.tsx +++ b/src/components/sections/pricing.tsx @@ -1,8 +1,8 @@ import Link from "next/link"; import Balancer from "react-wrap-balancer"; -import { validateRequest } from "~/lib/auth"; import { getUserSubscriptionPlan } from "~/lib/subscription"; import { cn } from "~/lib/utils"; +import { validateRequest } from "~/server/auth"; import { Badge } from "../ui/badge"; import { buttonVariants } from "../ui/button"; import { diff --git a/src/lib/lucia.ts b/src/lib/lucia.ts new file mode 100644 index 0000000..f4853e6 --- /dev/null +++ b/src/lib/lucia.ts @@ -0,0 +1,27 @@ +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import { GitHub } from "arctic"; +import { Lucia } from "lucia"; +import db from "./db"; + +const adapter = new PrismaAdapter(db.session, db.user); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + secure: process.env.NODE_ENV === "production", + }, + }, + getUserAttributes: (attributes) => { + return { + name: attributes.name, + email: attributes.email, + picture: attributes.picture, + }; + }, +}); + +export const github = new GitHub( + process.env.GITHUB_CLIENT_ID!, + process.env.GITHUB_CLIENT_SECRET! +); diff --git a/src/lib/resend.ts b/src/lib/resend.ts index 342451b..f0d6d20 100644 --- a/src/lib/resend.ts +++ b/src/lib/resend.ts @@ -1,13 +1,40 @@ import ThanksTemp from "emails/thanks"; +import VerificationTemp from "emails/verification"; import { nanoid } from "nanoid"; import { Resend } from "resend"; -import { type SendMailProps } from "~/types"; +import { + type SendWelcomeEmailProps, + type sendVerificationEmailProps, +} from "~/types"; const resend = new Resend(process.env.RESEND_API_KEY); -export const sendMail = async ({ toMail, data }: SendMailProps) => { +export const sendWelcomeEmail = async ({ + toMail, + userName, +}: SendWelcomeEmailProps) => { const subject = "Thanks for using ChadNext!"; - const temp = ThanksTemp({ userName: data.name }); + const temp = ThanksTemp({ userName }); + + //@ts-expect-error text field is required + await resend.emails.send({ + from: `ChadNext App `, + to: toMail, + subject: subject, + headers: { + "X-Entity-Ref-ID": nanoid(), + }, + react: temp, + }); +}; + +export const sendVerificationEmail = async ({ + toMail, + verificationUrl, + userName, +}: sendVerificationEmailProps) => { + const subject = "Email Verification for ChadNext"; + const temp = VerificationTemp({ userName, verificationUrl }); //@ts-expect-error text field is required await resend.emails.send({ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c61dbd2..02372ef 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -53,3 +53,5 @@ export class FreePlanLimitError extends Error { super(message); } } + +export const appUrl = process.env.NEXT_PUBLIC_APP_URL; diff --git a/src/lib/auth.ts b/src/server/auth.ts similarity index 53% rename from src/lib/auth.ts rename to src/server/auth.ts index a237883..3602785 100644 --- a/src/lib/auth.ts +++ b/src/server/auth.ts @@ -1,27 +1,12 @@ -import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; -import { GitHub } from "arctic"; -import { Lucia, type Session, type User } from "lucia"; +"use server"; + +import { type Session, type User } from "lucia"; import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { TimeSpan, createDate } from "oslo"; import { cache } from "react"; -import db from "./db"; - -const adapter = new PrismaAdapter(db.session, db.user); - -export const lucia = new Lucia(adapter, { - sessionCookie: { - expires: false, - attributes: { - secure: process.env.NODE_ENV === "production", - }, - }, - getUserAttributes: (attributes) => { - return { - name: attributes.name, - email: attributes.email, - picture: attributes.picture, - }; - }, -}); +import db from "~/lib/db"; +import { lucia } from "~/lib/lucia"; export const validateRequest = cache( async (): Promise< @@ -59,7 +44,40 @@ export const validateRequest = cache( } ); -export const github = new GitHub( - process.env.GITHUB_CLIENT_ID!, - process.env.GITHUB_CLIENT_SECRET! -); +export async function logout() { + const { session } = await validateRequest(); + if (!session) { + return { + error: "Unauthorized", + }; + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + return redirect("/login"); +} + +export async function createEmailVerificationToken( + userId: string, + email: string +): Promise { + await db.emailVerificationToken.deleteMany({ + where: { + userId, + }, + }); + const newToken = await db.emailVerificationToken.create({ + data: { + userId, + email, + expiresAt: createDate(new TimeSpan(3, "m")), + }, + }); + return newToken.id; +} diff --git a/src/server/logout.ts b/src/server/logout.ts deleted file mode 100644 index 2212d81..0000000 --- a/src/server/logout.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use server"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { lucia, validateRequest } from "~/lib/auth"; - -export async function logout() { - const { session } = await validateRequest(); - if (!session) { - return { - error: "Unauthorized", - }; - } - - await lucia.invalidateSession(session.id); - - const sessionCookie = lucia.createBlankSessionCookie(); - cookies().set( - sessionCookie.name, - sessionCookie.value, - sessionCookie.attributes - ); - return redirect("/login"); -} diff --git a/src/types/index.ts b/src/types/index.ts index 9c9281c..47c3024 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -44,9 +44,11 @@ export type UserSubscriptionPlan = SubscriptionPlan & isPro: boolean; }; -export interface SendMailProps { +export interface SendWelcomeEmailProps { toMail: string; - data: { - name: string; - }; + userName: string; +} + +export interface sendVerificationEmailProps extends SendWelcomeEmailProps { + verificationUrl: string; } diff --git a/src/types/lucia.d.ts b/src/types/lucia.d.ts index 5044129..77970af 100644 --- a/src/types/lucia.d.ts +++ b/src/types/lucia.d.ts @@ -1,4 +1,4 @@ -import { lucia } from "~/lib/auth"; +import { lucia } from "~/lib/lucia"; declare module "lucia" { interface Register { @@ -8,6 +8,5 @@ declare module "lucia" { name: string; email: string; picture: string; - github_id: number; } }