add supabase authentication

This commit is contained in:
Yannick Le Duc
2025-08-25 16:02:57 +02:00
parent 6ad24b36dc
commit ec681cfd13
5 changed files with 347 additions and 106 deletions

View File

@@ -13,11 +13,12 @@ import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Input } from '@/components/ui/input';
import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard';
import { Users, User, Calendar, Mail, Vote, Copy, Check } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default function CampaignParticipantsPage() {
function CampaignParticipantsPageContent() {
const params = useParams();
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
@@ -369,3 +370,11 @@ export default function CampaignParticipantsPage() {
</div>
);
}
export default function CampaignParticipantsPage() {
return (
<AuthGuard>
<CampaignParticipantsPageContent />
</AuthGuard>
);
}

View File

@@ -12,11 +12,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard';
import { FileText, User, Calendar, Mail } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default function CampaignPropositionsPage() {
function CampaignPropositionsPageContent() {
const params = useParams();
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(null);
@@ -295,3 +296,11 @@ export default function CampaignPropositionsPage() {
</div>
);
}
export default function CampaignPropositionsPage() {
return (
<AuthGuard>
<CampaignPropositionsPageContent />
</AuthGuard>
);
}

View File

@@ -12,17 +12,19 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import Navigation from '@/components/Navigation';
import AuthGuard from '@/components/AuthGuard';
import { FolderOpen, Users, FileText, CheckCircle, Clock, Plus } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default function AdminPage() {
function AdminPageContent() {
const [campaigns, setCampaigns] = useState<CampaignWithStats[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [copiedCampaignId, setCopiedCampaignId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadCampaigns();
@@ -65,23 +67,39 @@ export default function AdminPage() {
};
const getStatusBadge = (status: string) => {
const statusConfig = {
deposit: { label: 'Dépôt de propositions', variant: 'secondary' as const },
voting: { label: 'En cours de vote', variant: 'default' as const },
closed: { label: 'Terminée', variant: 'destructive' as const }
};
const config = statusConfig[status as keyof typeof statusConfig];
return <Badge variant={config.variant}>{config.label}</Badge>;
switch (status) {
case 'deposit':
return <Badge variant="secondary">Dépôt de propositions</Badge>;
case 'voting':
return <Badge variant="default">En cours de vote</Badge>;
case 'closed':
return <Badge variant="outline">Terminée</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const getSpendingTiersDisplay = (tiers: string) => {
return tiers.split(',').map(tier => tier.trim()).join(', ') + '€';
return tiers.split(',').map(tier => `${tier.trim()}`).join(', ');
};
const filteredCampaigns = campaigns.filter(campaign =>
campaign.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
campaign.description.toLowerCase().includes(searchTerm.toLowerCase())
);
const stats = {
total: campaigns.length,
deposit: campaigns.filter(c => c.status === 'deposit').length,
voting: campaigns.filter(c => c.status === 'voting').length,
closed: campaigns.filter(c => c.status === 'closed').length,
};
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>
@@ -102,15 +120,12 @@ export default function AdminPage() {
<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">
Administration
</h1>
<p className="text-slate-600 dark:text-slate-300 mt-2">
Gérez vos campagnes de budget participatif
</p>
<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>
</div>
<Button onClick={() => setShowCreateModal(true)} size="lg">
Nouvelle campagne
<Plus className="w-4 h-4 mr-2" />
Nouvelle campagne
</Button>
</div>
</div>
@@ -122,11 +137,11 @@ export default function AdminPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">Total Campagnes</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{campaigns.length}</p>
</div>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<span className="text-blue-600 dark:text-blue-300">📊</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.total}</p>
</div>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<FolderOpen className="w-4 h-4 text-blue-600 dark:text-blue-300" />
</div>
</div>
</CardContent>
</Card>
@@ -136,12 +151,10 @@ export default function AdminPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">En cours</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{campaigns.filter(c => c.status === 'voting').length}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.voting}</p>
</div>
<div className="w-8 h-8 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<span className="text-green-600 dark:text-green-300">🗳</span>
<Clock className="w-4 h-4 text-green-600 dark:text-green-300" />
</div>
</div>
</CardContent>
@@ -152,12 +165,10 @@ export default function AdminPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">Dépôt</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{campaigns.filter(c => c.status === 'deposit').length}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.deposit}</p>
</div>
<div className="w-8 h-8 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<span className="text-yellow-600 dark:text-yellow-300">📝</span>
<FileText className="w-4 h-4 text-yellow-600 dark:text-yellow-300" />
</div>
</div>
</CardContent>
@@ -168,39 +179,54 @@ export default function AdminPage() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">Terminées</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{campaigns.filter(c => c.status === 'closed').length}
</p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.closed}</p>
</div>
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<span className="text-purple-600 dark:text-purple-300"></span>
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-300" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="mb-6">
<Input
type="text"
placeholder="Rechercher une campagne..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-md"
/>
</div>
{/* Campaigns List */}
{campaigns.length === 0 ? (
{filteredCampaigns.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center">
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">📋</span>
</div>
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4">
<FolderOpen className="w-8 h-8 text-slate-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
Aucune campagne créée
{searchTerm ? 'Aucune campagne trouvée' : 'Aucune campagne'}
</h3>
<p className="text-slate-600 dark:text-slate-300 mb-6">
Commencez par créer votre première campagne de budget participatif
{searchTerm
? 'Aucune campagne ne correspond à votre recherche.'
: 'Commencez par créer votre première campagne de budget participatif.'
}
</p>
<Button onClick={() => setShowCreateModal(true)}>
Créer une campagne
</Button>
{!searchTerm && (
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Créer une campagne
</Button>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-6">
{campaigns.map((campaign) => (
{filteredCampaigns.map((campaign) => (
<Card key={campaign.id} className="hover:shadow-lg transition-shadow duration-200">
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
@@ -209,9 +235,7 @@ export default function AdminPage() {
<CardTitle className="text-xl">{campaign.title}</CardTitle>
{getStatusBadge(campaign.status)}
</div>
<CardDescription className="text-base">
{campaign.description}
</CardDescription>
<CardDescription className="text-base">{campaign.description}</CardDescription>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Button
@@ -240,10 +264,6 @@ export default function AdminPage() {
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-sm text-slate-600 dark:text-slate-300">Budget par utilisateur</p>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">{campaign.budget_per_user}</p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-sm text-slate-600 dark:text-slate-300">Propositions</p>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">{campaign.stats.propositions}</p>
@@ -253,56 +273,54 @@ export default function AdminPage() {
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">{campaign.stats.participants}</p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-sm text-slate-600 dark:text-slate-300">Paliers de dépense</p>
<p className="text-sm text-slate-600 dark:text-slate-300">Budget/participant</p>
<p className="text-lg font-semibold text-slate-900 dark:text-slate-100">{campaign.budget_per_user}</p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-sm text-slate-600 dark:text-slate-300">Paliers</p>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{getSpendingTiersDisplay(campaign.spending_tiers)}</p>
</div>
</div>
{/* Public URL for deposit campaigns */}
{campaign.status === 'deposit' && (
<Card className="bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
Lien public pour déposer des propositions
</h4>
<div className="flex items-center space-x-2">
<Input
type="text"
readOnly
value={`${window.location.origin}/campaigns/${campaign.id}/propose`}
className="flex-1 text-xs bg-white dark:bg-slate-800 border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/campaigns/${campaign.id}/propose`);
setCopiedCampaignId(campaign.id);
setTimeout(() => setCopiedCampaignId(null), 2000);
}}
className="text-xs"
>
{copiedCampaignId === campaign.id ? 'Copié !' : '📋 Copier'}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="mb-4 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">
Lien public pour le dépôt de propositions :
</h4>
<div className="flex items-center space-x-2">
<Input
type="text"
readOnly
value={`${window.location.origin}/campaigns/${campaign.id}/propose`}
className="flex-1 text-sm bg-white dark:bg-slate-800 border-blue-300 dark:border-blue-600 text-blue-700 dark:text-blue-300 font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/campaigns/${campaign.id}/propose`);
}}
className="text-xs"
>
Copier
</Button>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<Button asChild variant="outline" className="flex-1">
<Link href={`/admin/campaigns/${campaign.id}/propositions`}>
📝 Propositions ({campaign.stats.propositions})
<FileText className="w-4 h-4 mr-2" />
Propositions ({campaign.stats.propositions})
</Link>
</Button>
<Button asChild variant="outline" className="flex-1">
<Link href={`/admin/campaigns/${campaign.id}/participants`}>
👥 Votants ({campaign.stats.participants})
<Users className="w-4 h-4 mr-2" />
Votants ({campaign.stats.participants})
</Link>
</Button>
</div>
@@ -313,30 +331,22 @@ export default function AdminPage() {
)}
{/* Modals */}
<CreateCampaignModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSuccess={handleCampaignCreated}
/>
<CreateCampaignModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} onSuccess={handleCampaignCreated} />
{selectedCampaign && (
<EditCampaignModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
onSuccess={handleCampaignEdited}
campaign={selectedCampaign}
/>
<EditCampaignModal isOpen={showEditModal} onClose={() => setShowEditModal(false)} onSuccess={handleCampaignEdited} campaign={selectedCampaign} />
)}
{selectedCampaign && (
<DeleteCampaignModal
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onSuccess={handleCampaignDeleted}
campaign={selectedCampaign}
/>
<DeleteCampaignModal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} onSuccess={handleCampaignDeleted} campaign={selectedCampaign} />
)}
</div>
</div>
);
}
export default function AdminPage() {
return (
<AuthGuard>
<AdminPageContent />
</AuthGuard>
);
}

View File

@@ -22,7 +22,7 @@ export default function HomePage() {
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button asChild size="lg" className="text-lg px-8 py-6">
<Link href="/admin">
🛠️ Espace Administration
🔐 Espace Administration
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">

View File

@@ -0,0 +1,213 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { supabase } from '@/lib/supabase';
import { User } from '@supabase/supabase-js';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertCircle, Mail, Lock, Loader2 } from 'lucide-react';
interface AuthGuardProps {
children: React.ReactNode;
}
export default function AuthGuard({ children }: AuthGuardProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [authMode, setAuthMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [authLoading, setAuthLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const router = useRouter();
useEffect(() => {
// Vérifier l'état de l'authentification au chargement
const checkUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
setLoading(false);
};
checkUser();
// Écouter les changements d'authentification
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setUser(session?.user ?? null);
setLoading(false);
}
);
return () => subscription.unsubscribe();
}, []);
const handleAuth = async (e: React.FormEvent) => {
e.preventDefault();
setAuthLoading(true);
setError('');
setMessage('');
try {
if (authMode === 'signin') {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
} else {
const { error } = await supabase.auth.signUp({
email,
password,
});
if (error) throw error;
setMessage('Vérifiez votre email pour confirmer votre inscription.');
}
} catch (error: any) {
setError(error.message);
} finally {
setAuthLoading(false);
}
};
const handleSignOut = async () => {
await supabase.auth.signOut();
router.push('/');
};
if (loading) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-slate-600 dark:text-slate-300" />
<p className="text-slate-600 dark:text-slate-300">Chargement...</p>
</div>
</div>
);
}
if (!user) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Administration</CardTitle>
<CardDescription>
{authMode === 'signin'
? 'Connectez-vous pour accéder à l\'administration'
: 'Créez un compte pour accéder à l\'administration'
}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAuth} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400" />
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
</div>
)}
{message && (
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<p className="text-sm text-green-600 dark:text-green-400">{message}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
Email
</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@example.com"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<Lock className="w-4 h-4" />
Mot de passe
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
</div>
<Button type="submit" className="w-full" disabled={authLoading}>
{authLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{authMode === 'signin' ? 'Connexion...' : 'Inscription...'}
</>
) : (
authMode === 'signin' ? 'Se connecter' : 'S\'inscrire'
)}
</Button>
</form>
<div className="mt-4 text-center">
<Button
variant="link"
onClick={() => setAuthMode(authMode === 'signin' ? 'signup' : 'signin')}
className="text-sm"
>
{authMode === 'signin'
? 'Pas de compte ? S\'inscrire'
: 'Déjà un compte ? Se connecter'
}
</Button>
</div>
<div className="mt-4 text-center">
<Button variant="ghost" asChild className="text-sm">
<a href="/">Retour à l'accueil</a>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div>
{/* Header avec bouton de déconnexion */}
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm text-slate-600 dark:text-slate-300">
Connecté en tant que :
</span>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{user.email}
</span>
</div>
<Button variant="outline" size="sm" onClick={handleSignOut}>
Se déconnecter
</Button>
</div>
</div>
</div>
{/* Contenu protégé */}
{children}
</div>
);
}