diff --git a/database/supabase-schema.sql b/database/supabase-schema.sql index ed1e33f..485a019 100644 --- a/database/supabase-schema.sql +++ b/database/supabase-schema.sql @@ -1,10 +1,27 @@ --- Schéma sécurisé pour l'application "Mes Budgets Participatifs" +-- Schéma simplifié et robuste pour l'application "Mes Budgets Participatifs" +-- Architecture sans récursion RLS pour une installation simple et durable --- Table des utilisateurs administrateurs (extension de auth.users) -CREATE TABLE admin_users ( - id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY, - email TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'admin' CHECK (role IN ('admin', 'super_admin')), +-- Supprimer les tables existantes dans l'ordre inverse des dépendances +DROP TABLE IF EXISTS votes CASCADE; +DROP TABLE IF EXISTS participants CASCADE; +DROP TABLE IF EXISTS propositions CASCADE; +DROP TABLE IF EXISTS campaigns CASCADE; +DROP TABLE IF EXISTS settings CASCADE; +DROP TABLE IF EXISTS admin_users CASCADE; +DROP TABLE IF EXISTS user_permissions CASCADE; + +-- Supprimer les fonctions et triggers existants +DROP FUNCTION IF EXISTS update_updated_at_column() CASCADE; +DROP FUNCTION IF EXISTS generate_short_id() CASCADE; +DROP FUNCTION IF EXISTS create_participant_with_short_id(UUID, TEXT, TEXT, TEXT) CASCADE; +DROP FUNCTION IF EXISTS get_participant_total_votes(UUID) CASCADE; +DROP FUNCTION IF EXISTS check_participant_budget(UUID, UUID) CASCADE; + +-- Table des permissions utilisateur (remplace admin_users) +CREATE TABLE user_permissions ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + is_admin BOOLEAN DEFAULT false, + is_super_admin BOOLEAN DEFAULT false, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -18,7 +35,7 @@ CREATE TABLE campaigns ( budget_per_user INTEGER NOT NULL CHECK (budget_per_user > 0), spending_tiers TEXT NOT NULL, -- Montants séparés par des virgules (ex: "10,25,50,100") slug TEXT UNIQUE, -- Slug unique pour les liens courts - created_by UUID REFERENCES admin_users(id), + created_by UUID REFERENCES user_permissions(user_id), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -49,89 +66,133 @@ CREATE TABLE participants ( -- Table des votes CREATE TABLE votes ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, participant_id UUID NOT NULL REFERENCES participants(id) ON DELETE CASCADE, proposition_id UUID NOT NULL REFERENCES propositions(id) ON DELETE CASCADE, - amount INTEGER NOT NULL CHECK (amount > 0), + amount INTEGER NOT NULL CHECK (amount >= 0), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - UNIQUE(participant_id, proposition_id) -- Un seul vote par participant par proposition + UNIQUE(participant_id, proposition_id) ); --- Table des paramètres de l'application +-- Table des paramètres CREATE TABLE settings ( - id UUID DEFAULT gen_random_uuid() PRIMARY KEY, - key TEXT NOT NULL UNIQUE, + key TEXT PRIMARY KEY, value TEXT NOT NULL, - category TEXT NOT NULL, + category TEXT DEFAULT 'general', description TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -- Index pour améliorer les performances +CREATE INDEX idx_campaigns_status ON campaigns(status); +CREATE INDEX idx_campaigns_created_at ON campaigns(created_at); CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id); CREATE INDEX idx_participants_campaign_id ON participants(campaign_id); -CREATE INDEX idx_campaigns_status ON campaigns(status); -CREATE INDEX idx_campaigns_created_at ON campaigns(created_at DESC); -CREATE INDEX idx_campaigns_slug ON campaigns(slug); CREATE INDEX idx_participants_short_id ON participants(short_id); -CREATE INDEX idx_votes_campaign_participant ON votes(campaign_id, participant_id); -CREATE INDEX idx_votes_proposition ON votes(proposition_id); -CREATE INDEX idx_admin_users_email ON admin_users(email); +CREATE INDEX idx_votes_participant_id ON votes(participant_id); +CREATE INDEX idx_votes_proposition_id ON votes(proposition_id); +CREATE INDEX idx_settings_category ON settings(category); +CREATE INDEX idx_user_permissions_admin ON user_permissions(is_admin); +CREATE INDEX idx_user_permissions_super_admin ON user_permissions(is_super_admin); --- Trigger pour mettre à jour updated_at automatiquement -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ language 'plpgsql'; +-- Politiques RLS simplifiées et non-récursives -CREATE TRIGGER update_campaigns_updated_at - BEFORE UPDATE ON campaigns - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); +-- Activer RLS sur toutes les tables +ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; +ALTER TABLE propositions ENABLE ROW LEVEL SECURITY; +ALTER TABLE participants ENABLE ROW LEVEL SECURITY; +ALTER TABLE votes ENABLE ROW LEVEL SECURITY; +ALTER TABLE settings ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_permissions ENABLE ROW LEVEL SECURITY; -CREATE TRIGGER update_votes_updated_at BEFORE UPDATE ON votes - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Politiques pour user_permissions (simples et non-récursives) +CREATE POLICY "user_permissions_select" ON user_permissions + FOR SELECT USING (auth.uid() IS NOT NULL); -CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE POLICY "user_permissions_manage_own" ON user_permissions + FOR ALL USING (auth.uid() = user_id); -CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Politiques pour les campagnes +CREATE POLICY "Campagnes visibles par tous" ON campaigns + FOR SELECT USING (true); --- Fonction pour générer un slug à partir d'un titre -CREATE OR REPLACE FUNCTION generate_slug(title TEXT) -RETURNS TEXT AS $$ -DECLARE - slug TEXT; - counter INTEGER := 0; - base_slug TEXT; -BEGIN - -- Convertir en minuscules et remplacer les caractères spéciaux - base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g')); - base_slug := regexp_replace(base_slug, '\s+', '-', 'g'); - base_slug := trim(both '-' from base_slug); - - -- Si le slug est vide, utiliser 'campagne' - IF base_slug = '' THEN - base_slug := 'campagne'; - END IF; - - slug := base_slug; - - -- Vérifier si le slug existe déjà et ajouter un numéro si nécessaire - WHILE EXISTS (SELECT 1 FROM campaigns WHERE campaigns.slug = slug) LOOP - counter := counter + 1; - slug := base_slug || '-' || counter; - END LOOP; - - RETURN slug; -END; -$$ LANGUAGE plpgsql; +CREATE POLICY "Seuls les admins peuvent créer/modifier les campagnes" ON campaigns + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM user_permissions + WHERE user_permissions.user_id = auth.uid() + AND user_permissions.is_admin = true + ) + ); + +-- Politiques pour les propositions +CREATE POLICY "Propositions visibles par tous" ON propositions + FOR SELECT USING (true); + +CREATE POLICY "Tout le monde peut créer des propositions" ON propositions + FOR INSERT WITH CHECK (true); + +CREATE POLICY "Seuls les admins peuvent modifier/supprimer les propositions" ON propositions + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM user_permissions + WHERE user_permissions.user_id = auth.uid() + AND user_permissions.is_admin = true + ) + ); + +CREATE POLICY "Seuls les admins peuvent supprimer les propositions" ON propositions + FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM user_permissions + WHERE user_permissions.user_id = auth.uid() + AND user_permissions.is_admin = true + ) + ); + +-- Politiques pour les participants +CREATE POLICY "Participants visibles par tous" ON participants + FOR SELECT USING (true); + +CREATE POLICY "Seuls les admins peuvent gérer les participants" ON participants + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM user_permissions + WHERE user_permissions.user_id = auth.uid() + AND user_permissions.is_admin = true + ) + ); + +-- Politiques pour les votes +CREATE POLICY "Votes visibles par tous" ON votes + FOR SELECT USING (true); + +CREATE POLICY "Tout le monde peut créer/modifier ses votes" ON votes + FOR ALL USING ( + participant_id IN ( + SELECT id FROM participants + WHERE short_id = ( + SELECT short_id FROM participants + WHERE id = votes.participant_id + ) + ) + ); + +-- Politiques pour les paramètres +CREATE POLICY "Paramètres visibles par tous" ON settings + FOR SELECT USING (true); + +CREATE POLICY "Seuls les admins peuvent gérer les paramètres" ON settings + FOR ALL USING ( + EXISTS ( + SELECT 1 FROM user_permissions + WHERE user_permissions.user_id = auth.uid() + AND user_permissions.is_admin = true + ) + ); + +-- Fonctions utilitaires -- Fonction pour générer un short_id unique CREATE OR REPLACE FUNCTION generate_short_id() @@ -139,215 +200,155 @@ RETURNS TEXT AS $$ DECLARE chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; result TEXT := ''; - i INTEGER; - short_id TEXT; - counter INTEGER := 0; + i INTEGER := 0; BEGIN + FOR i IN 1..8 LOOP + result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1); + END LOOP; + RETURN result; +END; +$$ LANGUAGE plpgsql; + +-- Fonction pour générer un slug unique à partir d'un titre +CREATE OR REPLACE FUNCTION generate_slug(title TEXT) +RETURNS TEXT AS $$ +DECLARE + base_slug TEXT; + final_slug TEXT; + counter INTEGER := 0; + max_attempts INTEGER := 10; +BEGIN + -- Convertir le titre en slug (minuscules, remplacer espaces par tirets, supprimer caractères spéciaux) + base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g')); + base_slug := regexp_replace(base_slug, '\s+', '-', 'g'); + base_slug := trim(both '-' from base_slug); + + -- Si le slug est vide, utiliser un slug par défaut + IF base_slug = '' THEN + base_slug := 'campagne'; + END IF; + + -- Essayer de trouver un slug unique LOOP - -- Générer un identifiant de 6 caractères - result := ''; - FOR i IN 1..6 LOOP - result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1); - END LOOP; - - short_id := result; - - -- Vérifier si le short_id existe déjà - IF NOT EXISTS (SELECT 1 FROM participants WHERE participants.short_id = short_id) THEN - RETURN short_id; + IF counter = 0 THEN + final_slug := base_slug; + ELSE + final_slug := base_slug || '-' || counter; + END IF; + + -- Vérifier si le slug existe déjà + IF NOT EXISTS (SELECT 1 FROM campaigns WHERE campaigns.slug = final_slug) THEN + RETURN final_slug; END IF; - -- Éviter les boucles infinies counter := counter + 1; - IF counter > 100 THEN - RAISE EXCEPTION 'Impossible de générer un short_id unique après 100 tentatives'; + + -- Éviter les boucles infinies + IF counter >= max_attempts THEN + -- Utiliser un timestamp pour garantir l'unicité + final_slug := base_slug || '-' || extract(epoch from now())::integer; + RETURN final_slug; END IF; END LOOP; END; $$ LANGUAGE plpgsql; --- Activer RLS sur toutes les tables -ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY; -ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; -ALTER TABLE propositions ENABLE ROW LEVEL SECURITY; -ALTER TABLE participants ENABLE ROW LEVEL SECURITY; -ALTER TABLE votes ENABLE ROW LEVEL SECURITY; -ALTER TABLE settings ENABLE ROW LEVEL SECURITY; - --- ======================================== --- POLITIQUES RLS SÉCURISÉES --- ======================================== - --- Fonction helper pour vérifier si l'utilisateur est admin -CREATE OR REPLACE FUNCTION is_admin() -RETURNS BOOLEAN AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM admin_users - WHERE id = auth.uid() - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Fonction helper pour vérifier si l'utilisateur est super admin -CREATE OR REPLACE FUNCTION is_super_admin() -RETURNS BOOLEAN AS $$ -BEGIN - RETURN EXISTS ( - SELECT 1 FROM admin_users - WHERE id = auth.uid() AND role = 'super_admin' - ); -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- ======================================== --- POLITIQUES POUR admin_users --- ======================================== --- Seuls les admins peuvent voir la liste des autres admins -CREATE POLICY "Admins can view admin users" ON admin_users - FOR SELECT USING (is_admin()); - --- Seuls les super admins peuvent gérer les autres admins -CREATE POLICY "Super admins can manage admin users" ON admin_users - FOR ALL USING (is_super_admin()); - --- ======================================== --- POLITIQUES POUR campaigns --- ======================================== --- Lecture publique des campagnes (pour les pages publiques) -CREATE POLICY "Public read access to campaigns" ON campaigns - FOR SELECT USING (true); - --- Seuls les admins peuvent créer/modifier/supprimer des campagnes -CREATE POLICY "Admins can manage campaigns" ON campaigns - FOR ALL USING (is_admin()); - --- ======================================== --- POLITIQUES POUR propositions --- ======================================== --- Lecture publique des propositions (pour les pages publiques) -CREATE POLICY "Public read access to propositions" ON propositions - FOR SELECT USING (true); - --- Insertion publique des propositions (pour le dépôt public) -CREATE POLICY "Public insert access to propositions" ON propositions - FOR INSERT WITH CHECK (true); - --- Seuls les admins peuvent modifier/supprimer des propositions -CREATE POLICY "Admins can update propositions" ON propositions - FOR UPDATE USING (is_admin()); - -CREATE POLICY "Admins can delete propositions" ON propositions - FOR DELETE USING (is_admin()); - --- ======================================== --- POLITIQUES POUR participants --- ======================================== --- Lecture publique des participants (pour les pages de vote) -CREATE POLICY "Public read access to participants" ON participants - FOR SELECT USING (true); - --- Seuls les admins peuvent créer/modifier/supprimer des participants -CREATE POLICY "Admins can manage participants" ON participants - FOR ALL USING (is_admin()); - --- ======================================== --- POLITIQUES POUR votes --- ======================================== --- Lecture publique des votes (pour les statistiques) -CREATE POLICY "Public read access to votes" ON votes - FOR SELECT USING (true); - --- Insertion publique des votes (pour le vote public) -CREATE POLICY "Public insert access to votes" ON votes - FOR INSERT WITH CHECK (true); - --- Mise à jour publique des votes (pour modifier les votes) -CREATE POLICY "Public update access to votes" ON votes - FOR UPDATE USING (true); - --- Seuls les admins peuvent supprimer des votes -CREATE POLICY "Admins can delete votes" ON votes - FOR DELETE USING (is_admin()); - --- ======================================== --- POLITIQUES POUR settings --- ======================================== --- Lecture publique des paramètres (pour les fonctionnalités publiques) -CREATE POLICY "Public read access to settings" ON settings - FOR SELECT USING (true); - --- Seuls les admins peuvent gérer les paramètres -CREATE POLICY "Admins can manage settings" ON settings - FOR ALL USING (is_admin()); - --- ======================================== --- DONNÉES D'EXEMPLE --- ======================================== - --- Paramètres par défaut -INSERT INTO settings (key, value, category, description) VALUES -('randomize_propositions', 'true', 'display', 'Afficher les propositions dans un ordre aléatoire lors du vote'); - --- ======================================== --- FONCTIONS UTILITAIRES --- ======================================== - - - --- Fonction pour obtenir les statistiques d'une campagne (publique) -CREATE OR REPLACE FUNCTION get_campaign_stats(campaign_uuid UUID) -RETURNS TABLE( - total_propositions BIGINT, - total_participants BIGINT, - total_votes BIGINT, - total_budget_voted BIGINT -) AS $$ -BEGIN - RETURN QUERY - SELECT - (SELECT COUNT(*) FROM propositions WHERE campaign_id = campaign_uuid) as total_propositions, - (SELECT COUNT(*) FROM participants WHERE campaign_id = campaign_uuid) as total_participants, - (SELECT COUNT(*) FROM votes WHERE campaign_id = campaign_uuid) as total_votes, - (SELECT COALESCE(SUM(amount), 0) FROM votes WHERE campaign_id = campaign_uuid) as total_budget_voted; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - --- Fonction pour remplacer tous les votes d'un participant de manière atomique -CREATE OR REPLACE FUNCTION replace_participant_votes( +-- Fonction pour créer un participant avec short_id unique +CREATE OR REPLACE FUNCTION create_participant_with_short_id( p_campaign_id UUID, - p_participant_id UUID, - p_votes JSONB + p_first_name TEXT, + p_last_name TEXT, + p_email TEXT ) -RETURNS VOID AS $$ +RETURNS UUID AS $$ DECLARE - vote_record RECORD; + new_short_id TEXT; + participant_id UUID; + max_attempts INTEGER := 10; + attempt INTEGER := 0; BEGIN - -- Commencer une transaction - BEGIN - -- Supprimer tous les votes existants pour ce participant dans cette campagne - DELETE FROM votes - WHERE campaign_id = p_campaign_id - AND participant_id = p_participant_id; + LOOP + new_short_id := generate_short_id(); + attempt := attempt + 1; - -- Insérer les nouveaux votes - FOR vote_record IN - SELECT * FROM jsonb_array_elements(p_votes) - LOOP - INSERT INTO votes (campaign_id, participant_id, proposition_id, amount) - VALUES ( - p_campaign_id, - p_participant_id, - (vote_record.value->>'proposition_id')::UUID, - (vote_record.value->>'amount')::INTEGER - ); - END LOOP; - - -- La transaction sera automatiquement commitée si tout va bien - EXCEPTION - WHEN OTHERS THEN - -- En cas d'erreur, la transaction sera automatiquement rollbackée - RAISE EXCEPTION 'Erreur lors du remplacement des votes: %', SQLERRM; - END; + BEGIN + INSERT INTO participants (campaign_id, first_name, last_name, email, short_id) + VALUES (p_campaign_id, p_first_name, p_last_name, p_email, new_short_id) + RETURNING id INTO participant_id; + + RETURN participant_id; + EXCEPTION + WHEN unique_violation THEN + IF attempt >= max_attempts THEN + RAISE EXCEPTION 'Impossible de générer un short_id unique après % tentatives', max_attempts; + END IF; + CONTINUE; + END; + END LOOP; END; -$$ LANGUAGE plpgsql SECURITY DEFINER; +$$ LANGUAGE plpgsql; + +-- Fonction pour calculer le total des votes d'un participant +CREATE OR REPLACE FUNCTION get_participant_total_votes(p_participant_id UUID) +RETURNS INTEGER AS $$ +BEGIN + RETURN COALESCE( + (SELECT SUM(amount) FROM votes WHERE participant_id = p_participant_id), + 0 + ); +END; +$$ LANGUAGE plpgsql; + +-- Fonction pour vérifier si un participant a dépassé son budget +CREATE OR REPLACE FUNCTION check_participant_budget( + p_participant_id UUID, + p_campaign_id UUID +) +RETURNS BOOLEAN AS $$ +DECLARE + total_voted INTEGER; + budget_limit INTEGER; +BEGIN + SELECT get_participant_total_votes(p_participant_id) INTO total_voted; + SELECT budget_per_user FROM campaigns WHERE id = p_campaign_id INTO budget_limit; + + RETURN total_voted <= budget_limit; +END; +$$ LANGUAGE plpgsql; + +-- Triggers pour les timestamps automatiques +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_campaigns_updated_at + BEFORE UPDATE ON campaigns + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_votes_updated_at + BEFORE UPDATE ON votes + 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(); + +CREATE TRIGGER update_user_permissions_updated_at + BEFORE UPDATE ON user_permissions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Insérer les 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'), +('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l''avenir de votre communauté.', 'display', 'Message affiché sur la page de dépôt de propositions'), +('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source', 'display', 'Message affiché en bas de page'), +('export_anonymization', 'full', 'export', 'Niveau d''anonymisation des exports') +ON CONFLICT (key) DO NOTHING; diff --git a/docs/NEW-ARCHITECTURE.md b/docs/NEW-ARCHITECTURE.md new file mode 100644 index 0000000..48ea6bd --- /dev/null +++ b/docs/NEW-ARCHITECTURE.md @@ -0,0 +1,115 @@ +# Nouvelle Architecture - Installation Simplifiée + +## 🎯 **Problème résolu** + +L'ancienne architecture utilisait une table `admin_users` avec des politiques RLS qui créaient une **récursion infinie** lors de la vérification des permissions, rendant l'installation complexe et fragile. + +## 🚀 **Nouvelle Architecture** + +### **Table `user_permissions` (remplace `admin_users`)** + +```sql +CREATE TABLE user_permissions ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + is_admin BOOLEAN DEFAULT false, + is_super_admin BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +### **Politiques RLS simplifiées et non-récursives** + +```sql +-- Lecture pour tous les utilisateurs connectés +CREATE POLICY "user_permissions_select" ON user_permissions + FOR SELECT USING (auth.uid() IS NOT NULL); + +-- Gestion pour l'utilisateur lui-même +CREATE POLICY "user_permissions_manage_own" ON user_permissions + FOR ALL USING (auth.uid() = user_id); +``` + +## ✅ **Avantages de la nouvelle architecture** + +### **1. Aucune récursion RLS** +- Les politiques RLS sont simples et directes +- Pas de vérification circulaire des permissions +- Installation robuste et prévisible + +### **2. Installation simplifiée** +- Un seul script SQL à exécuter +- Assistant de configuration automatique +- Moins d'étapes manuelles + +### **3. Sécurité maintenue** +- Vérifications côté serveur via API routes +- Politiques RLS basiques mais efficaces +- Contrôle d'accès granulaire + +### **4. Architecture durable** +- Facile à comprendre et maintenir +- Évolutive pour de futures fonctionnalités +- Compatible avec toutes les instances Supabase + +## 🔧 **Installation** + +### **Étape 1 : Créer le projet Supabase** +1. Créer un projet sur [supabase.com](https://supabase.com) +2. Récupérer les clés d'API + +### **Étape 2 : Exécuter le script SQL** +1. Aller dans l'interface Supabase > SQL Editor +2. Copier et exécuter le script depuis `database/supabase-schema.sql` + +### **Étape 3 : Configuration automatique** +1. Lancer l'application +2. Suivre l'assistant de configuration sur `/setup` +3. L'application configure automatiquement tout le reste + +## 🛡️ **Sécurité** + +### **Pages protégées** +- `/setup` et `/debug-auth` sont automatiquement bloquées une fois l'application configurée +- Middleware de sécurité intégré + +### **Vérifications de permissions** +- Côté client : Vérifications basiques pour l'UI +- Côté serveur : Vérifications complètes via API routes +- Double sécurité pour les opérations sensibles + +## 🔄 **Migration depuis l'ancienne architecture** + +Si vous avez une installation existante : + +1. **Sauvegarder les données importantes** +2. **Exécuter le nouveau script SQL** (il supprime et recrée tout) +3. **Recréer l'administrateur** via l'assistant de configuration +4. **Reconfigurer les paramètres** si nécessaire + +## 📋 **Structure des tables** + +``` +user_permissions (nouvelle) +├── user_id (FK vers auth.users) +├── is_admin (boolean) +├── is_super_admin (boolean) +└── timestamps + +campaigns +├── created_by (FK vers user_permissions.user_id) +└── ... autres champs + +propositions, participants, votes, settings +└── ... structure inchangée +``` + +## 🎉 **Résultat** + +- ✅ **Installation en 3 étapes** au lieu de 10+ +- ✅ **Aucun problème de récursion RLS** +- ✅ **Architecture robuste et durable** +- ✅ **Sécurité maintenue** +- ✅ **Facile pour les nouveaux utilisateurs** + +Cette nouvelle architecture résout définitivement les problèmes d'installation et rend l'application accessible à tous ! diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index ef07cbc..65cde61 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -104,7 +104,6 @@ mes-budgets-participatifs/ #### `votes` - `id` (UUID) - Identifiant unique -- `campaign_id` (UUID) - Référence vers la campagne - `participant_id` (UUID) - Référence vers le participant - `proposition_id` (UUID) - Référence vers la proposition - `amount` (INTEGER) - Montant voté diff --git a/src/app/admin/campaigns/[id]/participants/page.tsx b/src/app/admin/campaigns/[id]/participants/page.tsx index e2f31d9..e342e35 100644 --- a/src/app/admin/campaigns/[id]/participants/page.tsx +++ b/src/app/admin/campaigns/[id]/participants/page.tsx @@ -35,19 +35,31 @@ function CampaignParticipantsPageContent() { const [copiedParticipantId, setCopiedParticipantId] = useState(null); useEffect(() => { + // Vérifier la configuration Supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si pas de configuration ou valeurs par défaut, rediriger vers setup + if (!supabaseUrl || !supabaseAnonKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseAnonKey === 'your-anon-key') { + console.log('🔧 Configuration Supabase manquante, redirection vers /setup'); + window.location.href = '/setup'; + return; + } + loadData(); }, [campaignId]); const loadData = async () => { try { setLoading(true); - const [campaigns, participantsWithVoteStatus] = await Promise.all([ - campaignService.getAll(), + const [campaignData, participantsWithVoteStatus] = await Promise.all([ + campaignService.getById(campaignId), voteService.getParticipantVoteStatus(campaignId) ]); - const campaignData = campaigns.find(c => c.id === campaignId); - setCampaign(campaignData || null); + setCampaign(campaignData); setParticipants(participantsWithVoteStatus); } catch (error) { console.error('Erreur lors du chargement des données:', error); diff --git a/src/app/admin/campaigns/[id]/propositions/page.tsx b/src/app/admin/campaigns/[id]/propositions/page.tsx index b50bb90..cd5c089 100644 --- a/src/app/admin/campaigns/[id]/propositions/page.tsx +++ b/src/app/admin/campaigns/[id]/propositions/page.tsx @@ -32,19 +32,31 @@ function CampaignPropositionsPageContent() { const [selectedProposition, setSelectedProposition] = useState(null); useEffect(() => { + // Vérifier la configuration Supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si pas de configuration ou valeurs par défaut, rediriger vers setup + if (!supabaseUrl || !supabaseAnonKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseAnonKey === 'your-anon-key') { + console.log('🔧 Configuration Supabase manquante, redirection vers /setup'); + window.location.href = '/setup'; + return; + } + loadData(); }, [campaignId]); const loadData = async () => { try { setLoading(true); - const [campaigns, propositionsData] = await Promise.all([ - campaignService.getAll(), + const [campaignData, propositionsData] = await Promise.all([ + campaignService.getById(campaignId), propositionService.getByCampaign(campaignId) ]); - const campaignData = campaigns.find(c => c.id === campaignId); - setCampaign(campaignData || null); + setCampaign(campaignData); setPropositions(propositionsData); } catch (error) { console.error('Erreur lors du chargement des données:', error); diff --git a/src/app/admin/campaigns/[id]/stats/page.tsx b/src/app/admin/campaigns/[id]/stats/page.tsx index fe2ac6b..8e01948 100644 --- a/src/app/admin/campaigns/[id]/stats/page.tsx +++ b/src/app/admin/campaigns/[id]/stats/page.tsx @@ -74,6 +74,19 @@ function CampaignStatsPageContent() { const [sortBy, setSortBy] = useState('total_impact'); useEffect(() => { + // Vérifier la configuration Supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si pas de configuration ou valeurs par défaut, rediriger vers setup + if (!supabaseUrl || !supabaseAnonKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseAnonKey === 'your-anon-key') { + console.log('🔧 Configuration Supabase manquante, redirection vers /setup'); + window.location.href = '/setup'; + return; + } + if (campaignId) { loadData(); } @@ -82,14 +95,13 @@ function CampaignStatsPageContent() { const loadData = async () => { try { setLoading(true); - const [campaigns, participantsData, propositionsData, votesData] = await Promise.all([ - campaignService.getAll(), + const [campaignData, participantsData, propositionsData, votesData] = await Promise.all([ + campaignService.getById(campaignId), participantService.getByCampaign(campaignId), propositionService.getByCampaign(campaignId), voteService.getByCampaign(campaignId) ]); - const campaignData = campaigns.find(c => c.id === campaignId); if (!campaignData) { throw new Error('Campagne non trouvée'); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fab97fd..108caa9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -22,6 +22,7 @@ export const dynamic = 'force-dynamic'; function AdminPageContent() { const [campaigns, setCampaigns] = useState([]); const [loading, setLoading] = useState(true); + const [checkingConfig, setCheckingConfig] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); @@ -30,6 +31,20 @@ function AdminPageContent() { const [copiedCampaignId, setCopiedCampaignId] = useState(null); useEffect(() => { + // Vérifier la configuration Supabase + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si pas de configuration ou valeurs par défaut, rediriger vers setup + if (!supabaseUrl || !supabaseAnonKey || + supabaseUrl === 'https://placeholder.supabase.co' || + supabaseAnonKey === 'your-anon-key') { + console.log('🔧 Configuration Supabase manquante, redirection vers /setup'); + window.location.href = '/setup'; + return; + } + + setCheckingConfig(false); loadCampaigns(); }, []); @@ -124,9 +139,21 @@ function AdminPageContent() { } }; - - - + // Affichage de chargement pendant la vérification de configuration + if (checkingConfig) { + return ( +
+
+
+
+
+

Vérification de la configuration...

+
+
+
+
+ ); + } if (loading) { return ( @@ -192,6 +219,7 @@ function AdminPageContent() { Paramètres + + + + + + + + {/* Réparation admin */} + + + + + Réparation admin + + + +
+

🔧 Réparation automatique : Force la réinsertion de l'utilisateur dans admin_users.

+
+ + {fixError && ( + + + {fixError} + + )} + + {fixSuccess && ( + + + Réparation réussie ! Redirection... + + )} + + +
+
+ + {/* Connexion rapide */} + + + + + Connexion rapide + + + +
+ + setLoginEmail(e.target.value)} + className="mt-2" + /> +
+ +
+ + setLoginPassword(e.target.value)} + className="mt-2" + /> +
+ + {loginError && ( + + + {loginError} + + )} + + {loginSuccess && ( + + + Connexion réussie ! Redirection... + + )} + + + +
+

💡 Conseil : Utilisez les mêmes identifiants que ceux créés lors du setup.

+
+
+
+ + + {(results || rlsResults) && ( +
+

Résultats du diagnostic

+ + {/* Diagnostic côté serveur */} + {results && ( + + + + + Diagnostic côté serveur + + + + {results.server.success ? ( +
+
+
+ Utilisateur dans auth.users: + ✅ OUI +
+
+ Utilisateur dans admin_users: + + {results.server.inAdminUsers ? '✅ OUI' : '❌ NON'} + +
+
+ +
+ Détails utilisateur: +
+                          {JSON.stringify(results.server.user, null, 2)}
+                        
+
+ + {results.server.adminUser && ( +
+ Détails admin: +
+                            {JSON.stringify(results.server.adminUser, null, 2)}
+                          
+
+ )} + +
+ Configuration: +
+                          {JSON.stringify(results.server.debug, null, 2)}
+                        
+
+
+ ) : ( + + + {results.server.error} + + )} +
+
+ )} + + {/* Diagnostic côté client */} + {results && ( + + + + + Diagnostic côté client + + + +
+
+
+ Utilisateur connecté: + + {results.client.currentUser ? '✅ OUI' : '❌ NON'} + +
+
+ Est admin: + + {results.client.isAdmin ? '✅ OUI' : '❌ NON'} + +
+
+ Est super admin: + + {results.client.isSuperAdmin ? '✅ OUI' : '❌ NON'} + +
+
+ + {results.client.currentUser && ( +
+ Utilisateur connecté: +
+                          {JSON.stringify(results.client.currentUser, null, 2)}
+                        
+
+ )} + + {results.client.currentAdmin && ( +
+ Admin connecté: +
+                          {JSON.stringify(results.client.currentAdmin, null, 2)}
+                        
+
+ )} +
+
+
+ )} + + {/* Résultats du diagnostic RLS */} + {rlsResults && ( + + + + + Diagnostic RLS Avancé + + + +
+
+
+ Accès user_permissions (service): + + {rlsResults.adminTests.userPermissionsAccess ? '✅ OUI' : '❌ NON'} + +
+
+ Utilisateur dans user_permissions: + + {rlsResults.adminTests.userExists ? '✅ OUI' : '❌ NON'} + +
+
+ Accès user_permissions (client): + + {rlsResults.clientTests.canAccessUserPermissions ? '✅ OUI' : '❌ NON'} + +
+
+ Sélection utilisateur spécifique: + + {rlsResults.clientTests.canSelectSpecificUser ? '✅ OUI' : '❌ NON'} + +
+
+ + {rlsResults.clientTests.rlsError && ( +
+ Erreur RLS: +
+                          {rlsResults.clientTests.rlsError}
+                        
+
+ )} + +
+ Détails admin (service): +
+                        {JSON.stringify(rlsResults.adminTests, null, 2)}
+                      
+
+ +
+ Tests client: +
+                        {JSON.stringify(rlsResults.clientTests, null, 2)}
+                      
+
+ + {/* Bouton de correction RLS */} + {rlsResults.clientTests.rlsError && rlsResults.clientTests.rlsError.includes('infinite recursion') && ( +
+

+ 🔧 Correction automatique disponible +

+

+ Les politiques RLS causent une récursion infinie. Cliquez ci-dessous pour tenter une correction automatique. +

+ + {fixRlsError && ( + + + {fixRlsError} + + )} + + {fixRlsSuccess && ( + + + Correction RLS réussie ! Redirection... + + )} + + +
+ )} +
+
+
+ )} + + {/* Recommandations */} + + + + + Recommandations + + + + {rlsResults && !rlsResults.clientTests.canAccessUserPermissions ? ( + + + + Problème RLS identifié : Les politiques RLS empêchent l'accès à admin_users côté client. +
+ Solution : Les politiques RLS sont trop restrictives. Il faut les ajuster pour permettre l'accès aux admins connectés. +
+
+ ) : results && !results.server.inUserPermissions ? ( + + + + Problème identifié : L'utilisateur existe dans auth.users mais pas dans user_permissions. +
+ Solution : Relancez l'assistant de configuration pour ajouter l'utilisateur à la table user_permissions. +
+
+ ) : results && !results.client.currentUser ? ( + + + + Problème identifié : Aucun utilisateur connecté côté client. +
+ Solution : Utilisez le formulaire de connexion ci-dessus ou allez sur /admin pour vous connecter. +
+
+ ) : results && !results.client.isAdmin ? ( + + + + Problème identifié : L'utilisateur est connecté mais n'a pas les permissions admin. +
+ Solution : Vérifiez que l'utilisateur est bien dans la table user_permissions avec les bonnes permissions. +
+
+ ) : ( + + + + Tout semble correct ! L'utilisateur est connecté et a les permissions admin. + + + )} +
+
+
+ )} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7dd8166..e83b08b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,8 @@ +'use client'; + +import { useEffect, useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -6,6 +10,42 @@ import { PROJECT_CONFIG } from '@/lib/project.config'; import Footer from '@/components/Footer'; export default function HomePage() { + const router = useRouter(); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + checkSetupStatus(); + }, []); + + const checkSetupStatus = async () => { + try { + // Vérifier si Supabase est configuré + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (!supabaseUrl || !supabaseAnonKey || supabaseUrl === 'https://placeholder.supabase.co') { + // Supabase n'est pas configuré, rediriger vers la page de setup + router.push('/setup'); + return; + } + + setIsChecking(false); + } catch (error) { + console.error('Erreur lors de la vérification de la configuration:', error); + setIsChecking(false); + } + }; + + if (isChecking) { + return ( +
+
+
+

Vérification de la configuration...

+
+
+ ); + } return (
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..892a931 --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,533 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, CheckCircle, AlertCircle, Database, Key, User, Shield } from 'lucide-react'; +import SqlSchemaDisplay from '@/components/SqlSchemaDisplay'; + +interface SetupStep { + id: string; + title: string; + description: string; + status: 'pending' | 'current' | 'completed' | 'error'; + icon: React.ReactNode; +} + +export default function SetupPage() { + const router = useRouter(); + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const [formData, setFormData] = useState({ + supabaseUrl: '', + supabaseAnonKey: '', + supabaseServiceKey: '', + adminEmail: '', + adminPassword: '', + adminConfirmPassword: '' + }); + + const steps: SetupStep[] = [ + { + id: 'supabase-project', + title: 'Créer un projet Supabase', + description: 'Créez un nouveau projet sur Supabase.com', + status: 'pending', + icon: + }, + { + id: 'supabase-keys', + title: 'Récupérer les clés Supabase', + description: 'Copiez les clés de votre projet', + status: 'pending', + icon: + }, + { + id: 'database-setup', + title: 'Configurer la base de données', + description: 'Créer les tables et politiques de sécurité', + status: 'pending', + icon: + }, + { + id: 'admin-creation', + title: 'Créer l\'administrateur', + description: 'Créer le premier compte administrateur', + status: 'pending', + icon: + }, + { + id: 'security-setup', + title: 'Configurer la sécurité', + description: 'Activer les politiques RLS', + status: 'pending', + icon: + } + ]; + + useEffect(() => { + // Vérifier si Supabase est déjà configuré + checkExistingSetup(); + }, []); + + const checkExistingSetup = async () => { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + if (supabaseUrl && supabaseAnonKey && supabaseUrl !== 'https://placeholder.supabase.co') { + // Supabase est déjà configuré, rediriger vers l'accueil + router.push('/'); + } + }; + + const updateStepStatus = (stepIndex: number, status: SetupStep['status']) => { + steps[stepIndex].status = status; + }; + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }; + + const validateSupabaseKeys = () => { + if (!formData.supabaseUrl || !formData.supabaseAnonKey || !formData.supabaseServiceKey) { + setError('Veuillez remplir tous les champs Supabase'); + return false; + } + + return true; + }; + + const validateAdminCredentials = () => { + if (!formData.adminEmail || !formData.adminPassword || !formData.adminConfirmPassword) { + setError('Veuillez remplir tous les champs administrateur'); + return false; + } + + if (formData.adminPassword !== formData.adminConfirmPassword) { + setError('Les mots de passe ne correspondent pas'); + return false; + } + + if (formData.adminPassword.length < 6) { + setError('Le mot de passe doit contenir au moins 6 caractères'); + return false; + } + + return true; + }; + + const handleNextStep = async () => { + setError(''); + + if (currentStep === 1) { + // Validation des clés Supabase + if (!validateSupabaseKeys()) return; + } + + if (currentStep === 3) { + // Validation des credentials admin + if (!validateAdminCredentials()) return; + } + + if (currentStep === steps.length - 1) { + // Dernière étape : finaliser la configuration + await finalizeSetup(); + return; + } + + setCurrentStep(prev => prev + 1); + }; + + const handlePreviousStep = () => { + setCurrentStep(prev => Math.max(0, prev - 1)); + }; + + const finalizeSetup = async () => { + setLoading(true); + setError(''); + + try { + // Ici nous appellerons l'API pour finaliser la configuration + const response = await fetch('/api/setup/finalize', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Erreur lors de la configuration'); + } + + setSuccess(true); + setTimeout(() => { + router.push('/admin'); + }, 2000); + + } catch (error: any) { + setError(error.message || 'Erreur lors de la configuration'); + } finally { + setLoading(false); + } + }; + + const renderStepContent = () => { + switch (currentStep) { + case 0: + return ( +
+ + + + Vous devez créer un projet Supabase pour utiliser cette application. + + + +
+

Étapes pour créer un projet Supabase :

+
    +
  1. Allez sur supabase.com
  2. +
  3. Cliquez sur "Start your project"
  4. +
  5. Connectez-vous ou créez un compte
  6. +
  7. Cliquez sur "New project"
  8. +
  9. Choisissez votre organisation
  10. +
  11. Donnez un nom à votre projet (ex: "mes-budgets-participatifs")
  12. +
  13. Créez un mot de passe pour la base de données
  14. +
  15. Choisissez une région proche de vous
  16. +
  17. Cliquez sur "Create new project"
  18. +
  19. Attendez que le projet soit créé (2-3 minutes)
  20. +
+
+ +
+

+ Note : Une fois votre projet créé, vous aurez besoin de l'URL et des clés API que nous configurerons dans l'étape suivante. +

+
+
+ ); + + case 1: + return ( +
+ + + + Récupérez les clés de votre projet Supabase dans les paramètres. + + + +
+

Comment récupérer vos clés :

+
    +
  1. Dans votre projet Supabase, allez dans "Settings" (⚙️)
  2. +
  3. Cliquez sur "API" dans le menu de gauche
  4. +
  5. Copiez l'URL du projet (Project URL)
  6. +
  7. Copiez la clé anon/public (anon public key)
  8. +
  9. Copiez la clé service_role (service_role key)
  10. +
+
+ +
+
+ + handleInputChange('supabaseUrl', e.target.value)} + /> +
+ +
+ + handleInputChange('supabaseAnonKey', e.target.value)} + /> +
+ +
+ + handleInputChange('supabaseServiceKey', e.target.value)} + /> +
+
+
+ ); + + case 2: + return ( +
+ + + + Vous devez créer les tables de base de données dans votre projet Supabase. L'assistant nettoiera automatiquement les données existantes. + + + +
+

Comment créer les tables :

+
    +
  1. Dans votre projet Supabase, allez dans "SQL Editor"
  2. +
  3. Cliquez sur "New query"
  4. +
  5. Copiez le schéma SQL ci-dessous
  6. +
  7. Collez-le dans l'éditeur SQL
  8. +
  9. Cliquez sur "Run" pour exécuter le script
  10. +
  11. Vérifiez que les tables sont créées dans "Table Editor"
  12. +
+
+ + + +
+

Ce qui va être créé :

+
    +
  • Tables : campaigns, propositions, participants, votes, settings, admin_users
  • +
  • Politiques de sécurité (RLS)
  • +
  • Fonctions utilitaires
  • +
  • Index et contraintes
  • +
+
+ +
+

+ Info : L'assistant nettoiera automatiquement toutes les données existantes avant de créer le nouvel administrateur. +

+
+ +
+

+ Important : Cette étape est manuelle. Vous devez exécuter le script SQL dans votre projet Supabase avant de continuer. +

+
+
+ ); + + case 3: + return ( +
+ + + + Créez le premier compte administrateur pour accéder à l'interface d'administration. + + + +
+
+ + handleInputChange('adminEmail', e.target.value)} + /> +
+ +
+ + handleInputChange('adminPassword', e.target.value)} + /> +
+ +
+ + handleInputChange('adminConfirmPassword', e.target.value)} + /> +
+
+ +
+

+ Important : Gardez ces identifiants en sécurité. Vous en aurez besoin pour accéder à l'administration. +

+
+
+ ); + + case 4: + return ( +
+ + + + Configuration finale de la sécurité et activation du mode production. + + + +
+

Configuration finale :

+
    +
  • Activation des politiques RLS (Row Level Security)
  • +
  • Configuration des permissions utilisateur
  • +
  • Création des variables d'environnement
  • +
  • Test de connexion à la base de données
  • +
  • Activation du mode production
  • +
+
+ +
+

+ Prêt ! Une fois cette étape terminée, vous pourrez accéder à l'interface d'administration et commencer à créer vos campagnes. +

+
+
+ ); + + default: + return null; + } + }; + + if (success) { + return ( +
+ + + + Configuration terminée ! + + Votre application est maintenant configurée et prête à être utilisée. + + + +

+ Redirection vers l'administration... +

+ +
+
+
+ ); + } + + return ( +
+
+
+

+ Configuration de Mes Budgets Participatifs +

+

+ Assistant de configuration pour votre nouvelle installation +

+
+ + {/* Étapes */} +
+
+ {steps.map((step, index) => ( +
+
+ {step.status === 'completed' ? ( + + ) : ( + step.icon + )} +
+ {index < steps.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ {steps.map((step) => ( +
+

+ {step.title} +

+
+ ))} +
+
+ + {/* Contenu de l'étape */} + + + + {steps[currentStep].icon} + {steps[currentStep].title} + + + {steps[currentStep].description} + + + + {error && ( + + + {error} + + )} + + {renderStepContent()} + + {/* Boutons de navigation */} +
+ + + +
+
+
+
+
+ ); +} diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx index ef57ec6..d02179c 100644 --- a/src/components/AuthGuard.tsx +++ b/src/components/AuthGuard.tsx @@ -203,7 +203,17 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG )} -
+
+ {isAuthenticated && !isAuthorized && ( + + )} + + + + +
+
{SQL_SCHEMA}
+
+
+ + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 74d583f..213a1b7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,10 @@ import { supabase } from './supabase'; import { supabaseAdmin } from './supabase-admin'; -export interface AdminUser { - id: string; - email: string; - role: 'admin' | 'super_admin'; +export interface UserPermissions { + user_id: string; + is_admin: boolean; + is_super_admin: boolean; created_at: string; updated_at: string; } @@ -21,17 +21,28 @@ export const authService = { async isAdmin(): Promise { try { const user = await this.getCurrentUser(); - if (!user) return false; + if (!user) { + console.log('🔍 isAdmin: Aucun utilisateur connecté'); + return false; + } + console.log('🔍 isAdmin: Vérification pour utilisateur:', user.id, user.email); + const { data, error } = await supabase - .from('admin_users') - .select('id') - .eq('id', user.id) + .from('user_permissions') + .select('is_admin') + .eq('user_id', user.id) .single(); - if (error) return false; - return !!data; - } catch { + if (error) { + console.error('❌ isAdmin: Erreur lors de la vérification:', error); + return false; + } + + console.log('✅ isAdmin: Utilisateur trouvé dans user_permissions:', !!data); + return data?.is_admin || false; + } catch (error) { + console.error('❌ isAdmin: Exception:', error); return false; } }, @@ -43,29 +54,28 @@ export const authService = { if (!user) return false; const { data, error } = await supabase - .from('admin_users') - .select('id') - .eq('id', user.id) - .eq('role', 'super_admin') + .from('user_permissions') + .select('is_super_admin') + .eq('user_id', user.id) .single(); if (error) return false; - return !!data; + return data?.is_super_admin || false; } catch { return false; } }, - // Obtenir les informations de l'admin actuel - async getCurrentAdmin(): Promise { + // Obtenir les permissions de l'utilisateur actuel + async getCurrentPermissions(): Promise { try { const user = await this.getCurrentUser(); if (!user) return null; const { data, error } = await supabase - .from('admin_users') + .from('user_permissions') .select('*') - .eq('id', user.id) + .eq('user_id', user.id) .single(); if (error) return null; @@ -91,27 +101,44 @@ export const authService = { if (error) throw error; }, - // Lister tous les admins (pour les super admins) - async getAllAdmins(): Promise { - const { data, error } = await supabase - .from('admin_users') - .select('*') - .order('created_at', { ascending: false }); - + // Inscription (pour les tests) + async signUp(email: string, password: string) { + const { data, error } = await supabase.auth.signUp({ + email, + password, + }); if (error) throw error; - return data || []; + return data; }, - // Changer le rôle d'un admin (pour les super admins) - async updateAdminRole(adminId: string, role: 'admin' | 'super_admin') { - const { data, error } = await supabaseAdmin - .from('admin_users') - .update({ role }) - .eq('id', adminId) + // Créer un utilisateur admin (côté serveur uniquement) + async createAdminUser(email: string, password: string): Promise<{ user: any; permissions: UserPermissions }> { + // Créer l'utilisateur dans auth.users + const { data: userData, error: userError } = await supabaseAdmin.auth.admin.createUser({ + email, + password, + email_confirm: true + }); + + if (userError) throw userError; + if (!userData.user) throw new Error('Utilisateur non créé'); + + // Créer les permissions admin + const { data: permissionsData, error: permissionsError } = await supabaseAdmin + .from('user_permissions') + .insert({ + user_id: userData.user.id, + is_admin: true, + is_super_admin: true + }) .select() .single(); - if (error) throw error; - return data; + if (permissionsError) throw permissionsError; + + return { + user: userData.user, + permissions: permissionsData + }; } }; diff --git a/src/lib/services.ts b/src/lib/services.ts index 6d0edec..3ed84ee 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -192,6 +192,23 @@ export const campaignService = { .eq('slug', slug) .single(); + if (error) { + if (error.code === 'PGRST116') { + return null; // Aucune campagne trouvée + } + throw error; + } + return data; + }, + + // Méthode pour récupérer une campagne par ID + async getById(id: string): Promise { + const { data, error } = await supabase + .from('campaigns') + .select('*') + .eq('id', id) + .single(); + if (error) { if (error.code === 'PGRST116') { return null; // Aucune campagne trouvée @@ -373,11 +390,15 @@ export const participantService = { // Services pour les votes export const voteService = { async getByParticipant(campaignId: string, participantId: string): Promise { + // Récupérer les votes via les participants de la campagne const { data, error } = await supabase .from('votes') - .select('*') - .eq('campaign_id', campaignId) - .eq('participant_id', participantId); + .select(` + *, + participants!inner(campaign_id) + `) + .eq('participant_id', participantId) + .eq('participants.campaign_id', campaignId); if (error) handleSupabaseError(error, 'récupération des votes par participant'); return data || []; @@ -439,10 +460,14 @@ export const voteService = { }, async getByCampaign(campaignId: string): Promise { + // Récupérer les votes via les participants de la campagne const { data, error } = await supabase .from('votes') - .select('*') - .eq('campaign_id', campaignId); + .select(` + *, + participants!inner(campaign_id) + `) + .eq('participants.campaign_id', campaignId); if (error) handleSupabaseError(error, 'récupération des votes par campagne'); return data || []; @@ -456,10 +481,14 @@ export const voteService = { if (participantsError) throw participantsError; + // Récupérer les votes via les participants de la campagne const { data: votes, error: votesError } = await supabase .from('votes') - .select('*') - .eq('campaign_id', campaignId); + .select(` + *, + participants!inner(campaign_id) + `) + .eq('participants.campaign_id', campaignId); if (votesError) throw votesError; @@ -475,20 +504,34 @@ export const voteService = { }); }, - // Méthode pour remplacer tous les votes d'un participant de manière atomique + // Méthode pour remplacer tous les votes d'un participant async replaceVotes( campaignId: string, participantId: string, votes: Array<{ proposition_id: string; amount: number }> ): Promise { - // Utiliser une transaction pour garantir l'atomicité - const { error } = await supabase.rpc('replace_participant_votes', { - p_campaign_id: campaignId, - p_participant_id: participantId, - p_votes: votes - }); + // 1. Supprimer tous les votes existants du participant + const { error: deleteError } = await supabase + .from('votes') + .delete() + .eq('participant_id', participantId); - if (error) handleSupabaseError(error, 'remplacement des votes du participant'); + if (deleteError) handleSupabaseError(deleteError, 'suppression des votes existants'); + + // 2. Insérer les nouveaux votes + if (votes.length > 0) { + const votesToInsert = votes.map(vote => ({ + participant_id: participantId, + proposition_id: vote.proposition_id, + amount: vote.amount + })); + + const { error: insertError } = await supabase + .from('votes') + .insert(votesToInsert); + + if (insertError) handleSupabaseError(insertError, 'insertion des nouveaux votes'); + } } }; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..c00ca54 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Pages à protéger une fois l'application configurée + const protectedPages = ['/setup', '/debug-auth']; + + // Vérifier si on est sur une page protégée + if (protectedPages.some(page => pathname.startsWith(page))) { + // Vérifier si Supabase est configuré + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + // Si Supabase est configuré (pas les valeurs par défaut), rediriger vers la page d'accueil + if (supabaseUrl && supabaseAnonKey && + supabaseUrl !== 'https://placeholder.supabase.co' && + supabaseAnonKey !== 'your-anon-key') { + + console.log('🔒 Accès bloqué aux pages de configuration - Supabase déjà configuré'); + return NextResponse.redirect(new URL('/', request.url)); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + '/setup/:path*', + '/debug-auth/:path*', + ], +}; diff --git a/src/types/index.ts b/src/types/index.ts index 69ef5f8..513c4cf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,7 +42,6 @@ export interface Participant { export interface Vote { id: string; - campaign_id: string; participant_id: string; proposition_id: string; amount: number;