diff --git a/config/database.js b/config/database.js index fdc4980..f5749d9 100644 --- a/config/database.js +++ b/config/database.js @@ -97,10 +97,21 @@ async function initDatabase() { username VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, is_admin BOOLEAN DEFAULT FALSE, + api_key VARCHAR(64) UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); + // Migration: ajouter la colonne api_key si elle n'existe pas + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'api_key') THEN + ALTER TABLE users ADD COLUMN api_key VARCHAR(64) UNIQUE; + END IF; + END $$; + `); + // Table notes await client.query(` CREATE TABLE IF NOT EXISTS notes ( diff --git a/middleware/auth.js b/middleware/auth.js index 52f6761..59a52c9 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -1,6 +1,7 @@ -// Middleware d'authentification JWT +// Middleware d'authentification JWT et API Key const jwt = require('jsonwebtoken'); const logger = require('../config/logger'); +const { getOne } = require('../config/database'); const JWT_SECRET = process.env.JWT_SECRET || 'change_me_in_production_please_use_strong_secret'; @@ -9,14 +10,42 @@ if (JWT_SECRET === 'change_me_in_production_please_use_strong_secret' && process } /** - * Middleware pour vérifier le token JWT + * Middleware pour vérifier le token JWT ou la clé API + * Supporte: + * - Header "Authorization: Bearer TOKEN" (JWT, expire après 24h) + * - Header "X-API-Key: API_KEY" (clé permanente) */ -function authenticateToken(req, res, next) { +async function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; + const apiKey = req.headers['x-api-key']; + + // Option 1: Clé API permanente (prioritaire) + if (apiKey) { + try { + const user = await getOne( + 'SELECT id, username, is_admin FROM users WHERE api_key = $1', + [apiKey] + ); + + if (!user) { + logger.warn(`Tentative d'accès avec clé API invalide`); + return res.status(403).json({ error: 'Clé API invalide' }); + } + + req.user = user; + req.authType = 'api_key'; + return next(); + } catch (error) { + logger.error('Erreur lors de la vérification de la clé API:', error); + return res.status(500).json({ error: 'Erreur serveur' }); + } + } + + // Option 2: Token JWT const token = authHeader && authHeader.split(' ')[1]; // Format: "Bearer TOKEN" if (!token) { - return res.status(401).json({ error: 'Token d\'authentification manquant' }); + return res.status(401).json({ error: 'Token d\'authentification ou clé API manquant' }); } jwt.verify(token, JWT_SECRET, (err, user) => { @@ -27,6 +56,7 @@ function authenticateToken(req, res, next) { // Ajouter les informations utilisateur à la requête req.user = user; + req.authType = 'jwt'; next(); }); } diff --git a/routes/notes.routes.js b/routes/notes.routes.js index e7a40d0..f24a084 100644 --- a/routes/notes.routes.js +++ b/routes/notes.routes.js @@ -220,6 +220,385 @@ router.post('/', } ); +/** + * POST /api/notes/full + * Créer une note complète avec tâches et tags en une seule requête + */ +router.post('/full', + [ + body('title').trim().notEmpty().withMessage('Le titre est requis'), + body('content').optional(), + body('todos').optional().isArray(), + body('tags').optional().isArray() + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const { title, content, todos = [], tags = [] } = req.body; + + logger.info(`[CREATE NOTE FULL] Début création - user_id: ${req.user.id}, title: "${title}", todos: ${todos.length}, tags: ${tags.length}`); + + // 1. Créer la note + const noteResult = await runQuery(` + INSERT INTO notes (user_id, title, content) + VALUES ($1, $2, $3) + RETURNING * + `, [req.user.id, title, content || '']); + + const noteId = noteResult.id; + const createdTodos = []; + const createdTags = []; + + // 2. Créer les todos (avec support des subtasks) + const createTodosRecursive = async (todoList, parentId = null, level = 0) => { + let position = 0; + for (const todo of todoList) { + position++; + const todoResult = await runQuery(` + INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [ + noteId, + todo.text, + todo.completed || false, + todo.priority || false, + position, + parentId, + level + ]); + + const createdTodo = { + id: todoResult.id, + text: todoResult.text, + completed: todoResult.completed, + priority: todoResult.priority, + position: todoResult.position, + parent_id: todoResult.parent_id, + level: todoResult.level + }; + + // Traiter les subtasks si présents + if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { + createdTodo.subtasks = []; + const subtasks = await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); + createdTodo.subtasks = subtasks; + } + + createdTodos.push(createdTodo); + } + return createdTodos.filter(t => t.parent_id === parentId); + }; + + await createTodosRecursive(todos); + + // 3. Créer les tags + for (const tag of tags) { + const tagName = typeof tag === 'string' ? tag.trim().toLowerCase() : tag.name?.trim().toLowerCase(); + if (tagName) { + try { + const tagResult = await runQuery(` + INSERT INTO note_tags (note_id, tag) + VALUES ($1, $2) + RETURNING * + `, [noteId, tagName]); + createdTags.push({ id: tagResult.id, name: tagResult.tag }); + } catch (err) { + // Ignorer les doublons + if (!err.message?.includes('UNIQUE')) { + throw err; + } + } + } + } + + logger.info(`[CREATE NOTE FULL] Note créée avec succès - ID: ${noteId}, todos: ${createdTodos.length}, tags: ${createdTags.length}`); + + res.status(201).json({ + id: noteId, + title, + content: content || '', + image_filename: null, + archived: false, + priority: 0, + todos: createdTodos, + tags: createdTags, + images: [], + files: [], + created_at: noteResult.created_at, + updated_at: noteResult.updated_at + }); + } catch (error) { + logger.error('Erreur lors de la création de la note complète:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * PUT /api/notes/:id/full + * Mise à jour complète d'une note avec ses tâches et tags + */ +router.put('/:id/full', + [ + body('title').optional().trim().notEmpty(), + body('content').optional(), + body('todos').optional().isArray(), + body('tags').optional().isArray(), + body('replace_todos').optional().isBoolean(), + body('replace_tags').optional().isBoolean() + ], + async (req, res) => { + try { + const { title, content, todos, tags, replace_todos = false, replace_tags = false } = req.body; + const noteId = req.params.id; + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [noteId, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + logger.info(`[UPDATE NOTE FULL] Mise à jour - note_id: ${noteId}, replace_todos: ${replace_todos}, replace_tags: ${replace_tags}`); + + // 1. Mettre à jour la note + const updates = []; + const params = []; + let paramCount = 0; + + if (title !== undefined) { + paramCount++; + updates.push(`title = $${paramCount}`); + params.push(title); + } + if (content !== undefined) { + paramCount++; + updates.push(`content = $${paramCount}`); + params.push(content); + } + + if (updates.length > 0) { + updates.push('updated_at = CURRENT_TIMESTAMP'); + paramCount++; + params.push(noteId); + await runQuery(`UPDATE notes SET ${updates.join(', ')} WHERE id = $${paramCount}`, params); + } + + const createdTodos = []; + const createdTags = []; + + // 2. Gérer les todos + if (todos !== undefined) { + if (replace_todos) { + // Supprimer tous les todos existants + await runQuery('DELETE FROM note_todos WHERE note_id = $1', [noteId]); + } + + // Créer les nouveaux todos + const createTodosRecursive = async (todoList, parentId = null, level = 0) => { + let position = 0; + for (const todo of todoList) { + position++; + const todoResult = await runQuery(` + INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [ + noteId, + todo.text, + todo.completed || false, + todo.priority || false, + position, + parentId, + level + ]); + + const createdTodo = { + id: todoResult.id, + text: todoResult.text, + completed: todoResult.completed, + priority: todoResult.priority, + position: todoResult.position, + parent_id: todoResult.parent_id, + level: todoResult.level + }; + + if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { + createdTodo.subtasks = []; + await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); + } + + createdTodos.push(createdTodo); + } + }; + + await createTodosRecursive(todos); + } + + // 3. Gérer les tags + if (tags !== undefined) { + if (replace_tags) { + // Supprimer tous les tags existants + await runQuery('DELETE FROM note_tags WHERE note_id = $1', [noteId]); + } + + // Créer les nouveaux tags + for (const tag of tags) { + const tagName = typeof tag === 'string' ? tag.trim().toLowerCase() : tag.name?.trim().toLowerCase(); + if (tagName) { + try { + const tagResult = await runQuery(` + INSERT INTO note_tags (note_id, tag) + VALUES ($1, $2) + RETURNING * + `, [noteId, tagName]); + createdTags.push({ id: tagResult.id, name: tagResult.tag }); + } catch (err) { + if (!err.message?.includes('UNIQUE')) { + throw err; + } + } + } + } + } + + // Mettre à jour updated_at + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [noteId]); + + // Récupérer la note mise à jour + const updatedNote = await getOne(` + SELECT id, title, content, image_filename, archived, priority, created_at, updated_at + FROM notes WHERE id = $1 + `, [noteId]); + + // Récupérer tous les todos de la note + const allTodos = await getAll(` + SELECT id, text, completed, priority, position, parent_id, level + FROM note_todos WHERE note_id = $1 + ORDER BY position ASC, id ASC + `, [noteId]); + + // Récupérer tous les tags de la note + const allTags = await getAll(` + SELECT id, tag as name FROM note_tags WHERE note_id = $1 + ORDER BY tag ASC + `, [noteId]); + + logger.info(`[UPDATE NOTE FULL] Mise à jour réussie - note_id: ${noteId}`); + + res.json({ + message: 'Note mise à jour avec succès', + note: { + ...updatedNote, + todos: allTodos || [], + tags: allTags || [] + } + }); + } catch (error) { + logger.error('Erreur lors de la mise à jour complète de la note:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + +/** + * POST /api/notes/:id/todos/batch + * Ajouter plusieurs tâches à une note en une seule requête + */ +router.post('/:id/todos/batch', + [ + body('todos').isArray({ min: 1 }).withMessage('Au moins une tâche est requise'), + body('todos.*.text').trim().notEmpty().withMessage('Le texte de chaque tâche est requis') + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ error: 'Données invalides', details: errors.array() }); + } + + const noteId = req.params.id; + const { todos } = req.body; + + // Vérifier que la note appartient à l'utilisateur + const note = await getOne('SELECT id FROM notes WHERE id = $1 AND user_id = $2', [noteId, req.user.id]); + if (!note) { + return res.status(404).json({ error: 'Note non trouvée' }); + } + + logger.info(`[CREATE TODOS BATCH] Ajout de ${todos.length} tâches à la note ${noteId}`); + + const createdTodos = []; + + // Créer les todos récursivement + const createTodosRecursive = async (todoList, parentId = null, level = 0) => { + // Récupérer la position max actuelle + const maxPosResult = await getOne(` + SELECT COALESCE(MAX(position), 0) as max_pos + FROM note_todos + WHERE note_id = $1 AND COALESCE(parent_id, 0) = COALESCE($2, 0) + `, [noteId, parentId]); + let position = maxPosResult?.max_pos || 0; + + for (const todo of todoList) { + position++; + const todoResult = await runQuery(` + INSERT INTO note_todos (note_id, text, completed, priority, position, parent_id, level) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [ + noteId, + todo.text, + todo.completed || false, + todo.priority || false, + position, + parentId, + level + ]); + + const createdTodo = { + id: todoResult.id, + text: todoResult.text, + completed: todoResult.completed, + priority: todoResult.priority, + position: todoResult.position, + parent_id: todoResult.parent_id, + level: todoResult.level + }; + + // Traiter les subtasks si présents + if (todo.subtasks && Array.isArray(todo.subtasks) && todo.subtasks.length > 0) { + createdTodo.subtasks = await createTodosRecursive(todo.subtasks, todoResult.id, level + 1); + } + + createdTodos.push(createdTodo); + } + + return createdTodos.filter(t => t.parent_id === parentId); + }; + + await createTodosRecursive(todos); + + // Mettre à jour updated_at de la note + await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [noteId]); + + logger.info(`[CREATE TODOS BATCH] ${createdTodos.length} tâches créées pour la note ${noteId}`); + + res.status(201).json({ + message: `${createdTodos.length} tâche(s) ajoutée(s) avec succès`, + todos: createdTodos + }); + } catch (error) { + logger.error('Erreur lors de l\'ajout des tâches en lot:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } + } +); + /** * PUT /api/notes/:id * Modifier une note diff --git a/routes/users.routes.js b/routes/users.routes.js index 4d6cc7a..08c1950 100644 --- a/routes/users.routes.js +++ b/routes/users.routes.js @@ -2,32 +2,124 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcrypt'); +const crypto = require('crypto'); const { body, validationResult } = require('express-validator'); const { getAll, getOne, runQuery } = require('../config/database'); const { authenticateToken, requireAdmin } = require('../middleware/auth'); const logger = require('../config/logger'); +/** + * Génère une clé API sécurisée + * Format: nf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (48 caractères hex) + */ +function generateApiKey() { + return 'nf_' + crypto.randomBytes(24).toString('hex'); +} + // Toutes les routes nécessitent authentification et droits admin router.use(authenticateToken); router.use(requireAdmin); /** * GET /api/users - * Liste tous les utilisateurs + * Liste tous les utilisateurs (avec indication si clé API existe) */ router.get('/', async (req, res) => { try { const users = await getAll( - 'SELECT id, username, is_admin, created_at FROM users ORDER BY created_at DESC' + 'SELECT id, username, is_admin, api_key, created_at FROM users ORDER BY created_at DESC' ); - res.json(users); + // Ne pas exposer la clé complète, juste indiquer si elle existe + const usersWithApiKeyStatus = users.map(u => ({ + ...u, + has_api_key: !!u.api_key, + api_key: undefined // Ne pas envoyer la clé dans la liste + })); + res.json(usersWithApiKeyStatus); } catch (error) { logger.error('Erreur lors de la récupération des utilisateurs:', error); res.status(500).json({ error: 'Erreur serveur' }); } }); +/** + * GET /api/users/:id/api-key + * Récupérer la clé API d'un utilisateur (génère si n'existe pas) + */ +router.get('/:id/api-key', async (req, res) => { + try { + const userId = req.params.id; + + const user = await getOne('SELECT id, username, api_key FROM users WHERE id = $1', [userId]); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } + + // Si pas de clé API, en générer une + if (!user.api_key) { + const newApiKey = generateApiKey(); + await runQuery('UPDATE users SET api_key = $1 WHERE id = $2', [newApiKey, userId]); + logger.info(`[API KEY] Clé API générée pour ${user.username} (ID: ${userId})`); + return res.json({ api_key: newApiKey, generated: true }); + } + + res.json({ api_key: user.api_key, generated: false }); + } catch (error) { + logger.error('Erreur lors de la récupération de la clé API:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * POST /api/users/:id/api-key/regenerate + * Régénérer la clé API d'un utilisateur + */ +router.post('/:id/api-key/regenerate', async (req, res) => { + try { + const userId = req.params.id; + + const user = await getOne('SELECT id, username FROM users WHERE id = $1', [userId]); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } + + const newApiKey = generateApiKey(); + await runQuery('UPDATE users SET api_key = $1 WHERE id = $2', [newApiKey, userId]); + + logger.info(`[API KEY] Clé API régénérée pour ${user.username} (ID: ${userId})`); + + res.json({ api_key: newApiKey, message: 'Clé API régénérée avec succès' }); + } catch (error) { + logger.error('Erreur lors de la régénération de la clé API:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * DELETE /api/users/:id/api-key + * Révoquer la clé API d'un utilisateur + */ +router.delete('/:id/api-key', async (req, res) => { + try { + const userId = req.params.id; + + const user = await getOne('SELECT id, username FROM users WHERE id = $1', [userId]); + if (!user) { + return res.status(404).json({ error: 'Utilisateur non trouvé' }); + } + + await runQuery('UPDATE users SET api_key = NULL WHERE id = $1', [userId]); + + logger.info(`[API KEY] Clé API révoquée pour ${user.username} (ID: ${userId})`); + + res.json({ message: 'Clé API révoquée avec succès' }); + } catch (error) { + logger.error('Erreur lors de la révocation de la clé API:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + /** * POST /api/users * Créer un nouvel utilisateur diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index a416f7f..4152632 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -194,6 +194,7 @@ const Index = () => { const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [showStatsDashboard, setShowStatsDashboard] = useState(false); const [showPomodoroModal, setShowPomodoroModal] = useState(false); + const [apiInfoModal, setApiInfoModal] = useState<{ open: boolean; user?: UserType; apiKey?: string; loading?: boolean }>({ open: false }); // Pomodoro timer states const [pomodoroRunning, setPomodoroRunning] = useState(false); @@ -2258,6 +2259,22 @@ const Index = () => {