Skip to content

Commit

Permalink
Merge pull request #155 from moinulmoin/magic-link
Browse files Browse the repository at this point in the history
feat: ✨ magic link login using lucia v3
  • Loading branch information
moinulmoin authored Dec 31, 2023
2 parents 89db64a + 7859f40 commit aa1f509
Show file tree
Hide file tree
Showing 24 changed files with 366 additions and 94 deletions.
11 changes: 1 addition & 10 deletions emails/verification.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -62,19 +58,14 @@ const VerificationTemp: React.FC<Readonly<VerificationTemplateProps>> = ({
Sign In
</Button>
<Text className="mt-2.5 text-sm ">
This link expires in 24 hours and can only be used once.
This link expires in 3 minutes and can only be used once.
</Text>
</Section>
<Text className="mt-8 ">
Best,
<br />
ChadNext team
</Text>
<Hr className="my-8 border-gray-300" />
<Text className="text-sm text-gray-600">
Developed by{" "}
<Link href="https://twitter.com/immoinulmoin">Moinul Moin</Link>
</Text>
</Container>
</Body>
</Tailwind>
Expand Down
26 changes: 18 additions & 8 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
90 changes: 90 additions & 0 deletions src/app/api/auth/email-verify/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
};
10 changes: 5 additions & 5 deletions src/app/api/auth/login/github/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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,
},
});

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/login/github/route.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
43 changes: 43 additions & 0 deletions src/app/api/auth/login/magic-link/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
};
2 changes: 1 addition & 1 deletion src/app/api/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
2 changes: 1 addition & 1 deletion src/app/api/uploadthing/core.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { validateRequest } from "~/lib/auth";
import { validateRequest } from "~/server/auth";

const f = createUploadthing();

Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/projects/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Loading

1 comment on commit aa1f509

@vercel
Copy link

@vercel vercel bot commented on aa1f509 Dec 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.