feat(clinics): wipe cirúrgico por dispositivo — apaga banco + pasta Wasabi só da clínica; remove Zerar Sistema da sidebar
continuous-integration/webhook Deploy concluído (VPS4)
continuous-integration/webhook Deploy concluído (VPS4)
- DELETE /admin/clinics/:id/wipe: apaga images/gto_images do banco (WHERE client_name), lista e deleta em batch todos os objetos Wasabi com prefixo 'clinic_name/', e remove o registro do dispositivo - UI: botão 'Limpar dados' (🧹) separado do 🗑️ (remove só o cadastro), modal com aviso, confirmação e log da operação - Sidebar: remove link 'Zerar Sistema'
This commit is contained in:
@@ -90,14 +90,6 @@ export default function Sidebar({ version, onNewPatient, onUpload, onSettings, o
|
|||||||
<button className="nav-item" onClick={onPlugins}>
|
<button className="nav-item" onClick={onPlugins}>
|
||||||
<i className="fa-solid fa-plug" /> Plugins / Wasabi
|
<i className="fa-solid fa-plug" /> Plugins / Wasabi
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NavLink
|
|
||||||
to="/reset"
|
|
||||||
className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
|
|
||||||
style={{ color: '#dc2626' }}
|
|
||||||
>
|
|
||||||
<i className="fa-solid fa-triangle-exclamation" /> Zerar Sistema
|
|
||||||
</NavLink>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export default function AdminClinicsPage() {
|
|||||||
const [showPlugins, setShowPlugins] = useState(false);
|
const [showPlugins, setShowPlugins] = useState(false);
|
||||||
const [showSync, setShowSync] = useState(false);
|
const [showSync, setShowSync] = useState(false);
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [wipeClinic, setWipeClinic] = useState(null);
|
||||||
|
const [wiping, setWiping] = useState(false);
|
||||||
|
const [wipeLog, setWipeLog] = useState([]);
|
||||||
|
|
||||||
const loadClinics = async () => {
|
const loadClinics = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -61,7 +64,7 @@ export default function AdminClinicsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteClinic = async (id) => {
|
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 {
|
try {
|
||||||
await api.delete(`/admin/clinics/${id}`);
|
await api.delete(`/admin/clinics/${id}`);
|
||||||
showToast('Dispositivo removido', 'success');
|
showToast('Dispositivo removido', 'success');
|
||||||
@@ -69,6 +72,27 @@ export default function AdminClinicsPage() {
|
|||||||
} catch { showToast('Erro ao excluir', 'error'); }
|
} 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 total = clinics.length;
|
||||||
const active = clinics.filter((c) => c.is_active).length;
|
const active = clinics.filter((c) => c.is_active).length;
|
||||||
const blocked = total - active;
|
const blocked = total - active;
|
||||||
@@ -156,7 +180,13 @@ export default function AdminClinicsPage() {
|
|||||||
<input type="checkbox" checked={c.is_active} onChange={(e) => toggleStatus(c.id, e.target.checked)} />
|
<input type="checkbox" checked={c.is_active} onChange={(e) => toggleStatus(c.id, e.target.checked)} />
|
||||||
{c.is_active ? 'Ativo' : 'Bloqueado'}
|
{c.is_active ? 'Ativo' : 'Bloqueado'}
|
||||||
</label>
|
</label>
|
||||||
<button className="btn btn-danger btn-small" onClick={() => deleteClinic(c.id)}>🗑️</button>
|
<button className="btn btn-danger btn-small" onClick={() => deleteClinic(c.id)} title="Remover apenas o cadastro do dispositivo">🗑️</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-small"
|
||||||
|
style={{ background: '#7f1d1d', color: '#fff', borderColor: '#991b1b' }}
|
||||||
|
onClick={() => confirmWipe(c)}
|
||||||
|
title="Apagar TODOS os dados desta clínica (imagens, banco e Wasabi)"
|
||||||
|
>🧹 Limpar dados</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -193,6 +223,56 @@ export default function AdminClinicsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal de confirmação de wipe */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!wipeClinic}
|
||||||
|
onClose={() => { if (!wiping) { setWipeClinic(null); setWipeLog([]); } }}
|
||||||
|
title="🧹 Limpar Dados da Clínica"
|
||||||
|
large={false}
|
||||||
|
>
|
||||||
|
{wipeLog.length === 0 ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ background: '#fee2e2', border: '1px solid #f87171', borderRadius: 10, padding: 20, marginBottom: 20 }}>
|
||||||
|
<p style={{ color: '#7f1d1d', fontWeight: 700, marginBottom: 8 }}>⚠️ Ação irreversível</p>
|
||||||
|
<p style={{ color: '#991b1b', lineHeight: 1.7 }}>
|
||||||
|
Serão apagados <strong>permanentemente</strong>:<br />
|
||||||
|
• Todas as imagens de <strong>"{wipeClinic?.clinic_name}"</strong> no banco de dados<br />
|
||||||
|
• A pasta <code style={{ background: '#fecaca', padding: '0 4px', borderRadius: 3 }}>{wipeClinic?.clinic_name}/</code> inteira no Wasabi<br />
|
||||||
|
• O cadastro do dispositivo<br /><br />
|
||||||
|
Dados de <strong>outras clínicas não são afetados</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-secondary" onClick={() => { setWipeClinic(null); setWipeLog([]); }} disabled={wiping}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={handleWipe}
|
||||||
|
disabled={wiping}
|
||||||
|
style={{ background: '#7f1d1d' }}
|
||||||
|
>
|
||||||
|
{wiping ? '⏳ Apagando...' : '🧹 Confirmar e Apagar Tudo'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p style={{ marginBottom: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>Log da operação:</p>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 20px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{wipeLog.map((line, i) => (
|
||||||
|
<li key={i} style={{ background: '#f8f9fa', padding: '6px 12px', borderRadius: 6, fontSize: '0.9rem', fontFamily: 'monospace', color: line.startsWith('❌') ? '#dc2626' : '#166534' }}>
|
||||||
|
{line.startsWith('❌') ? line : `✓ ${line}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => { setWipeClinic(null); setWipeLog([]); }}>
|
||||||
|
Fechar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
<SettingsModal isOpen={showSettings} onClose={() => setShowSettings(false)} />
|
||||||
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
<UserManagementModal isOpen={showUsers} onClose={() => setShowUsers(false)} />
|
||||||
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
|
<PluginsModal isOpen={showPlugins} onClose={() => setShowPlugins(false)} />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ const router = express.Router();
|
|||||||
const db = require('../database');
|
const db = require('../database');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
|
const storage = require('../storage');
|
||||||
|
const { S3Client, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||||
|
|
||||||
// Middleware para verificar se é admin
|
// Middleware para verificar se é admin
|
||||||
const requireAdmin = (req, res, next) => {
|
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) => {
|
router.delete('/:id', requireAdmin, async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.run(`DELETE FROM clinics_devices WHERE id = $1`, [id]);
|
await db.run(`DELETE FROM clinics_devices WHERE id = $1`, [id]);
|
||||||
res.json({ success: true, message: 'Clínica excluída com sucesso' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user