ajout envoi smtp (paramètres, test envois, envoi à 1 participant). protège vue mot de passe

- ajout filtre page statistiques
This commit is contained in:
Yannick Le Duc
2025-08-25 18:28:14 +02:00
parent caed358661
commit b0a945f07b
21 changed files with 3523 additions and 30 deletions

144
SETTINGS.md Normal file
View File

@@ -0,0 +1,144 @@
# Paramètres de l'Application
## Vue d'ensemble
L'application dispose maintenant d'un système de paramètres global qui permet de configurer le comportement de l'application. Les paramètres sont organisés par catégories pour une meilleure organisation.
## Accès aux paramètres
1. Connectez-vous à l'interface d'administration
2. Cliquez sur le bouton "Paramètres" dans la barre d'outils
3. Vous accédez à la page de configuration des paramètres
## Catégories de paramètres
### Affichage
Cette catégorie contient les paramètres liés à l'affichage de l'interface utilisateur.
#### Ordre aléatoire des propositions
- **Clé** : `randomize_propositions`
- **Type** : Booléen (true/false)
- **Valeur par défaut** : `false`
- **Description** : Lorsque activé, les propositions sont affichées dans un ordre aléatoire pour chaque participant lors du vote.
**Comportement :**
- **Désactivé (Off)** : Les propositions sont affichées dans l'ordre chronologique de création
- **Activé (On)** : Les propositions sont mélangées aléatoirement pour chaque participant
**Impact :**
- Ce paramètre affecte uniquement la page de vote (`/campaigns/[id]/vote/[participantId]`)
- L'ordre aléatoire est appliqué à chaque chargement de la page
- Chaque participant voit un ordre différent, ce qui peut réduire les biais liés à l'ordre d'affichage
### Email
Cette catégorie contient les paramètres de configuration SMTP pour l'envoi d'emails automatiques.
#### Configuration SMTP
- **smtp_host** : Serveur SMTP (ex: smtp.gmail.com)
- **smtp_port** : Port SMTP (ex: 587 pour TLS, 465 pour SSL)
- **smtp_username** : Nom d'utilisateur SMTP
- **smtp_password** : Mot de passe SMTP (chiffré automatiquement)
- **smtp_secure** : Connexion sécurisée SSL/TLS (booléen)
- **smtp_from_email** : Adresse email d'expédition
- **smtp_from_name** : Nom d'expédition
**Sécurité :**
- Le mot de passe SMTP est automatiquement chiffré avec AES-256-GCM avant stockage
- La clé de chiffrement est dérivée de `SUPABASE_ANON_KEY`
- Seules les personnes ayant accès à la clé peuvent déchiffrer le mot de passe
**Fonctionnalités :**
- Test de connexion SMTP intégré
- Envoi d'email de test avec template HTML
- Validation des paramètres avant sauvegarde
- Interface avec masquage du mot de passe
- Sauvegarde automatique avec feedback visuel
## Structure technique
### Base de données
La table `settings` contient les paramètres :
```sql
CREATE TABLE settings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
```
### Services
Le service `settingsService` fournit les méthodes suivantes :
- `getAll()` : Récupère tous les paramètres
- `getByCategory(category)` : Récupère les paramètres d'une catégorie
- `getByKey(key)` : Récupère un paramètre par sa clé
- `getValue(key, defaultValue)` : Récupère la valeur d'un paramètre
- `getBooleanValue(key, defaultValue)` : Récupère la valeur booléenne d'un paramètre
- `setValue(key, value)` : Définit la valeur d'un paramètre
- `setBooleanValue(key, value)` : Définit la valeur booléenne d'un paramètre
- `getSmtpSettings()` : Récupère tous les paramètres SMTP
- `setSmtpSettings(settings)` : Sauvegarde les paramètres SMTP avec chiffrement
- `testSmtpConnection(settings)` : Teste la connexion SMTP
- `sendTestEmail(settings, toEmail)` : Envoie un email de test
### Chiffrement
Le service `encryptionService` fournit les méthodes suivantes :
- `encrypt(value)` : Chiffre une valeur avec AES-256-GCM
- `decrypt(encryptedValue)` : Déchiffre une valeur chiffrée
- `isEncrypted(value)` : Vérifie si une valeur est chiffrée
- `mask(value)` : Masque une valeur pour l'affichage
### Utilisation dans le code
```typescript
import { settingsService } from '@/lib/services';
// Récupérer un paramètre booléen
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', false);
// Utiliser le paramètre
if (randomizePropositions) {
// Mélanger les propositions
propositions.sort(() => Math.random() - 0.5);
}
// Récupérer les paramètres SMTP
const smtpSettings = await settingsService.getSmtpSettings();
// Envoyer un email de test
const result = await settingsService.sendTestEmail(smtpSettings, 'test@exemple.com');
```
## Migration
Pour ajouter la table des paramètres à une base de données existante :
1. **Paramètres de base** : Exécutez le script SQL dans `migration-settings.sql` dans l'éditeur SQL de Supabase
2. **Paramètres SMTP** : Exécutez ensuite le script SQL dans `migration-smtp-settings.sql`
## Ajout de nouveaux paramètres
Pour ajouter un nouveau paramètre :
1. Ajoutez l'insertion dans le script de migration
2. Utilisez le service `settingsService` dans votre code
3. Mettez à jour cette documentation
## Sécurité
- Tous les paramètres sont accessibles en lecture publique
- Seuls les administrateurs peuvent modifier les paramètres via l'interface d'administration
- Les paramètres sont protégés par l'AuthGuard

View File

@@ -0,0 +1,31 @@
-- Migration pour ajouter les paramètres SMTP
-- À exécuter dans l'éditeur SQL de Supabase après la migration des paramètres de base
-- Paramètres SMTP (seulement s'ils n'existent pas déjà)
INSERT INTO settings (key, value, category, description) VALUES
('smtp_host', '', 'email', 'Serveur SMTP')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_port', '587', 'email', 'Port SMTP')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_username', '', 'email', 'Nom d''utilisateur SMTP')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_password', '', 'email', 'Mot de passe SMTP (chiffré)')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_secure', 'true', 'email', 'Connexion sécurisée SSL/TLS')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_from_email', '', 'email', 'Adresse email d''expédition')
ON CONFLICT (key) DO NOTHING;
INSERT INTO settings (key, value, category, description) VALUES
('smtp_from_name', 'Mes Budgets Participatifs', 'email', 'Nom d''expédition')
ON CONFLICT (key) DO NOTHING;

1354
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,12 +17,15 @@
"@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@supabase/supabase-js": "^2.56.0", "@supabase/supabase-js": "^2.56.0",
"@types/nodemailer": "^7.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.541.0", "lucide-react": "^0.541.0",
"next": "15.5.0", "next": "15.5.0",
"nodemailer": "^7.0.5",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1"

View File

@@ -8,6 +8,7 @@ import AddParticipantModal from '@/components/AddParticipantModal';
import EditParticipantModal from '@/components/EditParticipantModal'; import EditParticipantModal from '@/components/EditParticipantModal';
import DeleteParticipantModal from '@/components/DeleteParticipantModal'; import DeleteParticipantModal from '@/components/DeleteParticipantModal';
import ImportCSVModal from '@/components/ImportCSVModal'; import ImportCSVModal from '@/components/ImportCSVModal';
import SendParticipantEmailModal from '@/components/SendParticipantEmailModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -29,6 +30,7 @@ function CampaignParticipantsPageContent() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false);
const [showSendEmailModal, setShowSendEmailModal] = useState(false);
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null); const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null); const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null);
@@ -357,6 +359,18 @@ function CampaignParticipantsPageContent() {
</> </>
)} )}
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSelectedParticipant(participant);
setShowSendEmailModal(true);
}}
className="text-xs"
>
<Mail className="w-3 h-3 mr-1" />
Envoyer un mail
</Button>
</div> </div>
</div> </div>
</div> </div>
@@ -402,6 +416,15 @@ function CampaignParticipantsPageContent() {
type="participants" type="participants"
campaignTitle={campaign?.title} campaignTitle={campaign?.title}
/> />
{selectedParticipant && campaign && (
<SendParticipantEmailModal
isOpen={showSendEmailModal}
onClose={() => setShowSendEmailModal(false)}
participant={selectedParticipant}
campaign={campaign}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import Navigation from '@/components/Navigation'; import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard';
import { import {
@@ -20,7 +21,12 @@ import {
Award, Award,
FileText, FileText,
Calendar, Calendar,
ArrowLeft ArrowLeft,
SortAsc,
TrendingDown,
Users2,
Target as TargetIcon,
Hash
} from 'lucide-react'; } from 'lucide-react';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -31,8 +37,29 @@ interface PropositionStats {
averageAmount: number; averageAmount: number;
minAmount: number; minAmount: number;
maxAmount: number; maxAmount: number;
totalAmount: number;
participationRate: number;
voteDistribution: number;
consensusScore: number;
} }
type SortOption =
| 'popularity'
| 'total_impact'
| 'consensus'
| 'engagement'
| 'distribution'
| 'alphabetical';
const sortOptions = [
{ value: 'popularity', label: 'Popularité', icon: TrendingUp, description: 'Moyenne décroissante puis nombre de votants' },
{ value: 'total_impact', label: 'Impact total', icon: Target, description: 'Somme totale investie décroissante' },
{ value: 'consensus', label: 'Consensus', icon: Users2, description: 'Plus petit écart-type = plus de consensus' },
{ value: 'engagement', label: 'Engagement', icon: Users, description: 'Taux de participation décroissant' },
{ value: 'distribution', label: 'Répartition', icon: BarChart3, description: 'Nombre de votes différents' },
{ value: 'alphabetical', label: 'Alphabétique', icon: Hash, description: 'Ordre alphabétique par titre' }
];
function CampaignStatsPageContent() { function CampaignStatsPageContent() {
const params = useParams(); const params = useParams();
const campaignId = params.id as string; const campaignId = params.id as string;
@@ -43,6 +70,7 @@ function CampaignStatsPageContent() {
const [votes, setVotes] = useState<Vote[]>([]); const [votes, setVotes] = useState<Vote[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [propositionStats, setPropositionStats] = useState<PropositionStats[]>([]); const [propositionStats, setPropositionStats] = useState<PropositionStats[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('popularity');
useEffect(() => { useEffect(() => {
if (campaignId) { if (campaignId) {
@@ -74,13 +102,33 @@ function CampaignStatsPageContent() {
const stats = propositionsData.map(proposition => { const stats = propositionsData.map(proposition => {
const propositionVotes = votesData.filter(vote => vote.proposition_id === proposition.id && vote.amount > 0); const propositionVotes = votesData.filter(vote => vote.proposition_id === proposition.id && vote.amount > 0);
const amounts = propositionVotes.map(vote => vote.amount); const amounts = propositionVotes.map(vote => vote.amount);
const totalAmount = amounts.reduce((sum, amount) => sum + amount, 0);
// Calculer l'écart-type pour le consensus
const mean = amounts.length > 0 ? totalAmount / amounts.length : 0;
const variance = amounts.length > 0
? amounts.reduce((sum, amount) => sum + Math.pow(amount - mean, 2), 0) / amounts.length
: 0;
const consensusScore = Math.sqrt(variance);
// Calculer le taux de participation pour cette proposition
const participationRate = participantsData.length > 0
? (propositionVotes.length / participantsData.length) * 100
: 0;
// Calculer la répartition des votes (nombre de montants différents)
const uniqueAmounts = new Set(amounts).size;
return { return {
proposition, proposition,
voteCount: propositionVotes.length, voteCount: propositionVotes.length,
averageAmount: amounts.length > 0 ? Math.round(amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length) : 0, averageAmount: amounts.length > 0 ? Math.round(totalAmount / amounts.length) : 0,
minAmount: amounts.length > 0 ? Math.min(...amounts) : 0, minAmount: amounts.length > 0 ? Math.min(...amounts) : 0,
maxAmount: amounts.length > 0 ? Math.max(...amounts) : 0 maxAmount: amounts.length > 0 ? Math.max(...amounts) : 0,
totalAmount,
participationRate: Math.round(participationRate * 100) / 100,
voteDistribution: uniqueAmounts,
consensusScore: Math.round(consensusScore * 100) / 100
}; };
}); });
@@ -92,6 +140,38 @@ function CampaignStatsPageContent() {
} }
}; };
const getSortedStats = () => {
const sorted = [...propositionStats];
switch (sortBy) {
case 'popularity':
return sorted.sort((a, b) => {
if (b.averageAmount !== a.averageAmount) {
return b.averageAmount - a.averageAmount;
}
return b.voteCount - a.voteCount;
});
case 'total_impact':
return sorted.sort((a, b) => b.totalAmount - a.totalAmount);
case 'consensus':
return sorted.sort((a, b) => a.consensusScore - b.consensusScore);
case 'engagement':
return sorted.sort((a, b) => b.participationRate - a.participationRate);
case 'distribution':
return sorted.sort((a, b) => b.voteDistribution - a.voteDistribution);
case 'alphabetical':
return sorted.sort((a, b) => a.proposition.title.localeCompare(b.proposition.title));
default:
return sorted;
}
};
const getParticipationRate = () => { const getParticipationRate = () => {
if (participants.length === 0) return 0; if (participants.length === 0) return 0;
const votedParticipants = participants.filter(p => { const votedParticipants = participants.filter(p => {
@@ -101,8 +181,6 @@ function CampaignStatsPageContent() {
return Math.round((votedParticipants.length / participants.length) * 100); return Math.round((votedParticipants.length / participants.length) * 100);
}; };
const getAverageVotesPerProposition = () => { const getAverageVotesPerProposition = () => {
if (propositions.length === 0) return 0; if (propositions.length === 0) return 0;
const totalVotes = votes.filter(v => v.amount > 0).length; const totalVotes = votes.filter(v => v.amount > 0).length;
@@ -230,6 +308,8 @@ function CampaignStatsPageContent() {
{/* Propositions Stats */} {/* Propositions Stats */}
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<VoteIcon className="w-5 h-5" /> <VoteIcon className="w-5 h-5" />
Préférences par proposition Préférences par proposition
@@ -237,6 +317,33 @@ function CampaignStatsPageContent() {
<CardDescription> <CardDescription>
Statistiques des montants exprimés par les participants pour chaque proposition Statistiques des montants exprimés par les participants pour chaque proposition
</CardDescription> </CardDescription>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 dark:text-slate-300">Trier par :</span>
<Select value={sortBy} onValueChange={(value: SortOption) => setSortBy(value)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortOptions.map((option) => {
const IconComponent = option.icon;
return (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<IconComponent className="w-4 h-4" />
<div>
<div className="font-medium">{option.label}</div>
<div className="text-xs text-slate-500">{option.description}</div>
</div>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{propositionStats.length === 0 ? ( {propositionStats.length === 0 ? (
@@ -251,9 +358,7 @@ function CampaignStatsPageContent() {
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{propositionStats {getSortedStats().map((stat, index) => (
.sort((a, b) => b.averageAmount - a.averageAmount) // Trier par moyenne décroissante
.map((stat, index) => (
<div key={stat.proposition.id} className="border rounded-lg p-6 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"> <div key={stat.proposition.id} className="border rounded-lg p-6 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex-1"> <div className="flex-1">
@@ -272,12 +377,16 @@ function CampaignStatsPageContent() {
{index === 0 && stat.averageAmount > 0 && ( {index === 0 && stat.averageAmount > 0 && (
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"> <Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<Award className="w-3 h-3 mr-1" /> <Award className="w-3 h-3 mr-1" />
Préférée {sortBy === 'popularity' ? 'Préférée' :
sortBy === 'total_impact' ? 'Plus d\'impact' :
sortBy === 'consensus' ? 'Plus de consensus' :
sortBy === 'engagement' ? 'Plus d\'engagement' :
sortBy === 'distribution' ? 'Plus de répartition' : 'Première'}
</Badge> </Badge>
)} )}
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400"> <p className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{stat.voteCount} {stat.voteCount}
@@ -292,6 +401,12 @@ function CampaignStatsPageContent() {
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400">Moyenne</p> <p className="text-xs text-slate-500 dark:text-slate-400">Moyenne</p>
</div> </div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{stat.totalAmount}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Total</p>
</div>
<div className="text-center"> <div className="text-center">
<p className="text-2xl font-bold text-orange-600 dark:text-orange-400"> <p className="text-2xl font-bold text-orange-600 dark:text-orange-400">
{stat.minAmount} {stat.minAmount}
@@ -304,6 +419,34 @@ function CampaignStatsPageContent() {
</p> </p>
<p className="text-xs text-slate-500 dark:text-slate-400">Maximum</p> <p className="text-xs text-slate-500 dark:text-slate-400">Maximum</p>
</div> </div>
<div className="text-center">
<p className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{stat.participationRate}%
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Participation</p>
</div>
</div>
{/* Métriques avancées */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex items-center gap-2">
<Users2 className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-600 dark:text-slate-300">Consensus</span>
</div>
<Badge variant="outline" className="text-xs">
Écart-type: {stat.consensusScore}
</Badge>
</div>
<div className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-600 dark:text-slate-300">Répartition</span>
</div>
<Badge variant="outline" className="text-xs">
{stat.voteDistribution} montants différents
</Badge>
</div>
</div> </div>
{stat.voteCount > 0 && ( {stat.voteCount > 0 && (

View File

@@ -13,7 +13,7 @@ import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import Navigation from '@/components/Navigation'; import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard'; import AuthGuard from '@/components/AuthGuard';
import { FolderOpen, Users, FileText, CheckCircle, Clock, Plus, BarChart3 } from 'lucide-react'; import { FolderOpen, Users, FileText, CheckCircle, Clock, Plus, BarChart3, Settings } from 'lucide-react';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -123,12 +123,20 @@ function AdminPageContent() {
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Administration</h1> <h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Administration</h1>
<p className="text-slate-600 dark:text-slate-300 mt-2">Gérez vos campagnes de budget participatif</p> <p className="text-slate-600 dark:text-slate-300 mt-2">Gérez vos campagnes de budget participatif</p>
</div> </div>
<div className="flex gap-2">
<Button asChild variant="outline" size="lg">
<Link href="/admin/settings">
<Settings className="w-4 h-4 mr-2" />
Paramètres
</Link>
</Button>
<Button onClick={() => setShowCreateModal(true)} size="lg"> <Button onClick={() => setShowCreateModal(true)} size="lg">
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Nouvelle campagne Nouvelle campagne
</Button> </Button>
</div> </div>
</div> </div>
</div>
{/* Stats Overview */} {/* Stats Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">

View File

@@ -0,0 +1,181 @@
'use client';
import { useState, useEffect } from 'react';
import { Setting } from '@/types';
import { settingsService } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard';
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
import { Settings, Monitor, Save, CheckCircle, Mail } from 'lucide-react';
export const dynamic = 'force-dynamic';
function SettingsPageContent() {
const [settings, setSettings] = useState<Setting[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [randomizePropositions, setRandomizePropositions] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const settingsData = await settingsService.getAll();
setSettings(settingsData);
// Charger la valeur du paramètre d'ordre aléatoire
const randomizeValue = await settingsService.getBooleanValue('randomize_propositions', false);
setRandomizePropositions(randomizeValue);
} catch (error) {
console.error('Erreur lors du chargement des paramètres:', error);
} finally {
setLoading(false);
}
};
const handleRandomizeChange = async (checked: boolean) => {
setRandomizePropositions(checked);
};
const handleSave = async () => {
try {
setSaving(true);
await settingsService.setBooleanValue('randomize_propositions', randomizePropositions);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (error) {
console.error('Erreur lors de la sauvegarde des paramètres:', error);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="container mx-auto px-4 py-8">
<Navigation />
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-slate-900 dark:border-slate-100 mx-auto mb-4"></div>
<p className="text-slate-600 dark:text-slate-300">Chargement des paramètres...</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="container mx-auto px-4 py-8">
<Navigation />
{/* Header */}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Paramètres</h1>
<p className="text-slate-600 dark:text-slate-300 mt-2">Configurez les paramètres de l'application</p>
</div>
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sauvegarde...
</>
) : saved ? (
<>
<CheckCircle className="w-4 h-4" />
Sauvegardé
</>
) : (
<>
<Save className="w-4 h-4" />
Sauvegarder
</>
)}
</Button>
</div>
</div>
{/* Settings Categories */}
<div className="space-y-8">
{/* Affichage Category */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<Monitor className="w-5 h-5 text-blue-600 dark:text-blue-300" />
</div>
<div>
<CardTitle className="text-xl">Affichage</CardTitle>
<CardDescription>
Paramètres d'affichage de l'interface utilisateur
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Randomize Propositions Setting */}
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex-1">
<Label htmlFor="randomize-propositions" className="text-base font-medium">
Afficher les propositions dans un ordre aléatoire lors du vote
</Label>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
Lorsque activé, les propositions seront affichées dans un ordre aléatoire pour chaque participant lors du vote.
</p>
</div>
<Switch
id="randomize-propositions"
checked={randomizePropositions}
onCheckedChange={handleRandomizeChange}
className="ml-4"
/>
</div>
</CardContent>
</Card>
{/* Email Category */}
<SmtpSettingsForm onSave={() => {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}} />
{/* Future Categories Placeholder */}
<Card className="border-dashed">
<CardContent className="p-8 text-center">
<Settings className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
Plus de catégories à venir
</h3>
<p className="text-slate-600 dark:text-slate-300">
D'autres catégories de paramètres seront ajoutées prochainement.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function SettingsPage() {
return (
<AuthGuard>
<SettingsPageContent />
</AuthGuard>
);
}

View File

@@ -0,0 +1,151 @@
import { NextRequest, NextResponse } from 'next/server';
import * as nodemailer from 'nodemailer';
import { SmtpSettings } from '@/types';
export async function POST(request: NextRequest) {
try {
const {
smtpSettings,
toEmail,
toName,
subject,
message,
campaignTitle,
voteUrl
} = await request.json();
// Validation des paramètres
if (!smtpSettings || !toEmail || !toName || !subject || !message) {
return NextResponse.json(
{ success: false, error: 'Paramètres manquants' },
{ status: 400 }
);
}
// Validation de l'email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(toEmail)) {
return NextResponse.json(
{ success: false, error: 'Adresse email de destination invalide' },
{ status: 400 }
);
}
// Validation des paramètres SMTP
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
return NextResponse.json(
{ success: false, error: 'Paramètres SMTP incomplets' },
{ status: 400 }
);
}
// Créer le transporteur SMTP
const transporter = nodemailer.createTransport({
host: smtpSettings.host,
port: smtpSettings.port,
secure: smtpSettings.secure,
auth: {
user: smtpSettings.username,
pass: smtpSettings.password,
},
tls: {
rejectUnauthorized: false,
},
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 10000,
});
// Vérifier la connexion
await transporter.verify();
// Créer le contenu HTML de l'email
const htmlContent = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; line-height: 1.6;">
<div style="background-color: #2563eb; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="margin: 0; font-size: 24px;">Mes Budgets Participatifs</h1>
</div>
<div style="background-color: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none;">
<h2 style="color: #1f2937; margin-top: 0;">Bonjour ${toName},</h2>
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #374151;">Campagne : ${campaignTitle}</h3>
<p style="margin-bottom: 0; color: #6b7280;">${message.replace(/\n/g, '<br>')}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="${voteUrl}"
style="background-color: #2563eb; color: white; padding: 15px 30px; text-decoration: none; border-radius: 6px; font-weight: bold; display: inline-block;">
🗳️ Voter maintenant
</a>
</div>
<div style="background-color: #fef3c7; padding: 15px; border-radius: 6px; margin: 20px 0;">
<p style="margin: 0; color: #92400e; font-size: 14px;">
<strong>💡 Conseil :</strong> Ce lien est personnel et unique. Ne le partagez pas avec d'autres personnes.
</p>
</div>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 14px; margin-bottom: 10px;">
Si le bouton ne fonctionne pas, vous pouvez copier et coller ce lien dans votre navigateur :
</p>
<p style="background-color: #f3f4f6; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; word-break: break-all; color: #374151;">
${voteUrl}
</p>
</div>
<div style="background-color: #f9fafb; padding: 20px; text-align: center; border-radius: 0 0 8px 8px; border: 1px solid #e5e7eb; border-top: none;">
<p style="color: #6b7280; font-size: 12px; margin: 0;">
Cet email a été envoyé automatiquement par Mes Budgets Participatifs.<br>
Si vous avez des questions, contactez l'administrateur de la campagne.
</p>
</div>
</div>
`;
// Envoyer l'email
const info = await transporter.sendMail({
from: `"${smtpSettings.from_name}" <${smtpSettings.from_email}>`,
to: `"${toName}" <${toEmail}>`,
subject: subject,
text: message,
html: htmlContent,
});
return NextResponse.json({
success: true,
messageId: info.messageId,
message: 'Email envoyé avec succès'
});
} catch (error) {
console.error('Erreur lors de l\'envoi de l\'email au participant:', error);
let errorMessage = 'Erreur lors de l\'envoi de l\'email';
if (error instanceof Error) {
if (error.message.includes('EBADNAME')) {
errorMessage = 'Impossible de résoudre le nom d\'hôte SMTP. Vérifiez que le serveur SMTP est correct.';
} else if (error.message.includes('ECONNREFUSED')) {
errorMessage = 'Connexion refusée. Vérifiez le serveur et le port SMTP.';
} else if (error.message.includes('ETIMEDOUT')) {
errorMessage = 'Délai d\'attente dépassé. Vérifiez votre connexion internet et les paramètres SMTP.';
} else if (error.message.includes('EAUTH')) {
errorMessage = 'Authentification échouée. Vérifiez le nom d\'utilisateur et le mot de passe SMTP.';
} else {
errorMessage = error.message;
}
}
return NextResponse.json(
{
success: false,
error: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server';
import * as nodemailer from 'nodemailer';
import { SmtpSettings } from '@/types';
export async function POST(request: NextRequest) {
try {
const { smtpSettings, toEmail } = await request.json();
// Validation des paramètres
if (!smtpSettings || !toEmail) {
return NextResponse.json(
{ success: false, error: 'Paramètres manquants' },
{ status: 400 }
);
}
// Validation de l'email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(toEmail)) {
return NextResponse.json(
{ success: false, error: 'Adresse email de destination invalide' },
{ status: 400 }
);
}
// Validation des paramètres SMTP
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
return NextResponse.json(
{ success: false, error: 'Paramètres SMTP incomplets' },
{ status: 400 }
);
}
// Créer le transporteur SMTP avec options de résolution DNS
const transporter = nodemailer.createTransport({
host: smtpSettings.host,
port: smtpSettings.port,
secure: smtpSettings.secure, // true pour 465, false pour les autres ports
auth: {
user: smtpSettings.username,
pass: smtpSettings.password,
},
// Options pour résoudre les problèmes DNS
tls: {
rejectUnauthorized: false, // Accepte les certificats auto-signés
},
// Timeout pour éviter les blocages
connectionTimeout: 10000, // 10 secondes
greetingTimeout: 10000,
socketTimeout: 10000,
});
// Vérifier la connexion
await transporter.verify();
// Envoyer l'email de test
const info = await transporter.sendMail({
from: `"${smtpSettings.from_name}" <${smtpSettings.from_email}>`,
to: toEmail,
subject: 'Test de configuration SMTP - Mes Budgets Participatifs',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #2563eb;">✅ Test de configuration SMTP réussi !</h2>
<p>Bonjour,</p>
<p>Cet email confirme que votre configuration SMTP fonctionne correctement.</p>
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Configuration utilisée :</h3>
<ul style="margin: 0; padding-left: 20px;">
<li><strong>Serveur :</strong> ${smtpSettings.host}:${smtpSettings.port}</li>
<li><strong>Sécurisé :</strong> ${smtpSettings.secure ? 'Oui (SSL/TLS)' : 'Non'}</li>
<li><strong>Utilisateur :</strong> ${smtpSettings.username}</li>
<li><strong>Expéditeur :</strong> ${smtpSettings.from_name} &lt;${smtpSettings.from_email}&gt;</li>
</ul>
</div>
<p>Vous pouvez maintenant utiliser cette configuration pour envoyer des emails automatiques depuis votre application.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #6b7280; font-size: 12px;">
Cet email a été envoyé automatiquement par Mes Budgets Participatifs pour tester la configuration SMTP.
</p>
</div>
`,
text: `
Test de configuration SMTP réussi !
Bonjour,
Cet email confirme que votre configuration SMTP fonctionne correctement.
Configuration utilisée :
- Serveur : ${smtpSettings.host}:${smtpSettings.port}
- Sécurisé : ${smtpSettings.secure ? 'Oui (SSL/TLS)' : 'Non'}
- Utilisateur : ${smtpSettings.username}
- Expéditeur : ${smtpSettings.from_name} <${smtpSettings.from_email}>
Vous pouvez maintenant utiliser cette configuration pour envoyer des emails automatiques depuis votre application.
---
Cet email a été envoyé automatiquement par Mes Budgets Participatifs pour tester la configuration SMTP.
`
});
return NextResponse.json({
success: true,
messageId: info.messageId
});
} catch (error) {
console.error('Erreur lors de l\'envoi de l\'email de test:', error);
let errorMessage = 'Erreur lors de l\'envoi de l\'email';
if (error instanceof Error) {
if (error.message.includes('EBADNAME')) {
errorMessage = 'Impossible de résoudre le nom d\'hôte SMTP. Vérifiez que le serveur SMTP est correct.';
} else if (error.message.includes('ECONNREFUSED')) {
errorMessage = 'Connexion refusée. Vérifiez le serveur et le port SMTP.';
} else if (error.message.includes('ETIMEDOUT')) {
errorMessage = 'Délai d\'attente dépassé. Vérifiez votre connexion internet et les paramètres SMTP.';
} else if (error.message.includes('EAUTH')) {
errorMessage = 'Authentification échouée. Vérifiez le nom d\'utilisateur et le mot de passe.';
} else {
errorMessage = error.message;
}
}
return NextResponse.json(
{
success: false,
error: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
import * as nodemailer from 'nodemailer';
import { SmtpSettings } from '@/types';
export async function POST(request: NextRequest) {
try {
const { smtpSettings } = await request.json();
// Validation des paramètres
if (!smtpSettings) {
return NextResponse.json(
{ success: false, error: 'Paramètres SMTP manquants' },
{ status: 400 }
);
}
// Validation des paramètres SMTP
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
return NextResponse.json(
{ success: false, error: 'Paramètres SMTP incomplets' },
{ status: 400 }
);
}
// Validation du port
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
return NextResponse.json(
{ success: false, error: 'Port SMTP invalide' },
{ status: 400 }
);
}
// Validation de l'email d'expédition
if (!smtpSettings.from_email.includes('@')) {
return NextResponse.json(
{ success: false, error: 'Adresse email d\'expédition invalide' },
{ status: 400 }
);
}
// Créer le transporteur SMTP avec options de résolution DNS
const transporter = nodemailer.createTransport({
host: smtpSettings.host,
port: smtpSettings.port,
secure: smtpSettings.secure, // true pour 465, false pour les autres ports
auth: {
user: smtpSettings.username,
pass: smtpSettings.password,
},
// Options pour résoudre les problèmes DNS
tls: {
rejectUnauthorized: false, // Accepte les certificats auto-signés
},
// Timeout pour éviter les blocages
connectionTimeout: 10000, // 10 secondes
greetingTimeout: 10000,
socketTimeout: 10000,
});
// Vérifier la connexion
await transporter.verify();
return NextResponse.json({
success: true,
message: 'Connexion SMTP réussie'
});
} catch (error) {
console.error('Erreur lors du test de connexion SMTP:', error);
let errorMessage = 'Erreur de connexion SMTP';
if (error instanceof Error) {
if (error.message.includes('EBADNAME')) {
errorMessage = 'Impossible de résoudre le nom d\'hôte SMTP. Vérifiez que le serveur SMTP est correct.';
} else if (error.message.includes('ECONNREFUSED')) {
errorMessage = 'Connexion refusée. Vérifiez le serveur et le port SMTP.';
} else if (error.message.includes('ETIMEDOUT')) {
errorMessage = 'Délai d\'attente dépassé. Vérifiez votre connexion internet et les paramètres SMTP.';
} else if (error.message.includes('EAUTH')) {
errorMessage = 'Authentification échouée. Vérifiez le nom d\'utilisateur et le mot de passe.';
} else {
errorMessage = error.message;
}
}
return NextResponse.json(
{
success: false,
error: errorMessage
},
{ status: 500 }
);
}
}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types'; import { Campaign, Proposition, Participant, Vote, PropositionWithVote } from '@/types';
import { campaignService, participantService, propositionService, voteService } from '@/lib/services'; import { campaignService, participantService, propositionService, voteService, settingsService } from '@/lib/services';
// Force dynamic rendering to avoid SSR issues with Supabase // Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -26,6 +26,7 @@ export default function PublicVotePage() {
// Votes temporaires stockés localement // Votes temporaires stockés localement
const [localVotes, setLocalVotes] = useState<Record<string, number>>({}); const [localVotes, setLocalVotes] = useState<Record<string, number>>({});
const [totalVoted, setTotalVoted] = useState(0); const [totalVoted, setTotalVoted] = useState(0);
const [isRandomOrder, setIsRandomOrder] = useState(false);
useEffect(() => { useEffect(() => {
if (campaignId && participantId) { if (campaignId && participantId) {
@@ -73,11 +74,20 @@ export default function PublicVotePage() {
const votes = await voteService.getByParticipant(campaignId, participantId); const votes = await voteService.getByParticipant(campaignId, participantId);
// Combiner les propositions avec leurs votes // Combiner les propositions avec leurs votes
const propositionsWithVotes = propositionsData.map(proposition => ({ let propositionsWithVotes = propositionsData.map(proposition => ({
...proposition, ...proposition,
vote: votes.find(vote => vote.proposition_id === proposition.id) vote: votes.find(vote => vote.proposition_id === proposition.id)
})); }));
// Vérifier si l'ordre aléatoire est activé
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', false);
if (randomizePropositions) {
// Mélanger les propositions de manière aléatoire
propositionsWithVotes = propositionsWithVotes.sort(() => Math.random() - 0.5);
setIsRandomOrder(true);
}
setPropositions(propositionsWithVotes); setPropositions(propositionsWithVotes);
// Initialiser les votes locaux avec les votes existants // Initialiser les votes locaux avec les votes existants
@@ -278,6 +288,14 @@ export default function PublicVotePage() {
<div> <div>
<h3 className="text-sm font-medium text-gray-500">Description</h3> <h3 className="text-sm font-medium text-gray-500">Description</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.description}</p> <p className="mt-1 text-sm text-gray-900">{campaign?.description}</p>
{isRandomOrder && (
<div className="mt-3 p-2 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-xs text-blue-700 flex items-center gap-1">
<span className="text-blue-500"></span>
Les propositions sont affichées dans un ordre aléatoire pour éviter les biais liés à l'ordre de présentation.
</p>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,229 @@
'use client';
import { useState, useEffect } from 'react';
import { Participant, Campaign } from '@/types';
import { settingsService } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Mail, Send, CheckCircle, XCircle } from 'lucide-react';
interface SendParticipantEmailModalProps {
isOpen: boolean;
onClose: () => void;
participant: Participant;
campaign: Campaign;
}
export default function SendParticipantEmailModal({
isOpen,
onClose,
participant,
campaign
}: SendParticipantEmailModalProps) {
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);
// Générer le lien de vote
const voteUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/campaigns/${campaign.id}/vote/${participant.id}`;
// Initialiser le message par défaut quand le modal s'ouvre
useEffect(() => {
if (isOpen && campaign && participant) {
setSubject(`Votez pour la campagne "${campaign.title}"`);
setMessage(`Bonjour ${participant.first_name},
Vous êtes invité(e) à participer au vote pour la campagne "${campaign.title}".
${campaign.description}
Pour voter, cliquez sur le lien suivant :
${voteUrl}
Vous disposez d'un budget de ${campaign.budget_per_user}€ à répartir entre les propositions selon vos préférences.
Merci de votre participation !
Cordialement,
L'équipe Mes Budgets Participatifs`);
}
}, [isOpen, campaign, participant]);
const handleSendEmail = async () => {
if (!subject.trim() || !message.trim()) {
setResult({ success: false, message: 'Veuillez remplir le sujet et le message' });
return;
}
try {
setSending(true);
setResult(null);
// Récupérer les paramètres SMTP
const smtpSettings = await settingsService.getSmtpSettings();
if (!smtpSettings.host || !smtpSettings.username || !smtpSettings.password) {
setResult({ success: false, message: 'Configuration SMTP manquante. Veuillez configurer les paramètres email dans les paramètres.' });
return;
}
// Envoyer l'email via l'API
const response = await fetch('/api/send-participant-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
smtpSettings,
toEmail: participant.email,
toName: `${participant.first_name} ${participant.last_name}`,
subject: subject.trim(),
message: message.trim(),
campaignTitle: campaign.title,
voteUrl
}),
});
const result = await response.json();
if (result.success) {
setResult({ success: true, message: 'Email envoyé avec succès !' });
// Vider les champs après succès
setTimeout(() => {
setSubject('');
setMessage('');
onClose();
}, 2000);
} else {
setResult({ success: false, message: result.error || 'Erreur lors de l\'envoi de l\'email' });
}
} catch (error) {
setResult({ success: false, message: 'Erreur inattendue lors de l\'envoi' });
} finally {
setSending(false);
}
};
const handleClose = () => {
setSubject('');
setMessage('');
setResult(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
Envoyer un email à {participant.first_name} {participant.last_name}
</DialogTitle>
<DialogDescription>
Envoyez un email personnalisé à ce participant avec le lien de vote.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Informations du participant */}
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">
Destinataire :
</h4>
<div className="text-sm text-slate-600 dark:text-slate-300">
<div><strong>Nom :</strong> {participant.first_name} {participant.last_name}</div>
<div><strong>Email :</strong> {participant.email}</div>
<div><strong>Campagne :</strong> {campaign.title}</div>
</div>
</div>
{/* Sujet */}
<div className="space-y-2">
<Label htmlFor="email-subject">Sujet *</Label>
<Input
id="email-subject"
type="text"
placeholder="Sujet de l'email"
value={subject}
onChange={(e) => setSubject(e.target.value)}
disabled={sending}
/>
</div>
{/* Message */}
<div className="space-y-2">
<Label htmlFor="email-message">Message *</Label>
<Textarea
id="email-message"
placeholder="Votre message..."
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={12}
disabled={sending}
className="font-mono text-sm"
/>
<div className="text-xs text-slate-500 dark:text-slate-400">
Le lien de vote sera automatiquement inclus dans votre message.
</div>
</div>
{/* Aperçu du lien */}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Lien de vote qui sera inclus :
</h4>
<div className="text-xs text-blue-700 dark:text-blue-300 font-mono break-all">
{voteUrl}
</div>
</div>
{/* Résultat */}
{result && (
<Alert className={result.success ? 'border-green-200 bg-green-50 dark:bg-green-900/20' : 'border-red-200 bg-red-50 dark:bg-red-900/20'}>
{result.success ? (
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
<AlertDescription className={result.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}>
{result.message}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={sending}
>
Annuler
</Button>
<Button
onClick={handleSendEmail}
disabled={sending || !subject.trim() || !message.trim()}
className="flex items-center gap-2"
>
{sending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Envoi en cours...
</>
) : (
<>
<Send className="w-4 h-4" />
Envoyer l'email
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,154 @@
'use client';
import { useState } from 'react';
import { SmtpSettings } from '@/types';
import { settingsService } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Mail, Send, CheckCircle, XCircle } from 'lucide-react';
interface SendTestEmailModalProps {
isOpen: boolean;
onClose: () => void;
smtpSettings: SmtpSettings;
}
export default function SendTestEmailModal({ isOpen, onClose, smtpSettings }: SendTestEmailModalProps) {
const [toEmail, setToEmail] = useState('');
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ success: boolean; message: string; messageId?: string } | null>(null);
const handleSendTestEmail = async () => {
if (!toEmail.trim()) {
setResult({ success: false, message: 'Veuillez saisir une adresse email de destination' });
return;
}
try {
setSending(true);
setResult(null);
const response = await settingsService.sendTestEmail(smtpSettings, toEmail.trim());
if (response.success) {
// Sauvegarder automatiquement les paramètres si l'envoi réussit
try {
await settingsService.setSmtpSettings(smtpSettings);
setResult({
success: true,
message: `Email de test envoyé avec succès ! ID: ${response.messageId} Les paramètres ont été sauvegardés automatiquement.`,
messageId: response.messageId
});
} catch (saveError) {
console.error('Erreur lors de la sauvegarde automatique:', saveError);
setResult({
success: true,
message: `Email de test envoyé avec succès ! ID: ${response.messageId} (Erreur lors de la sauvegarde automatique)`,
messageId: response.messageId
});
}
setToEmail(''); // Vider le champ après succès
} else {
setResult({ success: false, message: response.error || 'Erreur lors de l\'envoi' });
}
} catch (error) {
setResult({ success: false, message: 'Erreur inattendue lors de l\'envoi' });
} finally {
setSending(false);
}
};
const handleClose = () => {
setToEmail('');
setResult(null);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
Envoyer un email de test
</DialogTitle>
<DialogDescription>
Envoyez un email de test pour vérifier que votre configuration SMTP fonctionne correctement.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Configuration SMTP affichée */}
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-2">
Configuration utilisée :
</h4>
<div className="text-xs text-slate-600 dark:text-slate-300 space-y-1">
<div><strong>Serveur :</strong> {smtpSettings.host}:{smtpSettings.port}</div>
<div><strong>Sécurisé :</strong> {smtpSettings.secure ? 'Oui (SSL/TLS)' : 'Non'}</div>
<div><strong>Utilisateur :</strong> {smtpSettings.username}</div>
<div><strong>Expéditeur :</strong> {smtpSettings.from_name} &lt;{smtpSettings.from_email}&gt;</div>
</div>
</div>
{/* Champ email de destination */}
<div className="space-y-2">
<Label htmlFor="test-email">Adresse email de destination *</Label>
<Input
id="test-email"
type="email"
placeholder="destinataire@exemple.com"
value={toEmail}
onChange={(e) => setToEmail(e.target.value)}
disabled={sending}
/>
</div>
{/* Résultat */}
{result && (
<Alert className={result.success ? 'border-green-200 bg-green-50 dark:bg-green-900/20' : 'border-red-200 bg-red-50 dark:bg-red-900/20'}>
{result.success ? (
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
)}
<AlertDescription className={result.success ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}>
{result.message}
</AlertDescription>
</Alert>
)}
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={sending}
>
Annuler
</Button>
<Button
onClick={handleSendTestEmail}
disabled={sending || !toEmail.trim()}
className="flex items-center gap-2"
>
{sending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Envoi en cours...
</>
) : (
<>
<Send className="w-4 h-4" />
Envoyer l'email de test
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,400 @@
'use client';
import { useState, useEffect } from 'react';
import { SmtpSettings } from '@/types';
import { settingsService } from '@/lib/services';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Mail, Eye, EyeOff, TestTube, Save, CheckCircle, Send } from 'lucide-react';
import SendTestEmailModal from './SendTestEmailModal';
interface SmtpSettingsFormProps {
onSave?: () => void;
}
export default function SmtpSettingsForm({ onSave }: SmtpSettingsFormProps) {
const [settings, setSettings] = useState<SmtpSettings>({
host: '',
port: 587,
username: '',
password: '',
secure: true,
from_email: '',
from_name: ''
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [showTestEmailModal, setShowTestEmailModal] = useState(false);
const [passwordValue, setPasswordValue] = useState('');
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const smtpSettings = await settingsService.getSmtpSettings();
setSettings(smtpSettings);
// Ne pas stocker le mot de passe en clair dans l'état local
setPasswordValue('');
} catch (error) {
console.error('Erreur lors du chargement des paramètres SMTP:', error);
setMessage({ type: 'error', text: 'Erreur lors du chargement des paramètres' });
} finally {
setLoading(false);
}
};
const handleInputChange = (field: keyof SmtpSettings, value: string | number | boolean) => {
setSettings(prev => ({
...prev,
[field]: value
}));
// Si c'est le mot de passe, mettre à jour aussi l'état local
if (field === 'password') {
setPasswordValue(value as string);
}
};
const handleSave = async () => {
try {
setSaving(true);
setMessage(null);
// Utiliser le mot de passe saisi si disponible, sinon utiliser celui des settings
const settingsToSave = {
...settings,
password: passwordValue || settings.password
};
await settingsService.setSmtpSettings(settingsToSave);
setMessage({ type: 'success', text: 'Paramètres SMTP sauvegardés avec succès' });
onSave?.();
// Effacer le message après 3 secondes
setTimeout(() => setMessage(null), 3000);
} catch (error) {
console.error('Erreur lors de la sauvegarde:', error);
setMessage({ type: 'error', text: 'Erreur lors de la sauvegarde des paramètres' });
} finally {
setSaving(false);
}
};
const handleTestConnection = async () => {
try {
setTesting(true);
setMessage(null);
// Utiliser le mot de passe saisi si disponible
const settingsToTest = {
...settings,
password: passwordValue || settings.password
};
const result = await settingsService.testSmtpConnection(settingsToTest);
if (result.success) {
// Sauvegarder automatiquement les paramètres si le test réussit
try {
await settingsService.setSmtpSettings(settingsToTest);
setMessage({ type: 'success', text: 'Test de connexion SMTP réussi ! Les paramètres ont été sauvegardés automatiquement.' });
onSave?.(); // Appeler le callback de sauvegarde
} catch (saveError) {
console.error('Erreur lors de la sauvegarde automatique:', saveError);
setMessage({ type: 'success', text: 'Test de connexion SMTP réussi ! (Erreur lors de la sauvegarde automatique)' });
}
} else {
setMessage({ type: 'error', text: `Test de connexion échoué : ${result.error}` });
}
} catch (error) {
console.error('Erreur lors du test:', error);
setMessage({ type: 'error', text: 'Erreur lors du test de connexion' });
} finally {
setTesting(false);
}
};
if (loading) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-slate-900 dark:border-slate-100"></div>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<Mail className="w-5 h-5 text-green-600 dark:text-green-300" />
</div>
<div>
<CardTitle className="text-xl">Configuration Email</CardTitle>
<CardDescription>
Paramètres SMTP pour l'envoi d'emails automatiques
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{message && (
<Alert className={message.type === 'success' ? 'border-green-200 bg-green-50 dark:bg-green-900/20' : 'border-red-200 bg-red-50 dark:bg-red-900/20'}>
<AlertDescription className={message.type === 'success' ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}>
{message.text}
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Serveur SMTP */}
<div className="space-y-2">
<Label htmlFor="smtp-host">Serveur SMTP *</Label>
<Input
id="smtp-host"
type="text"
placeholder="smtp.gmail.com"
value={settings.host}
onChange={(e) => handleInputChange('host', e.target.value)}
/>
<div className="text-xs text-slate-500 dark:text-slate-400">
Exemples : smtp.gmail.com, smtp.office365.com, mail.infomaniak.com
</div>
</div>
{/* Port SMTP */}
<div className="space-y-2">
<Label htmlFor="smtp-port">Port SMTP *</Label>
<Input
id="smtp-port"
type="number"
placeholder="587"
value={settings.port}
onChange={(e) => handleInputChange('port', parseInt(e.target.value) || 587)}
/>
<div className="text-xs text-slate-500 dark:text-slate-400">
Ports courants : 587 (TLS), 465 (SSL), 25 (non sécurisé)
</div>
</div>
{/* Nom d'utilisateur */}
<div className="space-y-2">
<Label htmlFor="smtp-username">Nom d'utilisateur *</Label>
<Input
id="smtp-username"
type="text"
placeholder="votre-email@gmail.com"
value={settings.username}
onChange={(e) => handleInputChange('username', e.target.value)}
/>
</div>
{/* Mot de passe */}
<div className="space-y-2">
<Label htmlFor="smtp-password">Mot de passe *</Label>
<div className="relative">
<Input
id="smtp-password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={showPassword ? passwordValue : (passwordValue ? '' : '')}
onChange={(e) => handleInputChange('password', e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
{passwordValue && (
<div className="text-xs text-slate-500 dark:text-slate-400">
Mot de passe saisi. Cliquez sur l'œil pour le voir.
</div>
)}
</div>
{/* Email d'expédition */}
<div className="space-y-2">
<Label htmlFor="smtp-from-email">Email d'expédition *</Label>
<Input
id="smtp-from-email"
type="email"
placeholder="noreply@votre-domaine.com"
value={settings.from_email}
onChange={(e) => handleInputChange('from_email', e.target.value)}
/>
</div>
{/* Nom d'expédition */}
<div className="space-y-2">
<Label htmlFor="smtp-from-name">Nom d'expédition</Label>
<Input
id="smtp-from-name"
type="text"
placeholder="Mes Budgets Participatifs"
value={settings.from_name}
onChange={(e) => handleInputChange('from_name', e.target.value)}
/>
</div>
</div>
{/* Connexion sécurisée */}
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div className="flex-1">
<Label htmlFor="smtp-secure" className="text-base font-medium">
Connexion sécurisée (SSL/TLS)
</Label>
<p className="text-sm text-slate-600 dark:text-slate-300 mt-1">
Activez cette option pour utiliser une connexion chiffrée SSL/TLS
</p>
</div>
<Switch
id="smtp-secure"
checked={settings.secure}
onCheckedChange={(checked) => handleInputChange('secure', checked)}
className="ml-4"
/>
</div>
{/* Boutons d'action */}
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button
onClick={handleTestConnection}
disabled={testing || !settings.host || !settings.username || !settings.password}
variant="outline"
className="flex items-center gap-2"
>
{testing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Test en cours...
</>
) : (
<>
<TestTube className="w-4 h-4" />
Tester la connexion
</>
)}
</Button>
<Button
onClick={() => setShowTestEmailModal(true)}
disabled={!settings.host || !settings.username || (!passwordValue && !settings.password) || !settings.from_email}
variant="outline"
className="flex items-center gap-2"
>
<Send className="w-4 h-4" />
Envoyer un email de test
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sauvegarde...
</>
) : (
<>
<Save className="w-4 h-4" />
Sauvegarder
</>
)}
</Button>
</div>
{/* Informations de sécurité */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
🔒 Sécurité
</h4>
<p className="text-sm text-blue-700 dark:text-blue-300">
Le mot de passe SMTP est automatiquement chiffré avant d'être stocké dans la base de données.
Seules les personnes ayant accès à la clé de chiffrement peuvent le déchiffrer.
</p>
</div>
{/* Aide pour les configurations SMTP populaires */}
<div className="mt-4 p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg">
<h4 className="text-sm font-medium text-slate-900 dark:text-slate-100 mb-3">
💡 Configurations SMTP populaires
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div>
<h5 className="font-medium text-slate-700 dark:text-slate-300 mb-1">Gmail</h5>
<div className="text-slate-600 dark:text-slate-400 space-y-1">
<div>Serveur : smtp.gmail.com</div>
<div>Port : 587</div>
<div>SSL/TLS : Activé</div>
</div>
</div>
<div>
<h5 className="font-medium text-slate-700 dark:text-slate-300 mb-1">Outlook/Hotmail</h5>
<div className="text-slate-600 dark:text-slate-400 space-y-1">
<div>Serveur : smtp-mail.outlook.com</div>
<div>Port : 587</div>
<div>SSL/TLS : Activé</div>
</div>
</div>
<div>
<h5 className="font-medium text-slate-700 dark:text-slate-300 mb-1">Yahoo</h5>
<div className="text-slate-600 dark:text-slate-400 space-y-1">
<div>Serveur : smtp.mail.yahoo.com</div>
<div>Port : 587</div>
<div>SSL/TLS : Activé</div>
</div>
</div>
<div>
<h5 className="font-medium text-slate-700 dark:text-slate-300 mb-1">Infomaniak</h5>
<div className="text-slate-600 dark:text-slate-400 space-y-1">
<div>Serveur : mail.infomaniak.com</div>
<div>Port : 587</div>
<div>SSL/TLS : Activé</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Modal d'envoi d'email de test */}
<SendTestEmailModal
isOpen={showTestEmailModal}
onClose={() => setShowTestEmailModal(false)}
smtpSettings={{
...settings,
password: passwordValue || settings.password
}}
/>
</>
);
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

101
src/lib/email.ts Normal file
View File

@@ -0,0 +1,101 @@
import { SmtpSettings } from '@/types';
export const emailService = {
/**
* Teste la connectivité réseau de base
*/
async testNetworkConnectivity(host: string): Promise<{ success: boolean; error?: string }> {
try {
// Test simple de connectivité avec un ping DNS
const response = await fetch(`https://dns.google/resolve?name=${host}`, {
method: 'GET',
headers: {
'Accept': 'application/dns-json',
},
});
if (!response.ok) {
return { success: false, error: 'Impossible de résoudre le nom d\'hôte' };
}
const data = await response.json();
if (data.Status !== 0 || !data.Answer || data.Answer.length === 0) {
return { success: false, error: 'Nom d\'hôte non trouvé' };
}
return { success: true };
} catch (error) {
return {
success: false,
error: 'Erreur de connectivité réseau'
};
}
},
/**
* Teste la connexion SMTP via API route
*/
async testConnection(smtpSettings: SmtpSettings): Promise<{ success: boolean; error?: string }> {
try {
// Test de connectivité réseau d'abord
const networkTest = await this.testNetworkConnectivity(smtpSettings.host);
if (!networkTest.success) {
return {
success: false,
error: `Problème de connectivité : ${networkTest.error}. Vérifiez votre connexion internet et le nom du serveur SMTP.`
};
}
const response = await fetch('/api/test-smtp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ smtpSettings }),
});
const result = await response.json();
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur de connexion SMTP'
};
}
},
/**
* Envoie un email de test via API route
*/
async sendTestEmail(
smtpSettings: SmtpSettings,
toEmail: string
): Promise<{ success: boolean; error?: string; messageId?: string }> {
try {
const response = await fetch('/api/test-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ smtpSettings, toEmail }),
});
const result = await response.json();
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur lors de l\'envoi de l\'email'
};
}
},
/**
* Valide une adresse email
*/
validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
};

73
src/lib/encryption.ts Normal file
View File

@@ -0,0 +1,73 @@
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
// Clé de chiffrement dérivée de la clé Supabase
const deriveKey = (): Buffer => {
const salt = process.env.SUPABASE_ANON_KEY || 'default-salt';
return pbkdf2Sync(salt, 'mes-budgets-participatifs', 100000, 32, 'sha256');
};
export const encryptionService = {
/**
* Chiffre une valeur avec AES-256-GCM
*/
encrypt(value: string): string {
if (!value) return '';
const key = deriveKey();
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encryptedData
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
},
/**
* Déchiffre une valeur chiffrée avec AES-256-GCM
*/
decrypt(encryptedValue: string): string {
if (!encryptedValue) return '';
try {
const parts = encryptedValue.split(':');
if (parts.length !== 3) {
throw new Error('Format de chiffrement invalide');
}
const [ivHex, authTagHex, encryptedData] = parts;
const key = deriveKey();
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
console.error('Erreur lors du déchiffrement:', error);
return '';
}
},
/**
* Vérifie si une valeur est chiffrée
*/
isEncrypted(value: string): boolean {
return value && value.includes(':') && value.split(':').length === 3;
},
/**
* Masque une valeur pour l'affichage
*/
mask(value: string, maskChar: string = '•'): string {
if (!value) return '';
return maskChar.repeat(Math.min(value.length, 8));
}
};

View File

@@ -1,5 +1,7 @@
import { supabase } from './supabase'; import { supabase } from './supabase';
import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus } from '@/types'; import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus, Setting, SmtpSettings } from '@/types';
import { encryptionService } from './encryption';
import { emailService } from './email';
// Services pour les campagnes // Services pour les campagnes
export const campaignService = { export const campaignService = {
@@ -279,3 +281,175 @@ export const voteService = {
}); });
} }
}; };
// Services pour les paramètres
export const settingsService = {
async getAll(): Promise<Setting[]> {
const { data, error } = await supabase
.from('settings')
.select('*')
.order('category', { ascending: true })
.order('key', { ascending: true });
if (error) throw error;
return data || [];
},
async getByCategory(category: string): Promise<Setting[]> {
const { data, error } = await supabase
.from('settings')
.select('*')
.eq('category', category)
.order('key', { ascending: true });
if (error) throw error;
return data || [];
},
async getByKey(key: string): Promise<Setting | null> {
const { data, error } = await supabase
.from('settings')
.select('*')
.eq('key', key)
.single();
if (error) {
if (error.code === 'PGRST116') return null; // No rows returned
throw error;
}
return data;
},
async getValue(key: string, defaultValue: string = ''): Promise<string> {
const setting = await this.getByKey(key);
return setting?.value || defaultValue;
},
async getBooleanValue(key: string, defaultValue: boolean = false): Promise<boolean> {
const value = await this.getValue(key, defaultValue.toString());
return value === 'true';
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async create(setting: any): Promise<Setting> {
const { data, error } = await supabase
.from('settings')
.insert(setting)
.select()
.single();
if (error) throw error;
return data;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async update(key: string, updates: any): Promise<Setting> {
const { data, error } = await supabase
.from('settings')
.update(updates)
.eq('key', key)
.select()
.single();
if (error) throw error;
return data;
},
async setValue(key: string, value: string): Promise<Setting> {
const existing = await this.getByKey(key);
if (existing) {
return this.update(key, { value });
} else {
return this.create({ key, value, category: 'general' });
}
},
async setBooleanValue(key: string, value: boolean): Promise<Setting> {
return this.setValue(key, value.toString());
},
async delete(key: string): Promise<void> {
const { error } = await supabase
.from('settings')
.delete()
.eq('key', key);
if (error) throw error;
},
// Méthodes spécifiques pour les paramètres SMTP
async getSmtpSettings(): Promise<SmtpSettings> {
const smtpKeys = [
'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password',
'smtp_secure', 'smtp_from_email', 'smtp_from_name'
];
const settings = await Promise.all(
smtpKeys.map(key => this.getByKey(key))
);
return {
host: settings[0]?.value || '',
port: parseInt(settings[1]?.value || '587'),
username: settings[2]?.value || '',
password: encryptionService.isEncrypted(settings[3]?.value || '')
? encryptionService.decrypt(settings[3]?.value || '')
: settings[3]?.value || '',
secure: settings[4]?.value === 'true',
from_email: settings[5]?.value || '',
from_name: settings[6]?.value || ''
};
},
async setSmtpSettings(smtpSettings: SmtpSettings): Promise<void> {
const settingsToUpdate = [
{ key: 'smtp_host', value: smtpSettings.host, category: 'email', description: 'Serveur SMTP' },
{ key: 'smtp_port', value: smtpSettings.port.toString(), category: 'email', description: 'Port SMTP' },
{ key: 'smtp_username', value: smtpSettings.username, category: 'email', description: 'Nom d\'utilisateur SMTP' },
{ key: 'smtp_password', value: encryptionService.encrypt(smtpSettings.password), category: 'email', description: 'Mot de passe SMTP (chiffré)' },
{ key: 'smtp_secure', value: smtpSettings.secure.toString(), category: 'email', description: 'Connexion sécurisée SSL/TLS' },
{ key: 'smtp_from_email', value: smtpSettings.from_email, category: 'email', description: 'Adresse email d\'expédition' },
{ key: 'smtp_from_name', value: smtpSettings.from_name, category: 'email', description: 'Nom d\'expédition' }
];
await Promise.all(
settingsToUpdate.map(setting => this.setValue(setting.key, setting.value))
);
},
async testSmtpConnection(smtpSettings: SmtpSettings): Promise<{ success: boolean; error?: string }> {
try {
// Validation basique des paramètres
if (!smtpSettings.host || !smtpSettings.port || !smtpSettings.username || !smtpSettings.password) {
return { success: false, error: 'Paramètres SMTP incomplets' };
}
if (smtpSettings.port < 1 || smtpSettings.port > 65535) {
return { success: false, error: 'Port SMTP invalide' };
}
if (!smtpSettings.from_email.includes('@')) {
return { success: false, error: 'Adresse email d\'expédition invalide' };
}
// Test de connexion via API route
return await emailService.testConnection(smtpSettings);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Erreur inconnue' };
}
},
async sendTestEmail(smtpSettings: SmtpSettings, toEmail: string): Promise<{ success: boolean; error?: string; messageId?: string }> {
try {
// Validation de l'email de destination
if (!emailService.validateEmail(toEmail)) {
return { success: false, error: 'Adresse email de destination invalide' };
}
// Envoi de l'email de test via API route
return await emailService.sendTestEmail(smtpSettings, toEmail);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Erreur lors de l\'envoi de l\'email de test' };
}
}
};

View File

@@ -57,6 +57,26 @@ export interface ParticipantWithVoteStatus extends Participant {
total_voted_amount?: number; total_voted_amount?: number;
} }
export interface Setting {
id: string;
key: string;
value: string;
category: string;
description?: string;
created_at: string;
updated_at: string;
}
export interface SmtpSettings {
host: string;
port: number;
username: string;
password: string;
secure: boolean;
from_email: string;
from_name: string;
}
export interface Database { export interface Database {
public: { public: {
Tables: { Tables: {
@@ -75,6 +95,11 @@ export interface Database {
Insert: Omit<Participant, 'id' | 'created_at'>; Insert: Omit<Participant, 'id' | 'created_at'>;
Update: Partial<Omit<Participant, 'id' | 'created_at'>>; Update: Partial<Omit<Participant, 'id' | 'created_at'>>;
}; };
settings: {
Row: Setting;
Insert: Omit<Setting, 'id' | 'created_at' | 'updated_at'>;
Update: Partial<Omit<Setting, 'id' | 'created_at' | 'updated_at'>>;
};
}; };
}; };
} }

View File

@@ -46,6 +46,17 @@ CREATE TABLE votes (
UNIQUE(participant_id, proposition_id) -- Un seul vote par participant par proposition UNIQUE(participant_id, proposition_id) -- Un seul vote par participant par proposition
); );
-- Table des paramètres de l'application
CREATE TABLE settings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index pour améliorer les performances -- Index pour améliorer les performances
CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id); CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id);
CREATE INDEX idx_participants_campaign_id ON participants(campaign_id); CREATE INDEX idx_participants_campaign_id ON participants(campaign_id);
@@ -73,6 +84,9 @@ CREATE TRIGGER update_campaigns_updated_at
CREATE TRIGGER update_votes_updated_at BEFORE UPDATE ON votes CREATE TRIGGER update_votes_updated_at BEFORE UPDATE ON votes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Politique RLS (Row Level Security) - Activer pour toutes les tables -- Politique RLS (Row Level Security) - Activer pour toutes les tables
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE propositions ENABLE ROW LEVEL SECURITY; ALTER TABLE propositions ENABLE ROW LEVEL SECURITY;
@@ -98,8 +112,17 @@ CREATE POLICY "Allow public insert access to votes" ON votes FOR INSERT WITH CHE
CREATE POLICY "Allow public update access to votes" ON votes FOR UPDATE USING (true); CREATE POLICY "Allow public update access to votes" ON votes FOR UPDATE USING (true);
CREATE POLICY "Allow public delete access to votes" ON votes FOR DELETE USING (true); CREATE POLICY "Allow public delete access to votes" ON votes FOR DELETE USING (true);
CREATE POLICY "Allow public read access to settings" ON settings FOR SELECT USING (true);
CREATE POLICY "Allow public insert access to settings" ON settings FOR INSERT WITH CHECK (true);
CREATE POLICY "Allow public update access to settings" ON settings FOR UPDATE USING (true);
CREATE POLICY "Allow public delete access to settings" ON settings FOR DELETE USING (true);
-- Données d'exemple (optionnel) -- Données d'exemple (optionnel)
INSERT INTO campaigns (title, description, status, budget_per_user, spending_tiers) VALUES INSERT INTO campaigns (title, description, status, budget_per_user, spending_tiers) VALUES
('Amélioration du quartier', 'Propositions pour améliorer notre quartier avec un budget participatif', 'deposit', 100, '10,25,50,100'), ('Amélioration du quartier', 'Propositions pour améliorer notre quartier avec un budget participatif', 'deposit', 100, '10,25,50,100'),
('Équipements sportifs', 'Sélection d équipements sportifs pour la commune', 'voting', 50, '5,10,25,50'), ('Équipements sportifs', 'Sélection d équipements sportifs pour la commune', 'voting', 50, '5,10,25,50'),
('Culture et loisirs', 'Projets culturels et de loisirs pour tous', 'closed', 75, '15,30,45,75'); ('Culture et loisirs', 'Projets culturels et de loisirs pour tous', 'closed', 75, '15,30,45,75');
-- Paramètres par défaut
INSERT INTO settings (key, value, category, description) VALUES
('randomize_propositions', 'false', 'display', 'Afficher les propositions dans un ordre aléatoire lors du vote');