From 263c98d74fd60997989379b3cac9ed4152ca8d71 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 14 May 2026 05:56:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(settings):=20Google=20OAuth=20credentials?= =?UTF-8?q?=20UI=20in=20Configura=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Load GOOGLE_CLIENT_ID/SECRET from DB at startup (env vars take priority) - GET /api/settings/google-credentials → masked status (secret never returned) - POST /api/settings/google-credentials → save to DB, hot-reload in memory - /api/auth/google/url returns 503 with clear message when unconfigured Frontend (ConfiguracoesView): - OAuth credential inputs (Client ID + Client Secret with show/hide toggle) - Shows configured status badge (ENV vs DB) and masked clientIdHint - Instructions with redirect URI shown inline Co-Authored-By: Claude Sonnet 4.6 --- backend/server.js | 54 ++++++++- frontend/views/ConfiguracoesView.tsx | 159 ++++++++++++++++++++++++--- 2 files changed, 196 insertions(+), 17 deletions(-) diff --git a/backend/server.js b/backend/server.js index 9ea38b6..e0a14fa 100644 --- a/backend/server.js +++ b/backend/server.js @@ -170,9 +170,27 @@ app.post('/api/login', async (req, res) => { const PORT = process.env.PORT || 3005; // --- GOOGLE OAUTH2 SETUP --- +// Credentials: env vars take priority; DB-stored values used as fallback (loaded at startup). +const googleCreds = { + clientId: process.env.GOOGLE_CLIENT_ID || null, + clientSecret: process.env.GOOGLE_CLIENT_SECRET || null, +}; + +async function loadGoogleCredsFromDb() { + try { + const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'google_credentials'"); + if (rows[0]) { + const saved = JSON.parse(rows[0].data); + if (!googleCreds.clientId && saved.clientId) googleCreds.clientId = saved.clientId; + if (!googleCreds.clientSecret && saved.clientSecret) googleCreds.clientSecret = saved.clientSecret; + if (googleCreds.clientId) console.log('[GOOGLE] Credentials loaded from DB'); + } + } catch (e) { console.error('[GOOGLE] Could not load credentials from DB:', e.message); } +} + const createOAuth2Client = () => new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, + googleCreds.clientId, + googleCreds.clientSecret, process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/api/oauth2callback` ); @@ -440,6 +458,34 @@ app.post('/api/settings', async (req, res) => { } catch (err) { res.status(500).json({ error: err.message }); } }); +// GET /api/settings/google-credentials — returns configured status + masked clientId only +app.get('/api/settings/google-credentials', tenantGuard, async (req, res) => { + const fromEnv = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); + const configured = !!(googleCreds.clientId && googleCreds.clientSecret); + const clientIdHint = googleCreds.clientId + ? googleCreds.clientId.substring(0, 12) + '...' + googleCreds.clientId.slice(-8) + : null; + res.json({ configured, clientIdHint, fromEnv }); +}); + +// POST /api/settings/google-credentials — save to DB + update in-memory cache +app.post('/api/settings/google-credentials', tenantGuard, async (req, res) => { + const { clientId, clientSecret } = req.body; + if (!clientId || !clientSecret) return res.status(400).json({ error: 'clientId e clientSecret são obrigatórios.' }); + try { + await pool.query( + `INSERT INTO settings (id, category, data) VALUES ('google_credentials', 'oauth', $1) + ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`, + [JSON.stringify({ clientId, clientSecret })] + ); + // Update in-memory cache immediately (no restart needed) + googleCreds.clientId = clientId; + googleCreds.clientSecret = clientSecret; + console.log('[GOOGLE] Credentials updated via UI'); + res.json({ success: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // --- MAGIC LINK & DENTIST SELF-REGISTRATION --- app.post('/api/dentistas/magic-link', async (req, res) => { const { clinicaId, dentistName } = req.body; @@ -1520,9 +1566,9 @@ async function generateIntelligentNotifications() { } // Start server after all routes are registered -app.listen(PORT, '0.0.0.0', () => { +app.listen(PORT, '0.0.0.0', async () => { console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`); - // Run immediately on startup, then every 5 minutes + await loadGoogleCredsFromDb(); generateIntelligentNotifications(); setInterval(generateIntelligentNotifications, 5 * 60 * 1000); }); diff --git a/frontend/views/ConfiguracoesView.tsx b/frontend/views/ConfiguracoesView.tsx index 8ad5b7d..007e346 100644 --- a/frontend/views/ConfiguracoesView.tsx +++ b/frontend/views/ConfiguracoesView.tsx @@ -1,30 +1,73 @@ import React, { useState, useEffect } from 'react'; -import { User, Building2, ShieldCheck, LogOut } from 'lucide-react'; +import { User, Building2, ShieldCheck, LogOut, KeyRound, CheckCircle2, AlertCircle, Eye, EyeOff, Loader2 } from 'lucide-react'; import { HybridBackend } from '../services/backend.ts'; import { GoogleConnectButton } from '../components/GoogleConnectButton.tsx'; +import { useToast } from '../contexts/ToastContext.tsx'; export const ConfiguracoesView: React.FC = () => { const [connectedAccounts, setConnectedAccounts] = useState([]); + const [credsStatus, setCredsStatus] = useState<{ configured: boolean; clientIdHint: string | null; fromEnv: boolean } | null>(null); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [showSecret, setShowSecret] = useState(false); + const [savingCreds, setSavingCreds] = useState(false); + const currentRole = HybridBackend.getCurrentRole(); const userName = HybridBackend.getCurrentUserName(); const userEmail = HybridBackend.getCurrentUser(); const activeWorkspace = HybridBackend.getActiveWorkspace(); const clinColor = activeWorkspace?.cor || '#2563eb'; + const toast = useToast(); + + const authHeader = { 'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`, 'Content-Type': 'application/json' }; const fetchGoogleStatus = async () => { try { - const response = await fetch('/api/auth/google/status'); - const data = await response.json(); - setConnectedAccounts(data); - } catch (error) { - console.error('Erro ao buscar status do Google:', error); - } + const res = await fetch('/api/auth/google/status'); + setConnectedAccounts(await res.json()); + } catch {} + }; + + const fetchCredsStatus = async () => { + try { + const res = await fetch('/api/settings/google-credentials', { headers: authHeader }); + if (res.ok) setCredsStatus(await res.json()); + } catch {} }; useEffect(() => { fetchGoogleStatus(); + if (currentRole === 'admin') fetchCredsStatus(); }, []); + const handleSaveCreds = async () => { + if (!clientId.trim() || !clientSecret.trim()) { + toast.error('PREENCHA OS DOIS CAMPOS.'); + return; + } + setSavingCreds(true); + try { + const res = await fetch('/api/settings/google-credentials', { + method: 'POST', + headers: authHeader, + body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim() }) + }); + if (res.ok) { + toast.success('CREDENCIAIS SALVAS!'); + setClientId(''); + setClientSecret(''); + await fetchCredsStatus(); + } else { + const d = await res.json(); + toast.error(d.error || 'ERRO AO SALVAR.'); + } + } catch { + toast.error('FALHA NA CONEXÃO.'); + } finally { + setSavingCreds(false); + } + }; + const handleLogout = () => { if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) { HybridBackend.logout(); @@ -81,21 +124,111 @@ export const ConfiguracoesView: React.FC = () => {

ID: {activeWorkspace.id}

)} -
+
- {/* Integrações */} + {/* Integrações — só para admin */} {currentRole === 'admin' && (
-

Integrações

+

Integrações Google

+ + {/* Credenciais OAuth */} +
+
+
+ + Credenciais OAuth 2.0 +
+ {credsStatus?.configured ? ( +
+ + + {credsStatus.fromEnv ? 'Via ENV' : 'Configurado'} + +
+ ) : ( +
+ + Não configurado +
+ )} +
+ + {credsStatus?.configured && credsStatus.clientIdHint && !credsStatus.fromEnv && ( +
+

Client ID atual

+

{credsStatus.clientIdHint}

+
+ )} + + {credsStatus?.fromEnv && ( +

+ Credenciais carregadas via variável de ambiente. Para substituir, preencha os campos abaixo. +

+ )} + +
+
+ + setClientId(e.target.value)} + placeholder={credsStatus?.configured ? 'Deixe vazio para manter atual' : 'xxxxxxxxxxxx.apps.googleusercontent.com'} + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm font-mono focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all" + /> +
+
+ +
+ setClientSecret(e.target.value)} + placeholder={credsStatus?.configured ? 'Deixe vazio para manter atual' : 'GOCSPX-...'} + className="w-full border border-gray-200 rounded-xl px-4 py-2.5 pr-10 text-sm font-mono focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all" + /> + +
+
+ + +
+ +

+ Obtenha as credenciais em{' '} + console.cloud.google.com + {' '}→ APIs & Services → Credentials → OAuth 2.0 Client ID. + Adicione{' '} + https://scoreodonto.com/api/oauth2callback + {' '}como URI de redirecionamento autorizado. +

+
+ + {/* Conectar conta Google */}
+

Conta conectada

a.owner_id === 'admin' || a.owner_id === userEmail)}