diff --git a/config/database.js b/config/database.js index c6bacbb..5b13b8f 100644 --- a/config/database.js +++ b/config/database.js @@ -179,6 +179,23 @@ function initDatabase() { ) `); + // Table google_oauth_tokens (tokens OAuth pour Google Calendar) + db.run(` + CREATE TABLE IF NOT EXISTS google_oauth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT, + token_type TEXT DEFAULT 'Bearer', + expiry_date INTEGER, + scope TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id) + ) + `); + // Créer les index pour la performance db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)'); db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)'); diff --git a/routes/calendar.routes.js b/routes/calendar.routes.js index cb156b0..c3bacdf 100644 --- a/routes/calendar.routes.js +++ b/routes/calendar.routes.js @@ -1,14 +1,217 @@ -// Routes pour Google Calendar +// Routes pour Google Calendar avec OAuth 2.0 const express = require('express'); const router = express.Router(); const { google } = require('googleapis'); const { getAll, getOne, runQuery } = require('../config/database'); -const { authenticateToken } = require('../middleware/auth'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); const logger = require('../config/logger'); // Toutes les routes nécessitent authentification router.use(authenticateToken); +// Scopes Google Calendar requis +const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly']; + +/** + * Créer un client OAuth2 + */ +async function getOAuth2Client() { + const clientId = await getOne("SELECT value FROM settings WHERE key = 'google_client_id'"); + const clientSecret = await getOne("SELECT value FROM settings WHERE key = 'google_client_secret'"); + + if (!clientId || !clientId.value || !clientSecret || !clientSecret.value) { + throw new Error('Client ID et Client Secret non configurés'); + } + + const redirectUri = `${process.env.APP_URL || 'http://localhost:2222'}/api/calendar/oauth-callback`; + + return new google.auth.OAuth2( + clientId.value, + clientSecret.value, + redirectUri + ); +} + +/** + * GET /api/calendar/auth-url + * Générer l'URL d'authentification OAuth + */ +router.get('/auth-url', requireAdmin, async (req, res) => { + try { + const oauth2Client = await getOAuth2Client(); + + const authUrl = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: SCOPES, + prompt: 'consent', // Force le prompt pour obtenir refresh_token + state: req.user.id.toString() // Pour identifier l'utilisateur au retour + }); + + res.json({ authUrl }); + } catch (error) { + logger.error('Erreur lors de la génération de l\'URL OAuth:', error); + res.status(500).json({ + error: 'Erreur lors de la génération de l\'URL', + details: error.message + }); + } +}); + +/** + * GET /api/calendar/oauth-callback + * Callback OAuth pour récupérer les tokens + */ +router.get('/oauth-callback', async (req, res) => { + try { + const { code, state } = req.query; + + if (!code) { + return res.status(400).send('Code d\'autorisation manquant'); + } + + const userId = parseInt(state); + if (!userId) { + return res.status(400).send('État utilisateur invalide'); + } + + const oauth2Client = await getOAuth2Client(); + + // Échanger le code contre des tokens + const { tokens } = await oauth2Client.getToken(code); + + // Sauvegarder les tokens dans la base de données + const existing = await getOne('SELECT id FROM google_oauth_tokens WHERE user_id = ?', [userId]); + + if (existing) { + await runQuery(` + UPDATE google_oauth_tokens + SET access_token = ?, refresh_token = COALESCE(?, refresh_token), + token_type = ?, expiry_date = ?, scope = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `, [ + tokens.access_token, + tokens.refresh_token, + tokens.token_type || 'Bearer', + tokens.expiry_date || null, + tokens.scope || SCOPES.join(' '), + userId + ]); + } else { + await runQuery(` + INSERT INTO google_oauth_tokens + (user_id, access_token, refresh_token, token_type, expiry_date, scope) + VALUES (?, ?, ?, ?, ?, ?) + `, [ + userId, + tokens.access_token, + tokens.refresh_token || null, + tokens.token_type || 'Bearer', + tokens.expiry_date || null, + tokens.scope || SCOPES.join(' ') + ]); + } + + logger.info(`Tokens OAuth sauvegardés pour l'utilisateur ${userId}`); + + // Rediriger vers l'application avec un message de succès + res.send(` + + + +

Authentification réussie!

+

Vous pouvez fermer cette fenêtre et retourner à l'application.

+ + + `); + } catch (error) { + logger.error('Erreur lors du callback OAuth:', error); + res.status(500).send(` + + +

Erreur d'authentification

+

${error.message}

+ + + + `); + } +}); + +/** + * GET /api/calendar/auth-status + * Vérifier si l'utilisateur est authentifié avec Google + */ +router.get('/auth-status', async (req, res) => { + try { + const tokens = await getOne( + 'SELECT access_token, expiry_date FROM google_oauth_tokens WHERE user_id = ?', + [req.user.id] + ); + + const isAuthenticated = !!tokens; + const isExpired = tokens && tokens.expiry_date && tokens.expiry_date < Date.now(); + + res.json({ + isAuthenticated, + isExpired, + needsReauth: !isAuthenticated || isExpired + }); + } catch (error) { + logger.error('Erreur lors de la vérification du statut OAuth:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + +/** + * Récupérer et rafraîchir les tokens OAuth si nécessaire + */ +async function getValidTokens(userId) { + const tokenData = await getOne( + 'SELECT * FROM google_oauth_tokens WHERE user_id = ?', + [userId] + ); + + if (!tokenData) { + throw new Error('Tokens OAuth non trouvés. Veuillez vous authentifier.'); + } + + const oauth2Client = await getOAuth2Client(); + oauth2Client.setCredentials({ + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + token_type: tokenData.token_type, + expiry_date: tokenData.expiry_date, + scope: tokenData.scope + }); + + // Rafraîchir le token si expiré + if (tokenData.expiry_date && tokenData.expiry_date < Date.now()) { + try { + const { credentials } = await oauth2Client.refreshAccessToken(); + + // Mettre à jour les tokens dans la base de données + await runQuery(` + UPDATE google_oauth_tokens + SET access_token = ?, expiry_date = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `, [credentials.access_token, credentials.expiry_date, userId]); + + oauth2Client.setCredentials(credentials); + logger.info(`Token OAuth rafraîchi pour l'utilisateur ${userId}`); + } catch (error) { + logger.error('Erreur lors du rafraîchissement du token:', error); + throw new Error('Erreur lors du rafraîchissement du token. Veuillez vous reconnecter.'); + } + } + + return oauth2Client; +} + /** * GET /api/calendar/events * Récupérer les événements du calendrier @@ -38,24 +241,15 @@ router.get('/events', async (req, res) => { /** * POST /api/calendar/sync - * Synchroniser avec Google Calendar + * Synchroniser avec Google Calendar via OAuth */ router.post('/sync', async (req, res) => { try { - // Récupérer les paramètres Google Calendar depuis settings - const apiKey = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_api_key'"); - const calendarId = await getOne("SELECT value FROM settings WHERE key = 'google_calendar_id'"); + // Obtenir un client OAuth valide + const oauth2Client = await getValidTokens(req.user.id); - if (!apiKey || !apiKey.value) { - return res.status(400).json({ error: 'Clé API Google Calendar non configurée' }); - } - - if (!calendarId || !calendarId.value) { - return res.status(400).json({ error: 'ID du calendrier non configuré' }); - } - - // Créer un client Google Calendar - const calendar = google.calendar({ version: 'v3', auth: apiKey.value }); + // Créer un client Google Calendar avec OAuth + const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); // Récupérer les événements des 30 prochains jours const now = new Date(); @@ -63,7 +257,7 @@ router.post('/sync', async (req, res) => { futureDate.setDate(futureDate.getDate() + 30); const response = await calendar.events.list({ - calendarId: calendarId.value, + calendarId: 'primary', // Calendrier principal de l'utilisateur timeMin: now.toISOString(), timeMax: futureDate.toISOString(), maxResults: 50, @@ -130,6 +324,15 @@ router.post('/sync', async (req, res) => { }); } catch (error) { logger.error('Erreur lors de la synchronisation avec Google Calendar:', error); + + // Si l'erreur est liée à l'authentification, renvoyer un statut 401 + if (error.message.includes('authentifier') || error.message.includes('reconnecter')) { + return res.status(401).json({ + error: error.message, + needsReauth: true + }); + } + res.status(500).json({ error: 'Erreur lors de la synchronisation', details: error.message @@ -137,6 +340,22 @@ router.post('/sync', async (req, res) => { } }); +/** + * POST /api/calendar/disconnect + * Déconnecter Google Calendar (supprimer les tokens) + */ +router.post('/disconnect', async (req, res) => { + try { + await runQuery('DELETE FROM google_oauth_tokens WHERE user_id = ?', [req.user.id]); + logger.info(`Tokens OAuth supprimés pour l'utilisateur ${req.user.username}`); + + res.json({ message: 'Déconnecté avec succès' }); + } catch (error) { + logger.error('Erreur lors de la déconnexion:', error); + res.status(500).json({ error: 'Erreur serveur' }); + } +}); + /** * DELETE /api/calendar/events/:id * Supprimer un événement local diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 0510ba8..a5b08ca 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -162,7 +162,6 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito onKeyDown={handleKeyDown} dir="ltr" className="min-h-[400px] outline-none prose prose-sm max-w-none dark:prose-invert" - dangerouslySetInnerHTML={{ __html: content }} data-placeholder={placeholder || 'Commencez à écrire...'} style={{ fontSize: '16px', diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ec9aa42..ba62da3 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -54,6 +54,11 @@ const Index = () => { const [noteTags, setNoteTags] = useState([]); const [openRouterModels, setOpenRouterModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); + const [calendarAuthStatus, setCalendarAuthStatus] = useState({ + isAuthenticated: false, + isExpired: false, + needsReauth: true + }); const navigate = useNavigate(); // Modal states @@ -189,6 +194,75 @@ const Index = () => { } }, [showAdmin, adminTab, settings.openrouter_api_key]); + useEffect(() => { + if (showAdmin && adminTab === 'calendar') { + checkCalendarAuthStatus(); + } + }, [showAdmin, adminTab]); + + const checkCalendarAuthStatus = async () => { + try { + const status = await CalendarService.getAuthStatus(); + setCalendarAuthStatus(status); + } catch (error) { + console.error('Erreur lors de la vérification du statut OAuth:', error); + } + }; + + const handleGoogleConnect = async () => { + try { + const authUrl = await CalendarService.getAuthUrl(); + + // Ouvrir une popup pour l'authentification OAuth + const width = 600; + const height = 700; + const left = window.screen.width / 2 - width / 2; + const top = window.screen.height / 2 - height / 2; + + const popup = window.open( + authUrl, + 'GoogleAuth', + `width=${width},height=${height},top=${top},left=${left}` + ); + + // Écouter le message de la popup + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'google-auth-success') { + showSuccess('Authentification Google réussie'); + checkCalendarAuthStatus(); + window.removeEventListener('message', handleMessage); + } else if (event.data.type === 'google-auth-error') { + showError(`Erreur d'authentification: ${event.data.error}`); + window.removeEventListener('message', handleMessage); + } + }; + + window.addEventListener('message', handleMessage); + + // Fermer le listener si la popup est fermée manuellement + const checkPopup = setInterval(() => { + if (popup && popup.closed) { + clearInterval(checkPopup); + window.removeEventListener('message', handleMessage); + } + }, 1000); + } catch (error) { + showError('Erreur lors de la génération de l\'URL d\'authentification'); + } + }; + + const handleGoogleDisconnect = async () => { + try { + const success = await CalendarService.disconnect(); + if (success) { + showSuccess('Déconnecté de Google Calendar'); + checkCalendarAuthStatus(); + } + } catch (error) { + showError('Erreur lors de la déconnexion'); + } + }; + const handleLogout = () => { AuthService.logout(); navigate("/login"); @@ -1271,64 +1345,106 @@ const Index = () => { - Configuration Google Calendar + Configuration Google Calendar OAuth 2.0
- + setSettings({ ...settings, google_calendar_api_key: e.target.value })} + id="google-client-id" + type="text" + placeholder="123456789-abcdef.apps.googleusercontent.com" + value={settings.google_client_id || ''} + onChange={(e) => setSettings({ ...settings, google_client_id: e.target.value })} /> -

- Créez une clé API sur{' '} - - Google Cloud Console - - {' '}et activez l'API Google Calendar -

- + setSettings({ ...settings, google_calendar_id: e.target.value })} + id="google-client-secret" + type="password" + placeholder="GOCSPX-..." + value={settings.google_client_secret || ''} + onChange={(e) => setSettings({ ...settings, google_client_secret: e.target.value })} />

- Trouvez l'ID du calendrier dans les paramètres de votre calendrier Google.
- Pour votre calendrier principal, utilisez votre adresse email. + Créez un projet OAuth 2.0 sur{' '} + + Google Cloud Console + + {' '}et activez l'API Google Calendar. +
+ URI de redirection autorisée: {window.location.origin}/api/calendar/oauth-callback

-
+ + {settings.google_client_id && settings.google_client_secret && ( + <> +
+

Statut de connexion

+ {calendarAuthStatus.isAuthenticated && !calendarAuthStatus.isExpired ? ( +
+
+ Connecté à Google Calendar +
+ ) : ( +
+
+ Non connecté +
+ )} +
+ +
+ {calendarAuthStatus.needsReauth ? ( + + ) : ( + <> + + + + )} +
+ + )}
diff --git a/src/services/CalendarService.ts b/src/services/CalendarService.ts index 4ebd91a..4d3a226 100644 --- a/src/services/CalendarService.ts +++ b/src/services/CalendarService.ts @@ -37,6 +37,40 @@ class CalendarService { } } + /** + * Obtenir l'URL d'authentification OAuth Google + */ + async getAuthUrl(): Promise { + const response = await axios.get(`${this.baseURL}/auth-url`, this.getAuthHeader()); + return response.data.authUrl; + } + + /** + * Vérifier le statut d'authentification Google + */ + async getAuthStatus(): Promise<{ isAuthenticated: boolean; isExpired: boolean; needsReauth: boolean }> { + try { + const response = await axios.get(`${this.baseURL}/auth-status`, this.getAuthHeader()); + return response.data; + } catch (error) { + console.error("Erreur lors de la vérification du statut OAuth:", error); + return { isAuthenticated: false, isExpired: false, needsReauth: true }; + } + } + + /** + * Déconnecter Google Calendar + */ + async disconnect(): Promise { + try { + await axios.post(`${this.baseURL}/disconnect`, {}, this.getAuthHeader()); + return true; + } catch (error) { + console.error("Erreur lors de la déconnexion:", error); + return false; + } + } + /** * Synchroniser avec Google Calendar */ diff --git a/src/services/SettingsService.ts b/src/services/SettingsService.ts index 8a66b32..2f6750a 100644 --- a/src/services/SettingsService.ts +++ b/src/services/SettingsService.ts @@ -5,6 +5,9 @@ export interface Settings { ai_model?: string; rss_refresh_interval?: number; theme?: 'light' | 'dark' | 'auto'; + google_client_id?: string; + google_client_secret?: string; + // Legacy fields (kept for backward compatibility) google_calendar_api_key?: string; google_calendar_id?: string; }