diff --git a/package-lock.json b/package-lock.json index e6593e1..14e7e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@getalby/bitcoin-connect-react": "^3.5.3", "@getalby/lightning-tools": "^5.0.3", "@getalby/sdk": "^3.6.1", + "@next-auth/prisma-adapter": "^1.0.7", "@nostr-dev-kit/ndk": "^2.10.0", "@nostr-dev-kit/ndk-cache-dexie": "^2.5.1", "@prisma/client": "^5.17.0", @@ -25,7 +26,7 @@ "next": "14.2.5", "next-auth": "^4.24.7", "next-remove-imports": "^1.0.12", - "nodemailer": "^6.9.14", + "nodemailer": "^6.9.15", "nostr-tools": "^2.7.1", "primeicons": "^7.0.0", "primereact": "^10.7.0", @@ -916,6 +917,16 @@ "crypto-js": "4.2.0" } }, + "node_modules/@next-auth/prisma-adapter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz", + "integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==", + "license": "ISC", + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3", + "next-auth": "^4" + } + }, "node_modules/@next/env": { "version": "14.2.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz", @@ -8832,9 +8843,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.9.14", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.14.tgz", - "integrity": "sha512-Dobp/ebDKBvz91sbtRKhcznLThrKxKt97GI2FAlAyy+fk19j73Uz3sBXolVtmcXjaorivqsbbbjDY+Jkt4/bQA==", + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", + "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 92462fd..2e4dd94 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@getalby/bitcoin-connect-react": "^3.5.3", "@getalby/lightning-tools": "^5.0.3", "@getalby/sdk": "^3.6.1", + "@next-auth/prisma-adapter": "^1.0.7", "@nostr-dev-kit/ndk": "^2.10.0", "@nostr-dev-kit/ndk-cache-dexie": "^2.5.1", "@prisma/client": "^5.17.0", @@ -26,7 +27,7 @@ "next": "14.2.5", "next-auth": "^4.24.7", "next-remove-imports": "^1.0.12", - "nodemailer": "^6.9.14", + "nodemailer": "^6.9.15", "nostr-tools": "^2.7.1", "primeicons": "^7.0.0", "primereact": "^10.7.0", diff --git a/prisma/migrations/20240906171109_email_auth/migration.sql b/prisma/migrations/20240906171109_email_auth/migration.sql new file mode 100644 index 0000000..ec8cc37 --- /dev/null +++ b/prisma/migrations/20240906171109_email_auth/migration.sql @@ -0,0 +1,71 @@ +/* + Warnings: + + - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "email" TEXT, +ADD COLUMN "emailVerified" TIMESTAMP(3), +ADD COLUMN "image" TEXT, +ADD COLUMN "name" TEXT, +ADD COLUMN "privkey" TEXT, +ALTER COLUMN "pubkey" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "Account" ( + "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, + "oauth_token_secret" TEXT, + "oauth_token" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b524fe..f3e0f57 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,18 +8,62 @@ generator client { } model User { - id String @id @default(uuid()) - pubkey String @unique - username String? @unique - avatar String? - purchased Purchase[] - courses Course[] - resources Resource[] - courseDrafts CourseDraft[] - drafts Draft[] - role Role? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + pubkey String? @unique + privkey String? + name String? + email String? @unique + emailVerified DateTime? + image String? + username String? @unique + avatar String? + purchased Purchase[] + courses Course[] + resources Resource[] + courseDrafts CourseDraft[] + drafts Draft[] + role Role? + accounts Account[] + sessions Session[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + oauth_token_secret String? + oauth_token String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) } model Role { diff --git a/src/components/navbar/user/UserAvatar.js b/src/components/navbar/user/UserAvatar.js index ff49abd..bb584d2 100644 --- a/src/components/navbar/user/UserAvatar.js +++ b/src/components/navbar/user/UserAvatar.js @@ -46,7 +46,7 @@ const UserAvatar = () => { return null; // Or return a loader/spinner/placeholder } else if (user && Object.keys(user).length > 0) { // User exists, show username or pubkey - const displayName = user.username || user.pubkey.slice(0, 10) + '...'; + const displayName = user.username || user?.email || user?.pubkey.slice(0, 10) + '...'; const items = [ { @@ -81,7 +81,7 @@ const UserAvatar = () => { className={styles.logo} /> - + ); } else { diff --git a/src/components/profile/UserProfile.js b/src/components/profile/UserProfile.js index 098a72c..b616b08 100644 --- a/src/components/profile/UserProfile.js +++ b/src/components/profile/UserProfile.js @@ -70,7 +70,7 @@ const UserProfile = () => {

- {user.username || "Anon"} + {user.username || user?.email || "Anon"}

{user.pubkey} diff --git a/src/components/profile/UserSettings.js b/src/components/profile/UserSettings.js index 4587302..7edce70 100644 --- a/src/components/profile/UserSettings.js +++ b/src/components/profile/UserSettings.js @@ -70,7 +70,7 @@ const UserSettings = () => {

- {user.username || "Anon"} + {user.username || user?.email || "Anon"}

{user.pubkey} diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 0e23b54..c1bf435 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -85,12 +85,15 @@ export const addCoursePurchaseToUser = async (userId, purchaseData) => { export const createUser = async (data) => { return await prisma.user.create({ - data, + data: { + ...data, + emailVerified: data.email ? new Date() : null, + }, }); }; export const updateUser = async (id, data) => { - console.log("user modelllll", id, data) + console.log("Updating user", id, data) return await prisma.user.update({ where: { id }, data, @@ -169,3 +172,18 @@ export const expireUserSubscriptions = async (userIds) => { await prisma.$transaction(updatePromises); return userIds.length; }; + +export const getUserByEmail = async (email) => { + return await prisma.user.findUnique({ + where: { email }, + include: { + role: true, + purchased: { + include: { + course: true, + resource: true, + }, + }, + }, + }); +}; diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index a853c1e..383f3f1 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -1,8 +1,14 @@ import NextAuth from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; +import EmailProvider from "next-auth/providers/email"; import NDK from "@nostr-dev-kit/ndk"; import axios from "axios"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import prisma from "@/db/prisma"; import { findKind0Fields } from "@/utils/nostr"; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure' +import { bytesToHex } from '@noble/hashes/utils' +import { updateUser } from "@/db/models/userModels"; const relayUrls = [ "wss://nos.lol/", @@ -43,7 +49,6 @@ const authorize = async (pubkey) => { // Create user if (profile) { const fields = await findKind0Fields(profile); - console.log('FEEEEELDS', fields); const payload = { pubkey, ...fields }; const createUserResponse = await axios.post(`${BASE_URL}/api/users`, payload); @@ -58,6 +63,7 @@ const authorize = async (pubkey) => { export default NextAuth({ + adapter: PrismaAdapter(prisma), providers: [ CredentialsProvider({ id: "nostr", @@ -72,15 +78,43 @@ export default NextAuth({ return null; }, }), + EmailProvider({ + server: { + host: process.env.EMAIL_SERVER_HOST, + port: process.env.EMAIL_SERVER_PORT, + auth: { + user: process.env.EMAIL_SERVER_USER, + pass: process.env.EMAIL_SERVER_PASSWORD + } + }, + from: process.env.EMAIL_FROM + }), ], callbacks: { async jwt({ token, trigger, user }) { - console.log('TRIGGER', trigger); if (trigger === "update") { // if we trigger an update call the authorize function again const newUser = await authorize(token.user.pubkey); token.user = newUser; } + // if the user has no pubkey, generate a new key pair + if (token && token?.user && token?.user?.id && !token.user?.pubkey) { + try { + let sk = generateSecretKey() + let pk = getPublicKey(sk) + let skHex = bytesToHex(sk) + const updatedUser = await updateUser(token.user.id, {pubkey: pk, privkey: skHex}); + if (!updatedUser) { + console.error("Failed to update user"); + return null; + } + token.user = updatedUser; + } catch (error) { + console.error("Ephemeral key pair generation error:", error); + return null; + } + } + // Add combined user object to the token if (user) { token.user = user; @@ -88,6 +122,7 @@ export default NextAuth({ return token; }, async session({ session, token }) { + console.log('SESSION', session); // Add user from token to session session.user = token.user; session.jwt = token; @@ -97,7 +132,6 @@ export default NextAuth({ return baseUrl; }, async signOut({ token, session }) { - console.log('signOut', token, session); token = {} session = {} return true diff --git a/src/pages/api/users/[slug].js b/src/pages/api/users/[slug].js index c602fc8..05dfc32 100644 --- a/src/pages/api/users/[slug].js +++ b/src/pages/api/users/[slug].js @@ -2,14 +2,18 @@ import { getUserById, getUserByPubkey, updateUser, deleteUser } from "@/db/model export default async function handler(req, res) { const { slug } = req.query; - // Determine if slug is a pubkey or an ID + // Determine if slug is a pubkey, ID, or email const isPubkey = /^[0-9a-fA-F]{64}$/.test(slug); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(slug); try { let user; if (isPubkey) { // If slug is a pubkey user = await getUserByPubkey(slug); + } else if (isEmail) { + // If slug is an email + user = await getUserByEmail(slug); } else { // Assume slug is an ID const id = parseInt(slug); diff --git a/src/pages/auth/signin.js b/src/pages/auth/signin.js index d8edc06..f87318f 100644 --- a/src/pages/auth/signin.js +++ b/src/pages/auth/signin.js @@ -2,72 +2,86 @@ import { signIn, useSession } from "next-auth/react" import { useState, useEffect } from "react" import { useNDKContext } from "@/context/NDKContext"; import { Button } from 'primereact/button'; +import { InputText } from 'primereact/inputtext'; export default function SignIn() { - const [email, setEmail] = useState("") - const [nostrPubkey, setNostrPubkey] = useState("") - const [nostrPrivkey, setNostrPrivkey] = useState("") + const [email, setEmail] = useState("") + const [showEmailInput, setShowEmailInput] = useState(false) + const [nostrPubkey, setNostrPubkey] = useState("") + const [nostrPrivkey, setNostrPrivkey] = useState("") + const {ndk, addSigner} = useNDKContext(); + const { data: session, status } = useSession(); // Get the current session's data and status - const {ndk, addSigner} = useNDKContext(); + useEffect(() => { + console.log("session", session) + }, [session]) - const { data: session, status } = useSession(); // Get the current session's data and status + const handleEmailSignIn = async (e) => { + e.preventDefault() + await signIn("email", { email, callbackUrl: '/' }) + } - useEffect(() => { - console.log("session", session) - }, [session]) - - const handleEmailSignIn = (e) => { - e.preventDefault() - signIn("email", { email }) + const handleNostrSignIn = async (e) => { + e.preventDefault() + if (!ndk.signer) { + await addSigner(); } - - const handleNostrSignIn = async (e) => { - e.preventDefault() - - if (!ndk.signer) { - await addSigner(); - } - - - try { - const user = await ndk.signer.user() - - const pubkey = user?._pubkey - signIn("nostr", { pubkey }) - } catch (error) { - console.error("Error signing Nostr event:", error) - } + try { + const user = await ndk.signer.user() + const pubkey = user?._pubkey + signIn("nostr", { pubkey }) + } catch (error) { + console.error("Error signing Nostr event:", error) } + } - const handleAnonymousSignIn = (e) => { - e.preventDefault() - signIn("anonymous") - } + const handleAnonymousSignIn = (e) => { + e.preventDefault() + signIn("anonymous") + } - return ( -
-

Sign In

-
- ) + return ( +
+

Sign In

+
+ ) } \ No newline at end of file