diff --git a/src/components/profile/UserAccountLinking.js b/src/components/profile/UserAccountLinking.js new file mode 100644 index 0000000..d8e2724 --- /dev/null +++ b/src/components/profile/UserAccountLinking.js @@ -0,0 +1,213 @@ +import React, { useEffect } from 'react'; +import { Menu } from 'primereact/menu'; +import GenericButton from '@/components/buttons/GenericButton'; +import { signIn } from 'next-auth/react'; +import Image from 'next/image'; +import useWindowWidth from '@/hooks/useWindowWidth'; +import { useNDKContext } from "@/context/NDKContext"; +import { useToast } from '@/hooks/useToast'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; + +const LinkAccountsCard = ({ session }) => { + const isNostrLinked = session?.user?.pubkey && !session?.user?.privkey; + const isGithubLinked = session?.account?.provider === 'github'; + const isEmailLinked = Boolean(session?.user?.email); + const windowWidth = useWindowWidth(); + const { ndk, addSigner } = useNDKContext(); + const { showToast } = useToast(); + const router = useRouter(); + const { update } = useSession(); + + // Check for email verification success + useEffect(() => { + const checkEmailVerification = async () => { + if (router.query.emailVerified === 'true') { + await update(); // Update the session + showToast('success', 'Success', 'Email verified successfully'); + // Remove the query parameter + router.replace('/profile', undefined, { shallow: true }); + } else if (router.query.error === 'VerificationFailed') { + showToast('error', 'Error', 'Email verification failed'); + router.replace('/profile', undefined, { shallow: true }); + } + }; + + checkEmailVerification(); + }, [router.query, update, showToast, router]); + + const handleGithubLink = async () => { + if (!isGithubLinked) { + try { + await signIn("github", { + redirect: false, + // Pass existing user data for linking + userId: session?.user?.id, + pubkey: session?.user?.pubkey, + privkey: session?.user?.privkey || null + }); + } catch (error) { + console.error("Error linking GitHub:", error); + showToast('error', 'Error', 'Failed to link GitHub account'); + } + } + }; + + const handleNostrLink = async () => { + if (!isNostrLinked) { + try { + if (!ndk.signer) { + await addSigner(); + } + const user = await ndk.signer.user(); + const pubkey = user?._pubkey; + + if (pubkey) { + const response = await fetch('/api/user/link-nostr', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nostrPubkey: pubkey, + userId: session?.user?.id + }) + }); + + if (response.ok) { + showToast('success', 'Success', 'Nostr account linked successfully'); + // Refresh the session to get updated user data + await update(); + } else { + throw new Error('Failed to link Nostr account'); + } + } + } catch (error) { + console.error("Error linking Nostr:", error); + showToast('error', 'Error', 'Failed to link Nostr account'); + } + } + }; + + const handleEmailLink = async () => { + if (!isEmailLinked) { + try { + const email = prompt("Please enter your email address:"); + if (email) { + const response = await fetch('/api/user/link-email', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email, + userId: session?.user?.id + }) + }); + + if (response.ok) { + showToast('success', 'Success', 'Verification email sent'); + // The user will need to verify their email through the link sent + } else { + throw new Error('Failed to initiate email linking'); + } + } + } catch (error) { + console.error("Error linking email:", error); + showToast('error', 'Error', 'Failed to link email'); + } + } + }; + + const MobileCard = () => ( + <div className="bg-gray-800 rounded-xl p-6 flex flex-col items-start w-full h-[420px] border border-gray-700"> + <h2 className="text-2xl font-bold mb-6 text-white">Link Accounts</h2> + + <div className="flex flex-col gap-4 w-full"> + <GenericButton + label={isGithubLinked ? "Github Linked" : "Link Github"} + icon="pi pi-github" + onClick={handleGithubLink} + disabled={isGithubLinked} + className={`w-full min-w-[240px] border-none ${ + isGithubLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#24292e] hover:bg-[#2f363d]" + }`} + /> + + <GenericButton + label={isNostrLinked ? "Nostr Linked" : "Link Nostr"} + icon={<Image src="/images/nostr.png" width={20} height={20} alt="Nostr" className="mr-2" />} + onClick={handleNostrLink} + disabled={isNostrLinked} + className={`w-full min-w-[240px] border-none flex items-center justify-center ${ + isNostrLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#6B4E71] hover:bg-[#6B4E71]/80" + }`} + /> + + <GenericButton + label={isEmailLinked ? "Email Linked" : "Link Email"} + icon="pi pi-envelope" + onClick={handleEmailLink} + disabled={isEmailLinked} + className={`w-full min-w-[240px] border-none ${ + isEmailLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#4A5568] hover:bg-[#4A5568]/80" + }`} + /> + </div> + </div> + ); + + const DesktopCard = () => ( + <div className="bg-gray-800 rounded-xl p-6 flex flex-col items-start w-full max-w-[400px] mt-2 border border-gray-700"> + <h2 className="text-2xl font-bold mb-6 text-white">Link Accounts</h2> + + <div className="flex flex-col gap-4 w-full"> + <GenericButton + label={isGithubLinked ? "Github Linked" : "Link Github"} + icon="pi pi-github" + onClick={handleGithubLink} + disabled={isGithubLinked} + className={`w-full border-none ${ + isGithubLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#24292e] hover:bg-[#2f363d]" + }`} + /> + + <GenericButton + label={isNostrLinked ? "Nostr Linked" : "Link Nostr"} + icon={<Image src="/images/nostr.png" width={20} height={20} alt="Nostr" className="mr-2" />} + onClick={handleNostrLink} + disabled={isNostrLinked} + className={`w-full border-none flex items-center justify-center ${ + isNostrLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#6B4E71] hover:bg-[#6B4E71]/80" + }`} + /> + + <GenericButton + label={isEmailLinked ? "Email Linked" : "Link Email"} + icon="pi pi-envelope" + onClick={handleEmailLink} + disabled={isEmailLinked} + className={`w-full border-none ${ + isEmailLinked + ? "bg-gray-600 opacity-70 cursor-not-allowed" + : "bg-[#4A5568] hover:bg-[#4A5568]/80" + }`} + /> + </div> + </div> + ); + + return windowWidth <= 1440 ? <MobileCard /> : <DesktopCard />; +}; + +export default LinkAccountsCard; diff --git a/src/components/profile/UserProfile.js b/src/components/profile/UserProfile.js index c35fc41..ed0aa4f 100644 --- a/src/components/profile/UserProfile.js +++ b/src/components/profile/UserProfile.js @@ -7,6 +7,7 @@ import ActivityContributionChart from "@/components/charts/ActivityContributionC import useCheckCourseProgress from "@/hooks/tracking/useCheckCourseProgress"; import useWindowWidth from "@/hooks/useWindowWidth"; import UserProgress from "@/components/profile/progress/UserProgress"; +import UserAccountLinking from "@/components/profile/UserAccountLinking"; import UserProgressTable from '@/components/profile/DataTables/UserProgressTable'; import UserPurchaseTable from '@/components/profile/DataTables/UserPurchaseTable'; @@ -40,6 +41,7 @@ const UserProfile = () => { <div className="w-full flex flex-row max-lap:flex-col"> <div className="w-[22%] h-full max-lap:w-full"> {user && <UserProfileCard user={user} />} + {user && <UserAccountLinking session={session} />} </div> <div className="w-[78%] flex flex-col justify-center mx-auto max-lap:w-full"> diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index f69087d..2f99aff 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -274,6 +274,7 @@ export const authOptions = { username: fullUser.username, avatar: fullUser.avatar, name: fullUser.name, + email: fullUser.email, userCourses: fullUser.userCourses, userLessons: fullUser.userLessons, purchased: fullUser.purchased, diff --git a/src/pages/api/user/link-email.js b/src/pages/api/user/link-email.js new file mode 100644 index 0000000..4928155 --- /dev/null +++ b/src/pages/api/user/link-email.js @@ -0,0 +1,84 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; +import prisma from "@/db/prisma"; +import { createTransport } from "nodemailer"; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { email, userId } = req.body; + + // Check if email is already in use + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (existingUser) { + return res.status(400).json({ error: 'Email already in use' }); + } + + // Create verification token + const token = await prisma.verificationToken.create({ + data: { + identifier: email, + token: `${Math.random().toString(36).substring(2, 15)}`, + expires: new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + } + }); + + // Create email transport + const transport = createTransport({ + 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 + } + }); + + // Generate verification URL + const baseUrl = process.env.BACKEND_URL; + const verificationUrl = `${baseUrl}/api/user/verify-email?token=${token.token}&email=${email}&userId=${userId}`; + + // Send verification email + await transport.sendMail({ + to: email, + from: process.env.EMAIL_FROM, + subject: `Verify your email for PlebDevs`, + text: `Click this link to verify your email: ${verificationUrl}`, + html: ` + <body> + <div style="background: #f9f9f9; padding: 20px;"> + <div style="max-width: 600px; margin: 0 auto; background: #fff; padding: 20px; border-radius: 10px;"> + <h2>Verify your email for PlebDevs</h2> + <p>Click the button below to verify your email address:</p> + <div style="text-align: center; margin: 30px 0;"> + <a href="${verificationUrl}" + style="background: #4A5568; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;"> + Verify Email + </a> + </div> + <p style="color: #666; font-size: 14px;"> + If you didn't request this email, you can safely ignore it. + </p> + </div> + </div> + </body> + ` + }); + + // Don't update the user yet - wait for verification + res.status(200).json({ message: 'Verification email sent' }); + } catch (error) { + console.error('Error linking email:', error); + res.status(500).json({ error: 'Failed to link email' }); + } +} \ No newline at end of file diff --git a/src/pages/api/user/link-nostr.js b/src/pages/api/user/link-nostr.js new file mode 100644 index 0000000..85ed26e --- /dev/null +++ b/src/pages/api/user/link-nostr.js @@ -0,0 +1,32 @@ +import { getServerSession } from "next-auth/next"; +import { authOptions } from "../auth/[...nextauth]"; +import prisma from "@/db/prisma"; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const session = await getServerSession(req, res, authOptions); + if (!session) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { nostrPubkey, userId } = req.body; + + // Update user with new Nostr pubkey + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: { + pubkey: nostrPubkey, + privkey: null // Remove privkey when linking to external Nostr account + } + }); + + res.status(200).json(updatedUser); + } catch (error) { + console.error('Error linking Nostr:', error); + res.status(500).json({ error: 'Failed to link Nostr account' }); + } +} \ No newline at end of file diff --git a/src/pages/api/user/verify-email.js b/src/pages/api/user/verify-email.js new file mode 100644 index 0000000..a1b3337 --- /dev/null +++ b/src/pages/api/user/verify-email.js @@ -0,0 +1,46 @@ +import prisma from "@/db/prisma"; + +export default async function handler(req, res) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const { token, email, userId } = req.query; + + // Verify token + const verificationToken = await prisma.verificationToken.findFirst({ + where: { + token, + identifier: email, + expires: { gt: new Date() } + } + }); + + if (!verificationToken) { + return res.status(400).json({ error: 'Invalid or expired token' }); + } + + // Update user with verified email + await prisma.user.update({ + where: { id: userId }, + data: { + email, + emailVerified: new Date() + } + }); + + // Delete the used token using the token value + await prisma.verificationToken.delete({ + where: { + token: token // Changed from id to token + } + }); + + // Redirect to success page + res.redirect('/profile?emailVerified=true'); + } catch (error) { + console.error('Error verifying email:', error); + res.redirect('/profile?error=VerificationFailed'); + } +} \ No newline at end of file