feat: implement authentication system with email verification, user management dashboard, and administrative business controls
Some checks failed
Build and Push App / build (push) Failing after 1m9s
Some checks failed
Build and Push App / build (push) Failing after 1m9s
This commit is contained in:
@@ -34,12 +34,17 @@ export async function POST(req: Request) {
|
||||
|
||||
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'Réinitialisation de votre mot de passe - Afrohub',
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; rounded-lg">
|
||||
<h2 style="color: #0d9488; text-align: center;">Afrohub</h2>
|
||||
// Get site settings for email template
|
||||
const settings = await prisma.siteSetting.findUnique({
|
||||
where: { id: 'singleton' }
|
||||
});
|
||||
|
||||
const siteName = settings?.siteName || 'Afrohub';
|
||||
const subject = settings?.resetPasswordSubject || 'Réinitialisation de votre mot de passe - Afrohub';
|
||||
|
||||
let html = settings?.resetPasswordTemplate || `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||
<p>Bonjour ${user.name},</p>
|
||||
<p>Vous avez demandé la réinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
@@ -47,9 +52,20 @@ export async function POST(req: Request) {
|
||||
</div>
|
||||
<p>Ce lien expirera dans 1 heure. Si vous n'avez pas demandé cette réinitialisation, vous pouvez ignorer cet email.</p>
|
||||
<hr style="border: 0; border-top: 1px solid #e5e7eb; margin: 20px 0;">
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 Afrohub. Tous droits réservés.</p>
|
||||
<p style="font-size: 12px; color: #6b7280; text-align: center;">© 2025 ${siteName}. Tous droits réservés.</p>
|
||||
</div>
|
||||
`,
|
||||
`;
|
||||
|
||||
// Replace placeholders
|
||||
html = html
|
||||
.replace(/{name}/g, user.name)
|
||||
.replace(/{resetUrl}/g, resetUrl)
|
||||
.replace(/{siteName}/g, siteName);
|
||||
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: 'Si un compte existe pour cet email, un lien de réinitialisation a été envoyé.' });
|
||||
|
||||
@@ -35,6 +35,14 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email is verified
|
||||
if (!user.emailVerified) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Veuillez vérifier votre adresse email avant de vous connecter.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Omit password from response
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -36,16 +37,60 @@ export async function POST(request: NextRequest) {
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Generate verification token
|
||||
const verificationToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: 'VISITOR' // Default role
|
||||
role: 'VISITOR', // Default role
|
||||
verificationToken,
|
||||
emailVerified: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
const settings = await prisma.siteSetting.findUnique({
|
||||
where: { id: 'singleton' }
|
||||
});
|
||||
|
||||
const siteName = settings?.siteName || 'Afrohub';
|
||||
const subject = settings?.verifyEmailSubject || 'Vérification de votre compte - Afrohub';
|
||||
const verifyUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'}/api/auth/verify?token=${verificationToken}`;
|
||||
|
||||
let html = settings?.verifyEmailTemplate || `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||
<h2 style="color: #0d9488; text-align: center;">${siteName}</h2>
|
||||
<p>Bonjour ${name},</p>
|
||||
<p>Merci de vous être inscrit sur ${siteName}. Veuillez vérifier votre adresse email en cliquant sur le bouton ci-dessous :</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${verifyUrl}" style="background-color: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: bold;">Vérifier mon compte</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Replace placeholders
|
||||
html = html
|
||||
.replace(/{name}/g, name)
|
||||
.replace(/{verifyUrl}/g, verifyUrl)
|
||||
.replace(/{siteName}/g, siteName);
|
||||
|
||||
const { sendEmail } = await import('@/lib/mail');
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
} catch (mailError) {
|
||||
console.error('Failed to send verification email:', mailError);
|
||||
// We don't fail registration if mail fails, but maybe we should?
|
||||
// For now, just log it.
|
||||
}
|
||||
|
||||
// Omit password from response
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
|
||||
35
app/api/auth/verify/route.ts
Normal file
35
app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/login?error=Token manquant', request.url));
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { verificationToken: token }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.redirect(new URL('/login?error=Token invalide ou expiré', request.url));
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
emailVerified: true,
|
||||
verificationToken: null
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL('/login?verified=true', request.url));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Verification error:', error);
|
||||
return NextResponse.redirect(new URL('/login?error=Erreur de vérification', request.url));
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useUser } from '../../components/UserProvider';
|
||||
import { CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
const LoginPage = () => {
|
||||
const { settings, login } = useUser();
|
||||
@@ -14,6 +15,10 @@ const LoginPage = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const verified = searchParams.get('verified') === 'true';
|
||||
const queryError = searchParams.get('error');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -55,12 +60,23 @@ const LoginPage = () => {
|
||||
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1€</Link>
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded text-red-700 text-sm">
|
||||
{error}
|
||||
|
||||
<div className="mt-8 space-y-6">
|
||||
{verified && (
|
||||
<div className="bg-emerald-50 border-l-4 border-emerald-500 p-4 rounded flex items-center gap-3 text-emerald-700 text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500 flex-shrink-0" />
|
||||
<p>Votre compte a été vérifié avec succès ! Vous pouvez maintenant vous connecter.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(error || queryError) && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 rounded flex items-center gap-3 text-red-700 text-sm">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
<p>{error || queryError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<input
|
||||
@@ -100,6 +116,7 @@ const LoginPage = () => {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user