From 2faebe77f016f4a0bf2331a4a1491b58028be8ea Mon Sep 17 00:00:00 2001 From: Moinul Moin Date: Fri, 8 Dec 2023 17:59:49 +0600 Subject: [PATCH 1/3] feat: move to lucia auth(github oauth only for now) --- .eslintignore | 3 +- .eslintrc.json | 2 +- auth.d.ts | 10 ++ package.json | 3 + pnpm-lock.yaml | 31 ++++ .../migration.sql | 48 +++--- .../migrations/20231208111448_/migration.sql | 8 + prisma/schema.prisma | 62 +++----- .../(.)login}/page.tsx | 0 .../default.tsx | 0 src/app/api/auth/[...nextauth]/route.ts | 6 - .../api/auth/login/github/callback/route.ts | 64 ++++++++ src/app/api/auth/login/github/route.ts | 19 +++ src/app/api/auth/logout/route.ts | 25 ++++ src/app/api/uploadthing/core.ts | 10 +- src/app/dashboard/layout.tsx | 8 +- src/app/dashboard/projects/action.ts | 23 +-- src/app/dashboard/settings/settings-form.tsx | 35 +---- src/app/layout.tsx | 6 +- src/app/{signin => login}/page.tsx | 0 src/components/layout/auth-form.tsx | 139 ++++++++---------- src/components/layout/header/navbar.tsx | 8 +- src/components/layout/image-upload-modal.tsx | 2 +- src/components/layout/user-nav.tsx | 18 ++- src/lib/auth.ts | 124 ++++------------ src/middleware.ts | 32 ---- src/server/user.ts | 7 +- src/server/utils.ts | 4 +- src/types/index.ts | 8 +- src/types/next-auth.d.ts | 18 --- 30 files changed, 355 insertions(+), 368 deletions(-) create mode 100644 auth.d.ts rename prisma/migrations/{20231111061416_stripe => 20231208111437_}/migration.sql (59%) create mode 100644 prisma/migrations/20231208111448_/migration.sql rename src/app/{@signinDialog/(.)signin => @loginDialog/(.)login}/page.tsx (100%) rename src/app/{@signinDialog => @loginDialog}/default.tsx (100%) delete mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/login/github/callback/route.ts create mode 100644 src/app/api/auth/login/github/route.ts create mode 100644 src/app/api/auth/logout/route.ts rename src/app/{signin => login}/page.tsx (100%) delete mode 100644 src/middleware.ts delete mode 100644 src/types/next-auth.d.ts diff --git a/.eslintignore b/.eslintignore index 3bf2237..d5ca4be 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ -ui/ \ No newline at end of file +ui/ +*.d.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 7e37e8c..0001d9d 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -20,7 +20,7 @@ } ], "@typescript-eslint/no-unused-vars": [ - "error", + "warn", { "argsIgnorePattern": "^_" } diff --git a/auth.d.ts b/auth.d.ts new file mode 100644 index 0000000..9ab4f66 --- /dev/null +++ b/auth.d.ts @@ -0,0 +1,10 @@ +/// +declare namespace Lucia { + type Auth = import("./src/lib/auth").Auth; + type DatabaseUserAttributes = { + name: string; + email: string; + picture: string; + }; + type DatabaseSessionAttributes = {}; +} diff --git a/package.json b/package.json index 912e720..23dafd8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@ducanh2912/next-pwa": "^9.7.1", "@hookform/resolvers": "^3.3.2", + "@lucia-auth/adapter-prisma": "^3.0.2", + "@lucia-auth/oauth": "^3.5.0", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.4.2", "@radix-ui/react-alert-dialog": "^1.0.5", @@ -28,6 +30,7 @@ "@react-email/components": "^0.0.7", "@uploadthing/react": "^5.6.2", "date-fns": "^2.30.0", + "lucia": "^2.7.4", "lucide-react": "^0.286.0", "nanoid": "^5.0.1", "next": "14.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f85a6f..2585010 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ dependencies: '@hookform/resolvers': specifier: ^3.3.2 version: 3.3.2(react-hook-form@7.47.0) + '@lucia-auth/adapter-prisma': + specifier: ^3.0.2 + version: 3.0.2(@prisma/client@5.4.2)(lucia@2.7.4) + '@lucia-auth/oauth': + specifier: ^3.5.0 + version: 3.5.0(lucia@2.7.4) '@next-auth/prisma-adapter': specifier: ^1.0.7 version: 1.0.7(@prisma/client@5.4.2)(next-auth@4.24.5) @@ -56,6 +62,9 @@ dependencies: date-fns: specifier: ^2.30.0 version: 2.30.0 + lucia: + specifier: ^2.7.4 + version: 2.7.4 lucide-react: specifier: ^0.286.0 version: 0.286.0(react@18.2.0) @@ -1891,6 +1900,24 @@ packages: tslib: 2.6.2 dev: true + /@lucia-auth/adapter-prisma@3.0.2(@prisma/client@5.4.2)(lucia@2.7.4): + resolution: {integrity: sha512-EyJWZene1/zasPwPctv8wwNErZt5mwwm5JATbhg+kXr3R8pbC7lJfVzDTAeeFClVH5k/FywRcsBl3JkPaNIcow==} + peerDependencies: + '@prisma/client': ^4.2.0 || ^5.0.0 + lucia: ^2.0.0 + dependencies: + '@prisma/client': 5.4.2(prisma@5.4.2) + lucia: 2.7.4 + dev: false + + /@lucia-auth/oauth@3.5.0(lucia@2.7.4): + resolution: {integrity: sha512-JSwAMVwlDJtbvfcJV1nbkv41OD830pgICrx8zFT71SYd5I1MnEQ+GqMTXBGRyxzc5XlLatT8ZS1Jt0k81487xg==} + peerDependencies: + lucia: ^2.0.0 + dependencies: + lucia: 2.7.4 + dev: false + /@mdx-js/esbuild@2.3.0(esbuild@0.19.5): resolution: {integrity: sha512-r/vsqsM0E+U4Wr0DK+0EfmABE/eg+8ITW4DjvYdh3ve/tK2safaqHArNnaqbOk1DjYGrhxtoXoGaM3BY8fGBTA==} peerDependencies: @@ -6453,6 +6480,10 @@ packages: dependencies: yallist: 4.0.0 + /lucia@2.7.4: + resolution: {integrity: sha512-do6Aah5kX2DUl7N0CvELWV1/b+qZGF0AUmUrkthYeNfXfgx4RAkWi4BkAlgGDBB/0c1WApb18mUD1pA5qTaWmw==} + dev: false + /lucide-react@0.286.0(react@18.2.0): resolution: {integrity: sha512-0+AOFa/uiXlXJJTqcKto1gqbU9XflYgYZbS9DN2ytSIhSBQaF5xfRKAq/k0okBInpgu5P6i7dhCcgbHV4OMkHQ==} peerDependencies: diff --git a/prisma/migrations/20231111061416_stripe/migration.sql b/prisma/migrations/20231208111437_/migration.sql similarity index 59% rename from prisma/migrations/20231111061416_stripe/migration.sql rename to prisma/migrations/20231208111437_/migration.sql index d1c139b..0ed9333 100644 --- a/prisma/migrations/20231111061416_stripe/migration.sql +++ b/prisma/migrations/20231208111437_/migration.sql @@ -1,29 +1,20 @@ -- CreateTable -CREATE TABLE "Account" ( +CREATE TABLE "Session" ( "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("id") + "user_id" TEXT NOT NULL, + "active_expires" BIGINT NOT NULL, + "idle_expires" BIGINT NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") ); -- CreateTable -CREATE TABLE "Session" ( +CREATE TABLE "Key" ( "id" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, + "hashed_password" TEXT, + "user_id" TEXT NOT NULL, - CONSTRAINT "Session_pkey" PRIMARY KEY ("id") + CONSTRAINT "Key_pkey" PRIMARY KEY ("id") ); -- CreateTable @@ -31,9 +22,7 @@ CREATE TABLE "User" ( "id" TEXT NOT NULL, "name" TEXT, "email" TEXT, - "emailVerified" TIMESTAMP(3), - "image" TEXT, - "shortBio" TEXT, + "picture" TEXT, "stripe_customer_id" TEXT, "stripe_subscription_id" TEXT, "stripe_price_id" TEXT, @@ -61,16 +50,19 @@ CREATE TABLE "Project" ( ); -- CreateIndex -CREATE INDEX "Account_userId_idx" ON "Account"("userId"); +CREATE UNIQUE INDEX "Session_id_key" ON "Session"("id"); + +-- CreateIndex +CREATE INDEX "Session_user_id_idx" ON "Session"("user_id"); -- CreateIndex -CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); +CREATE UNIQUE INDEX "Key_id_key" ON "Key"("id"); -- CreateIndex -CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); +CREATE INDEX "Key_user_id_idx" ON "Key"("user_id"); -- CreateIndex -CREATE INDEX "Session_userId_idx" ON "Session"("userId"); +CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); @@ -88,10 +80,10 @@ CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token" CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); -- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Session" ADD CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Key" ADD CONSTRAINT "Key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20231208111448_/migration.sql b/prisma/migrations/20231208111448_/migration.sql new file mode 100644 index 0000000..74cc42d --- /dev/null +++ b/prisma/migrations/20231208111448_/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the `VerificationToken` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "VerificationToken"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a8d5d8a..bf34b04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,60 +11,38 @@ datasource db { directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection } -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +model Session { + id String @id @unique @default(cuid()) + user_id String + active_expires BigInt + idle_expires BigInt + user User @relation(references: [id], fields: [user_id], onDelete: Cascade) - @@unique([provider, providerAccountId]) - @@index([userId]) + @@index([user_id]) } -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) +model Key { + id String @id @unique @default(cuid()) + hashed_password String? + user_id String + user User @relation(references: [id], fields: [user_id], onDelete: Cascade) - @@index([userId]) + @@index([user_id]) } model User { - id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? - image String? - shortBio String? + id String @id @unique @default(cuid()) + name String? + email String? @unique + picture String? + auth_session Session[] + keys Key[] + projects Project[] stripeCustomerId String? @unique @map(name: "stripe_customer_id") stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") stripePriceId String? @map(name: "stripe_price_id") stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end") - - accounts Account[] - sessions Session[] - projects Project[] -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) } model Project { diff --git a/src/app/@signinDialog/(.)signin/page.tsx b/src/app/@loginDialog/(.)login/page.tsx similarity index 100% rename from src/app/@signinDialog/(.)signin/page.tsx rename to src/app/@loginDialog/(.)login/page.tsx diff --git a/src/app/@signinDialog/default.tsx b/src/app/@loginDialog/default.tsx similarity index 100% rename from src/app/@signinDialog/default.tsx rename to src/app/@loginDialog/default.tsx diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index aba56e2..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import NextAuth from "next-auth"; -import { authOptions } from "~/lib/auth"; - -const handler = NextAuth(authOptions); - -export { handler as GET, handler as POST }; diff --git a/src/app/api/auth/login/github/callback/route.ts b/src/app/api/auth/login/github/callback/route.ts new file mode 100644 index 0000000..1f0ad64 --- /dev/null +++ b/src/app/api/auth/login/github/callback/route.ts @@ -0,0 +1,64 @@ +import { OAuthRequestError } from "@lucia-auth/oauth"; +import { cookies, headers } from "next/headers"; +import { auth, githubAuth } from "~/lib/auth"; + +import type { NextRequest } from "next/server"; + +export const GET = async (request: NextRequest) => { + const storedState = cookies().get("github_oauth_state")?.value; + const url = new URL(request.url); + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + // validate state + if (!storedState || !state || storedState !== state || !code) { + return new Response(null, { + status: 400, + }); + } + try { + const { getExistingUser, githubUser, createUser } = + await githubAuth.validateCallback(code); + + const getUser = async () => { + const existingUser = await getExistingUser(); + if (existingUser) return existingUser; + const user = await createUser({ + attributes: { + name: githubUser.name!, + email: githubUser.email!, + picture: githubUser.avatar_url, + }, + }); + return user; + }; + + const user = await getUser(); + const session = await auth.createSession({ + userId: user.userId, + attributes: {}, + }); + const authRequest = auth.handleRequest(request.method, { + cookies, + headers, + }); + authRequest.setSession(session); + return new Response(null, { + status: 302, + headers: { + Location: "/dashboard", // redirect to profile page + }, + }); + } catch (e) { + console.log(e); + + if (e instanceof OAuthRequestError) { + // invalid code + return new Response(null, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +}; diff --git a/src/app/api/auth/login/github/route.ts b/src/app/api/auth/login/github/route.ts new file mode 100644 index 0000000..813d3b8 --- /dev/null +++ b/src/app/api/auth/login/github/route.ts @@ -0,0 +1,19 @@ +import * as context from "next/headers"; +import { githubAuth } from "~/lib/auth"; + +export const GET = async () => { + const [url, state] = await githubAuth.getAuthorizationUrl(); + // store state + context.cookies().set("github_oauth_state", state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60, + }); + return new Response(null, { + status: 302, + headers: { + Location: url.toString(), + }, + }); +}; diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..b83f173 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,25 @@ +import * as context from "next/headers"; +import { auth } from "~/lib/auth"; + +import type { NextRequest } from "next/server"; + +export const POST = async (request: NextRequest) => { + const authRequest = auth.handleRequest(request.method, context); + // check if user is authenticated + const session = await authRequest.validate(); + if (!session) { + return new Response(null, { + status: 401, + }); + } + // make sure to invalidate the current session! + await auth.invalidateSession(session.sessionId); + // delete session cookie + authRequest.setSession(null); + return new Response(null, { + status: 302, + headers: { + Location: "/login", // redirect to login page + }, + }); +}; diff --git a/src/app/api/uploadthing/core.ts b/src/app/api/uploadthing/core.ts index fbcb403..68946a9 100644 --- a/src/app/api/uploadthing/core.ts +++ b/src/app/api/uploadthing/core.ts @@ -1,5 +1,5 @@ -import { getToken } from "next-auth/jwt"; import { createUploadthing, type FileRouter } from "uploadthing/next"; +import { getPageSession } from "~/lib/auth"; const f = createUploadthing(); @@ -8,15 +8,15 @@ export const ourFileRouter = { // Define as many FileRoutes as you like, each with a unique routeSlug imageUploader: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } }) // Set permissions and file types for this FileRoute - .middleware(async ({ req }) => { + .middleware(async () => { // This code runs on your server before upload - const token = await getToken({ req }); + const session = await getPageSession(); // If you throw, the user will not be able to upload - if (!token) throw new Error("Unauthorized!"); + if (!session) throw new Error("Unauthorized!"); // Whatever is returned here is accessible in onUploadComplete as `metadata` - return { userId: token.id }; + return { userId: session.user.userId }; }) .onUploadComplete(async () => { // This code runs on your server after upload diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index c12f2c3..3bf4c68 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,10 +1,16 @@ +import { redirect } from "next/navigation"; import SidebarNav from "~/components/layout/sidebar-nav"; +import { getPageSession } from "~/lib/auth"; interface DashboardLayoutProps { children: React.ReactNode; } -export default function DashboardLayout({ children }: DashboardLayoutProps) { +export default async function DashboardLayout({ + children, +}: DashboardLayoutProps) { + const session = await getPageSession(); + if (!session) redirect("/login"); return (
diff --git a/src/app/dashboard/projects/action.ts b/src/app/dashboard/projects/action.ts index e7d76d7..3483e66 100644 --- a/src/app/dashboard/projects/action.ts +++ b/src/app/dashboard/projects/action.ts @@ -1,12 +1,11 @@ "use server"; import { type Project } from "@prisma/client"; -import { getServerSession, type Session } from "next-auth"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { authOptions } from "~/lib/auth"; import db from "~/lib/db"; import { getUserSubscriptionPlan } from "~/lib/subscription"; +import { getUser } from "~/server/user"; interface Payload { name: string; @@ -21,7 +20,7 @@ export async function createProject(payload: Payload) { ...payload, user: { connect: { - id: user.id, + id: user?.id, }, }, }, @@ -32,7 +31,7 @@ export async function createProject(payload: Payload) { export async function checkIfFreePlanLimitReached() { const user = await getUser(); - const subscriptionPlan = await getUserSubscriptionPlan(user.id); + const subscriptionPlan = await getUserSubscriptionPlan(user?.id as string); // If user is on a free plan. // Check if user has reached limit of 3 projects. @@ -40,7 +39,7 @@ export async function checkIfFreePlanLimitReached() { const count = await db.project.count({ where: { - userId: user.id, + userId: user?.id, }, }); @@ -51,7 +50,7 @@ export async function getProjects() { const user = await getUser(); const projects = await db.project.findMany({ where: { - userId: user.id, + userId: user?.id, }, orderBy: { createdAt: "desc", @@ -65,7 +64,7 @@ export async function getProjectById(id: string) { const project = await db.project.findFirst({ where: { id, - userId: user.id, + userId: user?.id, }, }); return project as Project; @@ -76,7 +75,7 @@ export async function updateProjectById(id: string, payload: Payload) { await db.project.update({ where: { id, - userId: user.id, + userId: user?.id, }, data: payload, }); @@ -88,15 +87,9 @@ export async function deleteProjectById(id: string) { await db.project.delete({ where: { id, - userId: user.id, + userId: user?.id, }, }); revalidatePath(`/dashboard/projects`); redirect("/dashboard/projects"); } - -export async function getUser() { - const session = (await getServerSession(authOptions)) as Session; - - return session.user; -} diff --git a/src/app/dashboard/settings/settings-form.tsx b/src/app/dashboard/settings/settings-form.tsx index 44fb54b..7e85372 100644 --- a/src/app/dashboard/settings/settings-form.tsx +++ b/src/app/dashboard/settings/settings-form.tsx @@ -17,7 +17,6 @@ import { FormMessage, } from "~/components/ui/form"; import { Input } from "~/components/ui/input"; -import { Textarea } from "~/components/ui/textarea"; import { toast } from "~/components/ui/use-toast"; import { saRemoveNewImageFromCDN, @@ -48,28 +47,27 @@ export default function SettingsForm({ values: { name: currentUser.name, email: currentUser.email, - shortBio: currentUser.shortBio, - image: currentUser.image, + picture: currentUser.picture, }, }); const { formState, getFieldState } = form; - const { isDirty: isImageChanged } = getFieldState("image"); + const { isDirty: isImageChanged } = getFieldState("picture"); const [showConfirmAlert, setShowConfirmAlert] = useState(false); useEffect(() => { - if (isImageChanged && currentUser.image !== oldImage.current) { - oldImage.current = currentUser.image; + if (isImageChanged && currentUser.picture !== oldImage.current) { + oldImage.current = currentUser.picture; } - }, [currentUser.image, isImageChanged]); + }, [currentUser.picture, isImageChanged]); function onSubmit(data: SettingsValues) { if (!formState.isDirty) return; if (isImageChanged) { startTransition(() => - saRemoveUserOldImageFromCDN(currentUser.id, data.image) + saRemoveUserOldImageFromCDN(currentUser.id, data.picture) .then(() => saUpdateUserInDb(currentUser.id, data)) .then(() => { toast({ @@ -103,7 +101,7 @@ export default function SettingsForm({ function handleReset() { if (isImageChanged) { - saRemoveNewImageFromCDN(form.getValues().image) + saRemoveNewImageFromCDN(form.getValues().picture) .then(() => form.reset()) .catch((error) => console.error(error)); } else { @@ -119,7 +117,7 @@ export default function SettingsForm({ > ( Picture @@ -169,23 +167,6 @@ export default function SettingsForm({ )} /> - ( - - Short Bio - -