MVP account linking component flow works

This commit is contained in:
austinkelsay 2025-02-06 16:58:59 -06:00
parent 3e2ec017c9
commit 819d5bbfa9
6 changed files with 378 additions and 0 deletions

View File

@ -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;

View File

@ -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">

View File

@ -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,

View File

@ -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' });
}
}

View File

@ -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' });
}
}

View File

@ -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');
}
}