diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..0b5c1c9 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import 'dotenv/config'; +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_secret_silver_centurion.sql b/drizzle/0000_secret_silver_centurion.sql new file mode 100644 index 0000000..5648570 --- /dev/null +++ b/drizzle/0000_secret_silver_centurion.sql @@ -0,0 +1,50 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean NOT NULL, + "image" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp, + "updated_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..eb0e645 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,319 @@ +{ + "id": "46c47915-d6d4-465f-9006-c016fb9d0c1f", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..e68f37d --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1740226999095, + "tag": "0000_secret_silver_centurion", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index cbe94b5..3da1312 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@ai-sdk/openai": "^1.1.13", "@hookform/resolvers": "^4.1.0", + "@neondatabase/serverless": "^0.10.4", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a084961..d490d15 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@hookform/resolvers': specifier: ^4.1.0 version: 4.1.0(react-hook-form@7.54.2(react@19.0.0)) + '@neondatabase/serverless': + specifier: ^0.10.4 + version: 0.10.4 '@radix-ui/react-accordion': specifier: ^1.2.3 version: 1.2.3(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -82,7 +85,7 @@ importers: version: 16.4.7 drizzle-orm: specifier: ^0.39.3 - version: 0.39.3(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(kysely@0.27.5)(pg@8.13.3) + version: 0.39.3(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(kysely@0.27.5)(pg@8.13.3) lucide-react: specifier: ^0.475.0 version: 0.475.0(react@19.0.0) @@ -1104,6 +1107,9 @@ packages: '@mdx-js/mdx@3.1.0': resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + '@neondatabase/serverless@0.10.4': + resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} + '@next/env@15.1.7': resolution: {integrity: sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==} @@ -1780,6 +1786,9 @@ packages: '@types/pg@8.11.11': resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} + '@types/pg@8.11.6': + resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/react-dom@19.0.3': resolution: {integrity: sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==} peerDependencies: @@ -4287,6 +4296,10 @@ snapshots: - acorn - supports-color + '@neondatabase/serverless@0.10.4': + dependencies: + '@types/pg': 8.11.6 + '@next/env@15.1.7': {} '@next/swc-darwin-arm64@15.1.7': @@ -4951,6 +4964,12 @@ snapshots: pg-protocol: 1.7.1 pg-types: 4.0.2 + '@types/pg@8.11.6': + dependencies: + '@types/node': 20.17.19 + pg-protocol: 1.7.1 + pg-types: 4.0.2 + '@types/react-dom@19.0.3(@types/react@19.0.9)': dependencies: '@types/react': 19.0.9 @@ -5242,8 +5261,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.39.3(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(kysely@0.27.5)(pg@8.13.3): + drizzle-orm@0.39.3(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(kysely@0.27.5)(pg@8.13.3): optionalDependencies: + '@neondatabase/serverless': 0.10.4 '@opentelemetry/api': 1.9.0 '@types/pg': 8.11.11 kysely: 0.27.5 diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..6b047b5 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; // path to your auth file +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); diff --git a/src/components/auth/social-login-button.tsx b/src/components/auth/social-login-button.tsx index f0c89a8..3c01c52 100644 --- a/src/components/auth/social-login-button.tsx +++ b/src/components/auth/social-login-button.tsx @@ -2,12 +2,11 @@ import { Icons } from "@/components/icons/icons"; import { Button } from "@/components/ui/button"; -// import { DEFAULT_LOGIN_REDIRECT } from "@/routes"; -// import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useState } from "react"; -import { FaBrandsGitHub } from "../icons/github"; -import { FaBrandsGoogle } from "../icons/google"; +import { FaBrandsGitHub } from "@/components/icons/github"; +import { FaBrandsGoogle } from "@/components/icons/google"; +import { authClient } from "@/lib/auth-client"; /** * social login buttons @@ -18,12 +17,38 @@ export const SocialLoginButton = () => { const [isLoading, setIsLoading] = useState<"google" | "github" | null>(null); const onClick = async (provider: "google" | "github") => { - setIsLoading(provider); + // setIsLoading(provider); // signIn(provider, { // callbackUrl: callbackUrl || DEFAULT_LOGIN_REDIRECT, // }); // no need to reset the loading state, keep loading before webpage redirects // setIsLoading(null); + + await authClient.signIn.social({ + /** + * The social provider id + * @example "github", "google", "apple" + */ + provider: "github", + /** + * a url to redirect after the user authenticates with the provider + * @default "/" + */ + callbackURL: "/dashboard", + /** + * a url to redirect if an error occurs during the sign in process + */ + errorCallbackURL: "/auth/error", + /** + * a url to redirect if the user is newly registered + */ + // newUserCallbackURL: "/auth/welcome", + /** + * disable the automatic redirect to the provider. + * @default false + */ + // disableRedirect: true, + }); }; return ( @@ -33,7 +58,7 @@ export const SocialLoginButton = () => { className="w-full" variant="outline" onClick={() => onClick("google")} - // disabled={isLoading === "google"} + // disabled={isLoading === "google"} > {isLoading === "google" ? ( @@ -47,7 +72,7 @@ export const SocialLoginButton = () => { className="w-full" variant="outline" onClick={() => onClick("github")} - // disabled={isLoading === "github"} + // disabled={isLoading === "github"} > {isLoading === "github" ? ( diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..a5137fb --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,5 @@ +import { drizzle } from 'drizzle-orm/neon-http'; + +const db = drizzle(process.env.DATABASE_URL!); + +export default db; diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..e028842 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,47 @@ +import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull(), + image: text('image'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull() + }); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }) + }); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull() + }); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at') + }); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..9d614e2 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,5 @@ +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_APP_URL!, +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d76a427 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,26 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import db from "@/db/index"; +import { user, session, account, verification } from "@/db/schema"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", // or "mysql", "sqlite" + schema: { + user: user, + session: session, + account: account, + verification: verification, + }, + }), + emailAndPassword: { + enabled: true + }, + socialProviders: { + github: { + enabled: true, + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + } + }, +});