diff --git a/dental-server/dental-client/src/components/Sidebar.jsx b/dental-server/dental-client/src/components/Sidebar.jsx index 61e1529..9de2503 100644 --- a/dental-server/dental-client/src/components/Sidebar.jsx +++ b/dental-server/dental-client/src/components/Sidebar.jsx @@ -90,14 +90,6 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o - - `nav-item${isActive ? ' active' : ''}`} - style={{ color: '#dc2626' }} - > - Zerar Sistema - )} diff --git a/dental-server/dental-client/src/pages/AdminClinicsPage.jsx b/dental-server/dental-client/src/pages/AdminClinicsPage.jsx index 4c74748..b70f7af 100644 --- a/dental-server/dental-client/src/pages/AdminClinicsPage.jsx +++ b/dental-server/dental-client/src/pages/AdminClinicsPage.jsx @@ -23,6 +23,9 @@ export default function AdminClinicsPage() { const [showPlugins, setShowPlugins] = useState(false); const [showSync, setShowSync] = useState(false); const [showCreate, setShowCreate] = useState(false); + const [wipeClinic, setWipeClinic] = useState(null); + const [wiping, setWiping] = useState(false); + const [wipeLog, setWipeLog] = useState([]); const loadClinics = async () => { setLoading(true); @@ -61,7 +64,7 @@ export default function AdminClinicsPage() { }; const deleteClinic = async (id) => { - if (!confirm('Tem certeza que deseja excluir este dispositivo permanentemente? O Desktop perderá acesso instantaneamente.')) return; + if (!confirm('Excluir apenas o cadastro do dispositivo? As imagens e pacientes serão mantidos.')) return; try { await api.delete(`/admin/clinics/${id}`); showToast('Dispositivo removido', 'success'); @@ -69,6 +72,27 @@ export default function AdminClinicsPage() { } catch { showToast('Erro ao excluir', 'error'); } }; + const confirmWipe = (clinic) => setWipeClinic(clinic); + + const handleWipe = async () => { + if (!wipeClinic) return; + setWiping(true); + setWipeLog([]); + try { + const { data } = await api.delete(`/admin/clinics/${wipeClinic.id}/wipe`); + setWipeLog(data.log || []); + showToast(`Dados de "${wipeClinic.clinic_name}" apagados com sucesso.`, 'success'); + loadClinics(); + } catch (e) { + const log = e.response?.data?.log || []; + setWipeLog([...log, `❌ Erro: ${e.response?.data?.message || e.message}`]); + showToast('Erro durante o wipe. Veja o log.', 'error'); + } finally { + setWiping(false); + // mantém o modal aberto para mostrar o log; usuário fecha manualmente + } + }; + const total = clinics.length; const active = clinics.filter((c) => c.is_active).length; const blocked = total - active; @@ -156,7 +180,13 @@ export default function AdminClinicsPage() { toggleStatus(c.id, e.target.checked)} /> {c.is_active ? 'Ativo' : 'Bloqueado'} - + + @@ -193,6 +223,56 @@ export default function AdminClinicsPage() { + {/* Modal de confirmação de wipe */} + { if (!wiping) { setWipeClinic(null); setWipeLog([]); } }} + title="🧹 Limpar Dados da Clínica" + large={false} + > + {wipeLog.length === 0 ? ( +
+
+

⚠️ Ação irreversível

+

+ Serão apagados permanentemente:
+ • Todas as imagens de "{wipeClinic?.clinic_name}" no banco de dados
+ • A pasta {wipeClinic?.clinic_name}/ inteira no Wasabi
+ • O cadastro do dispositivo

+ Dados de outras clínicas não são afetados. +

+
+
+ + +
+
+ ) : ( +
+

Log da operação:

+
    + {wipeLog.map((line, i) => ( +
  • + {line.startsWith('❌') ? line : `✓ ${line}`} +
  • + ))} +
+ +
+ )} +
+ setShowSettings(false)} /> setShowUsers(false)} /> setShowPlugins(false)} /> diff --git a/dental-server/routes/admin-clinics.js b/dental-server/routes/admin-clinics.js index 78a9ebc..c286fda 100644 --- a/dental-server/routes/admin-clinics.js +++ b/dental-server/routes/admin-clinics.js @@ -3,6 +3,8 @@ const router = express.Router(); const db = require('../database'); const crypto = require('crypto'); const bcrypt = require('bcrypt'); +const storage = require('../storage'); +const { S3Client, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3'); // Middleware para verificar se é admin const requireAdmin = (req, res, next) => { @@ -82,11 +84,10 @@ router.put('/:id/status', requireAdmin, async (req, res) => { }); // ============================================================== -// 4. EXCLUIR CLÍNICA (ADMIN) +// 4. EXCLUIR CLÍNICA (ADMIN) — apenas o registro do dispositivo // ============================================================== router.delete('/:id', requireAdmin, async (req, res) => { const { id } = req.params; - try { await db.run(`DELETE FROM clinics_devices WHERE id = $1`, [id]); res.json({ success: true, message: 'Clínica excluída com sucesso' }); @@ -95,4 +96,92 @@ router.delete('/:id', requireAdmin, async (req, res) => { } }); +// ============================================================== +// 5. WIPE DE DADOS DA CLÍNICA (ADMIN) +// Apaga: registro do dispositivo + todas as imagens/GTOs do +// banco + pasta inteira da clínica no bucket Wasabi. +// CUIDADO: usa client_name como prefixo — só afeta essa clínica. +// ============================================================== +router.delete('/:id/wipe', requireAdmin, async (req, res) => { + const { id } = req.params; + const log = []; + + try { + // 1. Busca o dispositivo para obter o clinic_name (=client_name nas imagens) + const clinic = await db.get('SELECT * FROM clinics_devices WHERE id = $1', [id]); + if (!clinic) return res.status(404).json({ success: false, message: 'Clínica não encontrada' }); + + const clientName = clinic.clinic_name; + log.push(`Clínica: "${clientName}"`); + + // 2. Apaga imagens do banco (e GTOs vinculadas via CASCADE) + const images = await db.all('SELECT id FROM images WHERE client_name = $1', [clientName]); + if (images.length > 0) { + const ids = images.map(r => r.id); + // gto_images tem ON DELETE CASCADE, mas garantimos: + await db.run(`DELETE FROM gto_images WHERE image_id = ANY($1::int[])`, [ids]); + await db.run('DELETE FROM images WHERE client_name = $1', [clientName]); + log.push(`Banco: ${images.length} imagem(ns) apagada(s)`); + } else { + log.push('Banco: nenhuma imagem encontrada para esta clínica'); + } + + // 3. Apaga pasta inteira no Wasabi (prefixo = sanitize(clinic_name)/) + const wasabiStatus = storage.getWasabiStatus(); + if (wasabiStatus.enabled) { + // Reutiliza a config do storage carregada em memória via internal accessor + const cfg = JSON.parse( + (await db.get("SELECT value FROM system_config WHERE key='wasabi_config'")).value + ); + const ep = cfg.wasabiEndpoint.startsWith('http') + ? cfg.wasabiEndpoint : `https://${cfg.wasabiEndpoint}`; + const s3 = new S3Client({ + region: cfg.wasabiRegion, + endpoint: ep, + credentials: { accessKeyId: cfg.wasabiAccessKey, secretAccessKey: cfg.wasabiSecretKey }, + forcePathStyle: true, + }); + + // sanitize igual ao storage.js + const cleanClient = clientName.trim().replace(/[\/\\?#%*:"<>|]/g, '_'); + const prefix = `${cleanClient}/`; + let totalDeleted = 0; + let continuationToken; + + do { + const listed = await s3.send(new ListObjectsV2Command({ + Bucket: cfg.wasabiBucket, + Prefix: prefix, + MaxKeys: 1000, + ContinuationToken: continuationToken, + })); + + const objects = (listed.Contents || []).map(o => ({ Key: o.Key })); + if (objects.length > 0) { + await s3.send(new DeleteObjectsCommand({ + Bucket: cfg.wasabiBucket, + Delete: { Objects: objects, Quiet: true }, + })); + totalDeleted += objects.length; + } + + continuationToken = listed.IsTruncated ? listed.NextContinuationToken : null; + } while (continuationToken); + + log.push(`Wasabi: ${totalDeleted} objeto(s) apagado(s) com prefixo "${prefix}"`); + } else { + log.push('Wasabi: desativado, pulado'); + } + + // 4. Apaga o registro do dispositivo + await db.run('DELETE FROM clinics_devices WHERE id = $1', [id]); + log.push('Dispositivo removido do sistema'); + + res.json({ success: true, message: 'Dados da clínica apagados com sucesso', log }); + } catch (error) { + console.error('Erro no wipe da clínica:', error); + res.status(500).json({ success: false, message: 'Erro interno no wipe', detail: error.message, log }); + } +}); + module.exports = router;