Compare commits
2 Commits
0460a1586f
...
33ec11d6db
| Author | SHA1 | Date | |
|---|---|---|---|
| 33ec11d6db | |||
| 12e48a738c |
@@ -400,8 +400,22 @@ const BusinessDetailPage = () => {
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen pb-12">
|
||||
{/* Header Banner */}
|
||||
<div className="h-48 md:h-64 bg-gradient-to-r from-gray-900 to-gray-800 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
|
||||
<div className="h-48 md:h-64 bg-gray-900 relative overflow-hidden">
|
||||
{business.coverUrl ? (
|
||||
<img
|
||||
src={business.coverUrl}
|
||||
alt="Couverture"
|
||||
className="absolute inset-0 w-full h-full object-cover opacity-60"
|
||||
style={{
|
||||
objectPosition: business.coverPosition || '50% 50%',
|
||||
transform: `scale(${business.coverZoom || 1})`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-gray-900 to-gray-800 opacity-100">
|
||||
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] pointer-events-none"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-full flex flex-col justify-between py-6 relative z-10">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -419,11 +433,11 @@ const BusinessDetailPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 -mt-20 relative z-10">
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100 overflow-hidden">
|
||||
<div className="bg-white rounded-xl shadow-lg border border-gray-100">
|
||||
<div className="p-6 md:p-8">
|
||||
{/* Profile Header */}
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start">
|
||||
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative">
|
||||
<div className="w-32 h-32 md:w-40 md:h-40 rounded-xl bg-white p-1 shadow-md -mt-16 md:-mt-24 border border-gray-100 flex-shrink-0 relative z-20">
|
||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-lg bg-gray-50" />
|
||||
{business.isFeatured && (
|
||||
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 rounded-full p-2 shadow-md" title="Entrepreneur du Mois">
|
||||
|
||||
@@ -66,6 +66,9 @@ export async function POST(request: NextRequest) {
|
||||
city: data.city || "",
|
||||
description: data.description || "",
|
||||
logoUrl: data.logoUrl || "https://picsum.photos/200/200?random=default",
|
||||
coverUrl: data.coverUrl || null,
|
||||
coverPosition: data.coverPosition || "50% 50%",
|
||||
coverZoom: data.coverZoom ? parseFloat(data.coverZoom) : 1.0,
|
||||
videoUrl: data.videoUrl || null,
|
||||
contactEmail: data.contactEmail || "",
|
||||
contactPhone: data.contactPhone || null,
|
||||
|
||||
@@ -29,6 +29,9 @@ const normalizeBusinessData = (b: Business): Business => ({
|
||||
showSocials: b.showSocials ?? true,
|
||||
slug: b.slug || '',
|
||||
videoUrl: b.videoUrl || '',
|
||||
coverUrl: b.coverUrl || '',
|
||||
coverPosition: b.coverPosition || '50% 50%',
|
||||
coverZoom: b.coverZoom || 1,
|
||||
contactPhone: b.contactPhone || '',
|
||||
websiteUrl: b.websiteUrl || '',
|
||||
socialLinks: {
|
||||
@@ -164,7 +167,7 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
|
||||
const isNameOk = formData.name && formData.name !== "Nouvelle Entreprise";
|
||||
const isDescOk = formData.description && formData.description.length >= 20;
|
||||
const isLocOk = formData.location && formData.location !== "Ma Ville";
|
||||
const isLocOk = (formData.countryId && formData.city) || (formData.location && formData.location !== "Ma Ville");
|
||||
const isEmailOk = !!formData.contactEmail;
|
||||
const isPhoneOk = !!formData.contactPhone;
|
||||
const isActive = isNameOk && isDescOk && isLocOk && isEmailOk && isPhoneOk;
|
||||
@@ -220,14 +223,89 @@ const DashboardProfile = ({ business, setBusiness }: { business: Business, setBu
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center"><ImageIcon className="w-5 h-5 mr-2 text-brand-600" /> Identité Visuelle</h3>
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 flex flex-col items-center gap-2">
|
||||
<img className="h-24 w-24 object-cover rounded-full border-2 border-gray-200" src={formData.logoUrl} alt="Logo actuel" />
|
||||
<span className="text-[10px] font-bold text-gray-400 uppercase">Logo</span>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center hover:border-brand-400 transition-colors cursor-pointer bg-gray-50">
|
||||
<ImageIcon className="h-8 w-8 text-gray-400" />
|
||||
<p className="mt-1 text-xs text-gray-500">Glissez votre logo ici ou cliquez pour parcourir</p>
|
||||
<p className="mt-1 text-xs text-gray-500">Modifier le logo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Photo de Couverture (Bannière)</label>
|
||||
<div className="relative h-48 w-full rounded-lg overflow-hidden bg-gray-100 border-2 border-dashed border-gray-300 group transition-all">
|
||||
{formData.coverUrl ? (
|
||||
<img
|
||||
src={formData.coverUrl}
|
||||
className="w-full h-full object-cover transition-transform duration-200"
|
||||
style={{
|
||||
objectPosition: formData.coverPosition || '50% 50%',
|
||||
transform: `scale(${formData.coverZoom || 1})`
|
||||
}}
|
||||
alt="Couverture"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<ImageIcon className="w-8 h-8 mb-1" />
|
||||
<span className="text-xs">Aucune bannière personnalisée</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button className="bg-white text-gray-900 px-4 py-1.5 rounded-full text-xs font-bold shadow-lg">Changer la photo</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Zoom : {(formData.coverZoom || 1).toFixed(1)}x</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1" max="3" step="0.1"
|
||||
value={formData.coverZoom || 1}
|
||||
onChange={(e) => setFormData({...formData, coverZoom: parseFloat(e.target.value)})}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-brand-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Position Horizontale</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="100" step="1"
|
||||
value={parseInt((formData.coverPosition || '50% 50%').split(' ')[0]) || 50}
|
||||
onChange={(e) => {
|
||||
const y = (formData.coverPosition || '50% 50%').split(' ')[1] || '50%';
|
||||
setFormData({...formData, coverPosition: `${e.target.value}% ${y}`});
|
||||
}}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-brand-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-500 uppercase mb-2">Position Verticale</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0" max="100" step="1"
|
||||
value={parseInt((formData.coverPosition || '50% 50%').split(' ')[1]) || 50}
|
||||
onChange={(e) => {
|
||||
const x = (formData.coverPosition || '50% 50%').split(' ')[0] || '50%';
|
||||
setFormData({...formData, coverPosition: `${x} ${e.target.value}%`});
|
||||
}}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-brand-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="coverUrl"
|
||||
value={formData.coverUrl || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder="URL de votre image de couverture (ex: https://...)"
|
||||
className="mt-4 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-brand-500 focus:border-brand-500 sm:text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500 italic">Ajustez le zoom et la position pour un rendu optimal sur votre fiche.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6 mt-6">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700">Nom de l'entreprise</label>
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -38,6 +38,9 @@ model Business {
|
||||
location String
|
||||
description String
|
||||
logoUrl String
|
||||
coverUrl String?
|
||||
coverPosition String? @default("50% 50%")
|
||||
coverZoom Float? @default(1.0)
|
||||
videoUrl String?
|
||||
contactEmail String
|
||||
contactPhone String?
|
||||
|
||||
240
prisma/seed-countries.ts
Normal file
240
prisma/seed-countries.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import pg from 'pg';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ path: '.env.local' });
|
||||
dotenv.config();
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const pool = new pg.Pool({ connectionString });
|
||||
const adapter = new PrismaPg(pool);
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const countries = [
|
||||
{ name: "Afghanistan", code: "AF", flag: "🇦🇫" },
|
||||
{ name: "Albanie", code: "AL", flag: "🇦🇱" },
|
||||
{ name: "Algérie", code: "DZ", flag: "🇩🇿" },
|
||||
{ name: "Andorre", code: "AD", flag: "🇦🇩" },
|
||||
{ name: "Angola", code: "AO", flag: "🇦🇴" },
|
||||
{ name: "Antigua-et-Barbuda", code: "AG", flag: "🇦🇬" },
|
||||
{ name: "Argentine", code: "AR", flag: "🇦🇷" },
|
||||
{ name: "Arménie", code: "AM", flag: "🇦🇲" },
|
||||
{ name: "Australie", code: "AU", flag: "🇦🇺" },
|
||||
{ name: "Autriche", code: "AT", flag: "🇦🇹" },
|
||||
{ name: "Azerbaïdjan", code: "AZ", flag: "🇦🇿" },
|
||||
{ name: "Bahamas", code: "BS", flag: "🇧🇸" },
|
||||
{ name: "Bahreïn", code: "BH", flag: "🇧🇭" },
|
||||
{ name: "Bangladesh", code: "BD", flag: "🇧🇩" },
|
||||
{ name: "Barbade", code: "BB", flag: "🇧🇧" },
|
||||
{ name: "Belgique", code: "BE", flag: "🇧🇪" },
|
||||
{ name: "Belize", code: "BZ", flag: "🇧🇿" },
|
||||
{ name: "Bénin", code: "BJ", flag: "🇧🇯" },
|
||||
{ name: "Bhoutan", code: "BT", flag: "🇧🇹" },
|
||||
{ name: "Bolivie", code: "BO", flag: "🇧🇴" },
|
||||
{ name: "Bosnie-Herzégovine", code: "BA", flag: "🇧🇦" },
|
||||
{ name: "Botswana", code: "BW", flag: "🇧🇼" },
|
||||
{ name: "Brésil", code: "BR", flag: "🇧🇷" },
|
||||
{ name: "Brunéi", code: "BN", flag: "🇧🇳" },
|
||||
{ name: "Bulgarie", code: "BG", flag: "🇧🇬" },
|
||||
{ name: "Burkina Faso", code: "BF", flag: "🇧🇫" },
|
||||
{ name: "Burundi", code: "BI", flag: "🇧🇮" },
|
||||
{ name: "Cabo Verde", code: "CV", flag: "🇨🇻" },
|
||||
{ name: "Cambodge", code: "KH", flag: "🇰🇭" },
|
||||
{ name: "Cameroun", code: "CM", flag: "🇨🇲" },
|
||||
{ name: "Canada", code: "CA", flag: "🇨🇦" },
|
||||
{ name: "République centrafricaine", code: "CF", flag: "🇨🇫" },
|
||||
{ name: "Tchad", code: "TD", flag: "🇹🇩" },
|
||||
{ name: "Chili", code: "CL", flag: "🇨🇱" },
|
||||
{ name: "Chine", code: "CN", flag: "🇨🇳" },
|
||||
{ name: "Colombie", code: "CO", flag: "🇨🇴" },
|
||||
{ name: "Comores", code: "KM", flag: "🇰🇲" },
|
||||
{ name: "Congo (Brazzaville)", code: "CG", flag: "🇨🇬" },
|
||||
{ name: "Congo (Kinshasa)", code: "CD", flag: "🇨🇩" },
|
||||
{ name: "Costa Rica", code: "CR", flag: "🇨🇷" },
|
||||
{ name: "Côte d'Ivoire", code: "CI", flag: "🇨🇮" },
|
||||
{ name: "Croatie", code: "HR", flag: "🇭🇷" },
|
||||
{ name: "Cuba", code: "CU", flag: "🇨🇺" },
|
||||
{ name: "Chypre", code: "CY", flag: "🇨🇾" },
|
||||
{ name: "Tchéquie", code: "CZ", flag: "🇨🇿" },
|
||||
{ name: "Danemark", code: "DK", flag: "🇩🇰" },
|
||||
{ name: "Djibouti", code: "DJ", flag: "🇩🇯" },
|
||||
{ name: "Dominique", code: "DM", flag: "🇩🇲" },
|
||||
{ name: "République dominicaine", code: "DO", flag: "🇩🇴" },
|
||||
{ name: "Équateur", code: "EC", flag: "🇪🇨" },
|
||||
{ name: "Égypte", code: "EG", flag: "🇪🇬" },
|
||||
{ name: "El Salvador", code: "SV", flag: "🇸🇻" },
|
||||
{ name: "Guinée équatoriale", code: "GQ", flag: "🇬🇶" },
|
||||
{ name: "Érythrée", code: "ER", flag: "🇪🇷" },
|
||||
{ name: "Estonie", code: "EE", flag: "🇪🇪" },
|
||||
{ name: "Eswatini", code: "SZ", flag: "🇸🇿" },
|
||||
{ name: "Éthiopie", code: "ET", flag: "🇪🇹" },
|
||||
{ name: "Fidji", code: "FJ", flag: "🇫🇯" },
|
||||
{ name: "Finlande", code: "FI", flag: "🇫🇮" },
|
||||
{ name: "France", code: "FR", flag: "🇫🇷" },
|
||||
{ name: "Gabon", code: "GA", flag: "🇬🇦" },
|
||||
{ name: "Gambie", code: "GM", flag: "🇬🇲" },
|
||||
{ name: "Géorgie", code: "GE", flag: "🇬🇪" },
|
||||
{ name: "Allemagne", code: "DE", flag: "🇩🇪" },
|
||||
{ name: "Ghana", code: "GH", flag: "🇬🇭" },
|
||||
{ name: "Grèce", code: "GR", flag: "🇬🇷" },
|
||||
{ name: "Grenade", code: "GD", flag: "🇬🇩" },
|
||||
{ name: "Guatemala", code: "GT", flag: "🇬🇹" },
|
||||
{ name: "Guinée", code: "GN", flag: "🇬🇳" },
|
||||
{ name: "Guinée-Bissau", code: "GW", flag: "🇬🇼" },
|
||||
{ name: "Guyana", code: "GY", flag: "🇬🇾" },
|
||||
{ name: "Haïti", code: "HT", flag: "🇭🇹" },
|
||||
{ name: "Honduras", code: "HN", flag: "🇭🇳" },
|
||||
{ name: "Hongrie", code: "HU", flag: "🇭🇺" },
|
||||
{ name: "Islande", code: "IS", flag: "🇮🇸" },
|
||||
{ name: "Inde", code: "IN", flag: "🇮🇳" },
|
||||
{ name: "Indonésie", code: "ID", flag: "🇮🇩" },
|
||||
{ name: "Iran", code: "IR", flag: "🇮🇷" },
|
||||
{ name: "Irak", code: "IQ", flag: "🇮🇶" },
|
||||
{ name: "Irlande", code: "IE", flag: "🇮🇪" },
|
||||
{ name: "Israël", code: "IL", flag: "🇮🇱" },
|
||||
{ name: "Italie", code: "IT", flag: "🇮🇹" },
|
||||
{ name: "Jamaïque", code: "JM", flag: "🇯🇲" },
|
||||
{ name: "Japon", code: "JP", flag: "🇯🇵" },
|
||||
{ name: "Jordanie", code: "JO", flag: "🇯🇴" },
|
||||
{ name: "Kazakhstan", code: "KZ", flag: "🇰🇿" },
|
||||
{ name: "Kenya", code: "KE", flag: "🇰🇪" },
|
||||
{ name: "Kiribati", code: "KI", flag: "🇰🇮" },
|
||||
{ name: "Corée du Nord", code: "KP", flag: "🇰🇵" },
|
||||
{ name: "Corée du Sud", code: "KR", flag: "🇰🇷" },
|
||||
{ name: "Koweït", code: "KW", flag: "🇰🇼" },
|
||||
{ name: "Kirghizistan", code: "KG", flag: "🇰🇬" },
|
||||
{ name: "Laos", code: "LA", flag: "🇱🇦" },
|
||||
{ name: "Lettonie", code: "LV", flag: "🇱🇻" },
|
||||
{ name: "Liban", code: "LB", flag: "🇱🇧" },
|
||||
{ name: "Lesotho", code: "LS", flag: "🇱🇸" },
|
||||
{ name: "Libéria", code: "LR", flag: "🇱🇷" },
|
||||
{ name: "Libye", code: "LY", flag: "🇱🇾" },
|
||||
{ name: "Liechtenstein", code: "LI", flag: "🇱🇮" },
|
||||
{ name: "Lituanie", code: "LT", flag: "🇱🇹" },
|
||||
{ name: "Luxembourg", code: "LU", flag: "🇱🇺" },
|
||||
{ name: "Madagascar", code: "MG", flag: "🇲🇬" },
|
||||
{ name: "Malawi", code: "MW", flag: "🇲🇼" },
|
||||
{ name: "Malaisie", code: "MY", flag: "🇲🇾" },
|
||||
{ name: "Maldives", code: "MV", flag: "🇲🇻" },
|
||||
{ name: "Mali", code: "ML", flag: "🇲🇱" },
|
||||
{ name: "Malte", code: "MT", flag: "🇲🇹" },
|
||||
{ name: "Îles Marshall", code: "MH", flag: "🇲🇭" },
|
||||
{ name: "Mauritanie", code: "MR", flag: "🇲🇷" },
|
||||
{ name: "Maurice", code: "MU", flag: "🇲🇺" },
|
||||
{ name: "Mexique", code: "MX", flag: "🇲🇽" },
|
||||
{ name: "Micronésie", code: "FM", flag: "🇫🇲" },
|
||||
{ name: "Moldavie", code: "MD", flag: "🇲🇩" },
|
||||
{ name: "Monaco", code: "MC", flag: "🇲🇨" },
|
||||
{ name: "Mongolie", code: "MN", flag: "🇲🇳" },
|
||||
{ name: "Monténégro", code: "ME", flag: "🇲🇪" },
|
||||
{ name: "Maroc", code: "MA", flag: "🇲🇦" },
|
||||
{ name: "Mozambique", code: "MZ", flag: "🇲🇿" },
|
||||
{ name: "Myanmar", code: "MM", flag: "🇲🇲" },
|
||||
{ name: "Namibie", code: "NA", flag: "🇳🇦" },
|
||||
{ name: "Nauru", code: "NR", flag: "🇳🇷" },
|
||||
{ name: "Népal", code: "NP", flag: "🇳🇵" },
|
||||
{ name: "Pays-Bas", code: "NL", flag: "🇳🇱" },
|
||||
{ name: "Nouvelle-Zélande", code: "NZ", flag: "🇳🇿" },
|
||||
{ name: "Nicaragua", code: "NI", flag: "🇳🇮" },
|
||||
{ name: "Niger", code: "NE", flag: "🇳🇪" },
|
||||
{ name: "Nigéria", code: "NG", flag: "🇳🇬" },
|
||||
{ name: "Macédoine du Nord", code: "MK", flag: "🇲🇰" },
|
||||
{ name: "Norvège", code: "NO", flag: "🇳🇴" },
|
||||
{ name: "Oman", code: "OM", flag: "🇴🇲" },
|
||||
{ name: "Pakistan", code: "PK", flag: "🇵🇰" },
|
||||
{ name: "Palaos", code: "PW", flag: "🇵🇼" },
|
||||
{ name: "Panama", code: "PA", flag: "🇵🇦" },
|
||||
{ name: "Papouasie-Nouvelle-Guinée", code: "PG", flag: "🇵🇬" },
|
||||
{ name: "Paraguay", code: "PY", flag: "🇵🇾" },
|
||||
{ name: "Pérou", code: "PE", flag: "🇵🇪" },
|
||||
{ name: "Philippines", code: "PH", flag: "🇵🇭" },
|
||||
{ name: "Pologne", code: "PL", flag: "🇵🇱" },
|
||||
{ name: "Portugal", code: "PT", flag: "🇵🇹" },
|
||||
{ name: "Qatar", code: "QA", flag: "🇶🇦" },
|
||||
{ name: "Roumanie", code: "RO", flag: "🇷🇴" },
|
||||
{ name: "Russie", code: "RU", flag: "🇷🇺" },
|
||||
{ name: "Rwanda", code: "RW", flag: "🇷🇼" },
|
||||
{ name: "Saint-Christophe-et-Niévès", code: "KN", flag: "🇰🇳" },
|
||||
{ name: "Sainte-Lucie", code: "LC", flag: "🇱🇨" },
|
||||
{ name: "Saint-Vincent-et-les Grenadines", code: "VC", flag: "🇻🇨" },
|
||||
{ name: "Samoa", code: "WS", flag: "🇼🇸" },
|
||||
{ name: "Saint-Marin", code: "SM", flag: "🇸🇲" },
|
||||
{ name: "Sao Tomé-et-Principe", code: "ST", flag: "🇸🇹" },
|
||||
{ name: "Arabie Saoudite", code: "SA", flag: "🇸🇦" },
|
||||
{ name: "Sénégal", code: "SN", flag: "🇸🇳" },
|
||||
{ name: "Serbie", code: "RS", flag: "🇷🇸" },
|
||||
{ name: "Seychelles", code: "SC", flag: "🇸🇨" },
|
||||
{ name: "Sierra Leone", code: "SL", flag: "🇸🇱" },
|
||||
{ name: "Singapour", code: "SG", flag: "🇸🇬" },
|
||||
{ name: "Slovaquie", code: "SK", flag: "🇸🇰" },
|
||||
{ name: "Slovénie", code: "SI", flag: "🇸🇮" },
|
||||
{ name: "Îles Salomon", code: "SB", flag: "🇸🇧" },
|
||||
{ name: "Somalie", code: "SO", flag: "🇸🇴" },
|
||||
{ name: "Afrique du Sud", code: "ZA", flag: "🇿🇦" },
|
||||
{ name: "Soudan du Sud", code: "SS", flag: "🇸🇸" },
|
||||
{ name: "Espagne", code: "ES", flag: "🇪🇸" },
|
||||
{ name: "Sri Lanka", code: "LK", flag: "🇱🇰" },
|
||||
{ name: "Soudan", code: "SD", flag: "🇸🇩" },
|
||||
{ name: "Suriname", code: "SR", flag: "🇸🇷" },
|
||||
{ name: "Suède", code: "SE", flag: "🇸🇪" },
|
||||
{ name: "Suisse", code: "CH", flag: "🇨🇭" },
|
||||
{ name: "Syrie", code: "SY", flag: "🇸🇾" },
|
||||
{ name: "Taïwan", code: "TW", flag: "🇹🇼" },
|
||||
{ name: "Tadjikistan", code: "TJ", flag: "🇹🇯" },
|
||||
{ name: "Tanzanie", code: "TZ", flag: "🇹🇿" },
|
||||
{ name: "Thaïlande", code: "TH", flag: "🇹🇭" },
|
||||
{ name: "Timor oriental", code: "TL", flag: "🇹🇱" },
|
||||
{ name: "Togo", code: "TG", flag: "🇹🇬" },
|
||||
{ name: "Tonga", code: "TO", flag: "🇹🇴" },
|
||||
{ name: "Trinité-et-Tobago", code: "TT", flag: "🇹🇹" },
|
||||
{ name: "Tunisie", code: "TN", flag: "🇹🇳" },
|
||||
{ name: "Turquie", code: "TR", flag: "🇹🇷" },
|
||||
{ name: "Turkménistan", code: "TM", flag: "🇹🇲" },
|
||||
{ name: "Tuvalu", code: "TV", flag: "🇹🇻" },
|
||||
{ name: "Ouganda", code: "UG", flag: "🇺🇬" },
|
||||
{ name: "Ukraine", code: "UA", flag: "🇺🇦" },
|
||||
{ name: "Émirats Arabes Unis", code: "AE", flag: "🇦🇪" },
|
||||
{ name: "Royaume-Uni", code: "GB", flag: "🇬🇧" },
|
||||
{ name: "États-Unis", code: "US", flag: "🇺🇸" },
|
||||
{ name: "Uruguay", code: "UY", flag: "🇺🇾" },
|
||||
{ name: "Ouzbékistan", code: "UZ", flag: "🇺🇿" },
|
||||
{ name: "Vanuatu", code: "VU", flag: "🇻🇺" },
|
||||
{ name: "Vatican", code: "VA", flag: "🇻🇦" },
|
||||
{ name: "Venezuela", code: "VE", flag: "🇻🇪" },
|
||||
{ name: "Vietnam", code: "VN", flag: "🇻🇳" },
|
||||
{ name: "Yémen", code: "YE", flag: "🇾🇪" },
|
||||
{ name: "Zambie", code: "ZM", flag: "🇿🇲" },
|
||||
{ name: "Zimbabwe", code: "ZW", flag: "🇿🇼" }
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log('Seed: Start seeding countries...');
|
||||
|
||||
for (const country of countries) {
|
||||
await prisma.country.upsert({
|
||||
where: { code: country.code },
|
||||
update: {
|
||||
name: country.name,
|
||||
flag: country.flag,
|
||||
},
|
||||
create: {
|
||||
name: country.name,
|
||||
code: country.code,
|
||||
flag: country.flag,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seed: Successfully seeded ${countries.length} countries.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
});
|
||||
161
security_review.md
Normal file
161
security_review.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Revue de Sécurité - Afropreunariat
|
||||
|
||||
Ce document détaille une revue de sécurité complète de l'application Afropreunariat (basée sur Next.js, Prisma et PostgreSQL). Il identifie les menaces potentielles, les vulnérabilités courantes associées à cette pile technologique, et propose des solutions concrètes pour s'en prémunir.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cross-Site Scripting (XSS)
|
||||
|
||||
**La menace :**
|
||||
Le XSS survient lorsqu'un attaquant parvient à injecter du code JavaScript malveillant dans les pages web consultées par d'autres utilisateurs. Cela permet de voler des sessions, de rediriger les utilisateurs ou de modifier le contenu du site.
|
||||
|
||||
**Vulnérabilité identifiée sur le projet :**
|
||||
L'application utilise massivement la propriété `dangerouslySetInnerHTML` pour afficher du contenu riche (articles de blog, événements, fiches entreprises, etc.) :
|
||||
- `app/blog/[id]/page.tsx`
|
||||
- `app/afrolife/[id]/page.tsx`
|
||||
- `components/EventTimeline.tsx`
|
||||
- `app/cgv/page.tsx`, `app/cgu/page.tsx`
|
||||
|
||||
Si le contenu inséré dans `dangerouslySetInnerHTML` provient des utilisateurs (via un éditeur WYSIWYG dans le panel admin par exemple) et n'est pas "nettoyé" (sanitized), un attaquant pourrait inclure une balise `<script>` malveillante.
|
||||
|
||||
**Comment corriger :**
|
||||
- **Sanitisation :** Ne faites *jamais* confiance au contenu HTML en base de données. Utilisez une librairie de nettoyage (sanitizer) comme `isomorphic-dompurify` ou `dompurify` avant d'afficher le contenu.
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
// Au lieu de :
|
||||
// <div dangerouslySetInnerHTML={{ __html: post.content }} />
|
||||
|
||||
// Faites ceci :
|
||||
const cleanHTML = DOMPurify.sanitize(post.content);
|
||||
<div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
|
||||
```
|
||||
- **CSP (Content Security Policy) :** Configurez des en-têtes CSP restrictifs dans `next.config.js` pour empêcher l'exécution de scripts non autorisés (inline scripts).
|
||||
|
||||
---
|
||||
|
||||
## 2. Injections SQL (SQLi)
|
||||
|
||||
**La menace :**
|
||||
Un attaquant manipule les requêtes envoyées à la base de données en injectant du code SQL pour lire, modifier ou supprimer des données de manière non autorisée.
|
||||
|
||||
**État actuel du projet :**
|
||||
L'utilisation de **Prisma ORM** protège nativement l'application contre les injections SQL grâce à l'utilisation de requêtes paramétrées en coulisse (ex: `prisma.event.findMany(...)`).
|
||||
|
||||
**Comment maintenir la sécurité :**
|
||||
- Évitez d'utiliser les fonctions Prisma non sécurisées telles que `$queryRawUnsafe` ou `$executeRawUnsafe`.
|
||||
- Si vous devez utiliser des requêtes brutes, utilisez TOUJOURS `$queryRaw` ou `$executeRaw` (sans le suffixe `Unsafe`), qui paramètrent automatiquement les variables grâce aux *tagged templates literals* (ex: `prisma.$queryRaw\`SELECT * FROM User WHERE id = ${id}\``).
|
||||
|
||||
---
|
||||
|
||||
## 3. Gestion de l'Authentification et des Sessions
|
||||
|
||||
**La menace :**
|
||||
Le vol d'identifiants, l'usurpation d'identité ou la falsification de session.
|
||||
|
||||
**Vulnérabilité identifiée sur le projet :**
|
||||
Le projet stocke apparemment l'utilisateur dans le stockage local du navigateur (`localStorage.getItem('afro_user')` dans `UserProvider.tsx`).
|
||||
- Le `localStorage` est directement lisible par n'importe quel code JavaScript s'exécutant sur la page.
|
||||
- Si une faille XSS est exploitée (voir point 1), l'attaquant peut lire le `localStorage` et voler la session de l'utilisateur (ou son token JWT s'il y est stocké).
|
||||
|
||||
**Comment corriger :**
|
||||
- **Cookies HttpOnly :** Stockez les jetons d'authentification (JWT) ou les identifiants de session dans des cookies sécurisés (`HttpOnly`, `Secure`, `SameSite=Lax` ou `Strict`). Un cookie `HttpOnly` ne peut pas être lu par JavaScript, ce qui atténue grandement l'impact d'une faille XSS.
|
||||
- Si NextAuth.js (Auth.js) n'est pas utilisé, envisagez de migrer vers ce standard qui gère nativement la sécurité des cookies.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cross-Site Request Forgery (CSRF)
|
||||
|
||||
**La menace :**
|
||||
Le CSRF force un utilisateur authentifié à exécuter des actions non désirées sur une application web dans laquelle il est actuellement authentifié (ex: suppression de compte, modification de mot de passe) via un lien malveillant ou un formulaire caché sur un autre site.
|
||||
|
||||
**Comment corriger :**
|
||||
- **Server Actions :** Si vous utilisez les Server Actions de Next.js (`"use server"`), Next.js inclut une protection CSRF native (via la vérification des en-têtes `Origin` et `Host`).
|
||||
- **API Routes (REST) :** Si vous utilisez des routes d'API (`app/api/...`) modifiant des données (POST, PUT, DELETE), assurez-vous que les cookies de session utilisent l'attribut `SameSite: 'Lax'` ou `SameSite: 'Strict'`. Cela empêche le navigateur d'envoyer le cookie si la requête provient d'un autre domaine.
|
||||
|
||||
---
|
||||
|
||||
## 5. Contrôle d'Accès et Autorisations (Broken Access Control)
|
||||
|
||||
**La menace :**
|
||||
Un utilisateur accède à des ressources ou exécute des actions qu'il n'est pas censé pouvoir faire (ex: un utilisateur normal modifiant les réglages du site, ou accédant aux données d'une autre entreprise). On parle d'IDOR (Insecure Direct Object Reference).
|
||||
|
||||
**Comment corriger :**
|
||||
- **Vérification systématique côté serveur :** Ne vous fiez jamais au front-end pour bloquer l'accès. Dans chaque Route API (`GET`, `POST`, `DELETE`) ou Action Serveur, vérifiez :
|
||||
1. Si l'utilisateur est authentifié.
|
||||
2. S'il a le bon rôle (ex: `user.role === 'ADMIN'`).
|
||||
3. Si la ressource manipulée lui appartient (ex: `business.ownerId === user.id`).
|
||||
- *Exemple :* Si un utilisateur appelle `DELETE /api/businesses/123`, le serveur doit s'assurer que l'utilisateur qui fait la requête est le propriétaire de la boutique `123` ou un administrateur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Attaques par Déni de Service (DDoS) et Brute Force
|
||||
|
||||
**La menace :**
|
||||
Un attaquant submerge le serveur de requêtes pour le rendre indisponible, ou tente de deviner des mots de passe en envoyant des milliers de requêtes de connexion à la seconde.
|
||||
|
||||
**Comment corriger :**
|
||||
- **Rate Limiting (Limitation de débit) :** Implémentez un Rate Limiter pour bloquer les IP abusives.
|
||||
- Utilisez un middleware de limitation (ex: `@upstash/ratelimit` avec Redis si déployé en serverless, ou un outil proxy inversé comme Nginx / Cloudflare).
|
||||
- Pensez à limiter drastiquement les tentatives sur la route `/api/auth/login`.
|
||||
- Mettez en place Cloudflare (ou équivalent) devant votre serveur pour absorber les attaques DDoS massives.
|
||||
|
||||
---
|
||||
|
||||
## 7. Exposition de Données Sensibles (Sensitive Data Exposure)
|
||||
|
||||
**La menace :**
|
||||
Fuite de secrets (clés API, mots de passe de base de données) ou retour de trop d'informations aux clients (ex: renvoyer le mot de passe haché dans la réponse de l'API).
|
||||
|
||||
**Comment corriger :**
|
||||
- **Fichier `.env` :** Assurez-vous que le fichier `.env` est bien dans le `.gitignore` et n'est jamais commité sur GitHub.
|
||||
- **Select strict dans Prisma :** Lorsque vous récupérez un utilisateur, ne renvoyez jamais le mot de passe, même haché.
|
||||
```typescript
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: 1 },
|
||||
select: { id: true, name: true, email: true, role: true } // Ne sélectionnez PAS 'password'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. En-têtes de Sécurité HTTP
|
||||
|
||||
**La menace :**
|
||||
Les navigateurs web disposent de mécanismes de sécurité natifs qui doivent être activés par le serveur via des en-têtes HTTP. S'ils sont absents, le site s'expose à des attaques comme le Clickjacking ou le reniflage de type MIME.
|
||||
|
||||
**Comment corriger :**
|
||||
Ajoutez les en-têtes de sécurité dans votre fichier `next.config.js` :
|
||||
```javascript
|
||||
const securityHeaders = [
|
||||
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
|
||||
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, // Force le HTTPS
|
||||
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' }, // Empêche le Clickjacking (le site ne peut pas être mis dans une iframe tierce)
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Résumé et Priorités d'Action
|
||||
|
||||
Pour sécuriser Afropreunariat de manière robuste, voici le plan d'action par ordre de priorité :
|
||||
|
||||
1. **🔴 Critique : Sécuriser les XSS.** Installez `dompurify` et nettoyez systématiquement toutes les variables passées dans `dangerouslySetInnerHTML`.
|
||||
2. **🔴 Critique : Sécuriser la session.** Ne stockez plus les informations sensibles dans `localStorage`. Utilisez des cookies HttpOnly ou passez sur une librairie comme NextAuth.js.
|
||||
3. **🟠 Haute : Valider les autorisations (IDOR).** Passez en revue les Server Actions et API Routes (suppression d'entreprise, modification d'articles) pour s'assurer qu'elles vérifient l'identité et les permissions de l'utilisateur.
|
||||
4. **🟡 Moyenne : Configurer `next.config.js`.** Ajoutez les en-têtes de sécurité (Headers HTTP).
|
||||
5. **🟡 Moyenne : Rate Limiting.** Protégez vos routes d'authentification contre le brute force.
|
||||
Reference in New Issue
Block a user