Created
July 18, 2025 10:33
-
-
Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="id" class=""> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Bitwarden Password Analyzer (Final)</title> | |
<!-- Favicon dari Bitwarden --> | |
<link | |
rel="shortcut icon" | |
href="https://raw.githubusercontent.com/bitwarden/clients/main/apps/web/src/images/icons/favicon-32x32.png" | |
type="image/x-icon" | |
/> | |
<!-- Memuat Tailwind CSS --> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script> | |
tailwind.config = { | |
darkMode: 'class', | |
} | |
</script> | |
<style> | |
/* Animasi spinner untuk indikator loading */ | |
.spinner { | |
border: 4px solid rgba(255, 255, 255, 0.3); | |
border-radius: 50%; | |
border-top: 4px solid white; | |
width: 20px; | |
height: 20px; | |
animation: spin 1s linear infinite; | |
} | |
@keyframes spin { | |
0% { transform: rotate(0deg); } | |
100% { transform: rotate(360deg); } | |
} | |
/* Transisi halus untuk elemen */ | |
.transition-all { | |
transition: all 0.2s ease-in-out; | |
} | |
/* Menyembunyikan input file asli */ | |
.hidden-input { | |
display: none; | |
} | |
/* Styling untuk tombol filter yang aktif */ | |
.filter-btn-active { | |
background-color: #3B82F6; /* bg-blue-600 */ | |
color: white; | |
} | |
.dark .filter-btn-active { | |
background-color: #60A5FA; /* bg-blue-400 */ | |
color: #1F2937; /* dark:text-gray-900 */ | |
} | |
/* Tooltip styling */ | |
.tooltip { | |
position: relative; | |
display: inline-block; | |
} | |
.tooltip .tooltiptext { | |
visibility: hidden; | |
width: 220px; | |
background-color: #555; | |
color: #fff; | |
text-align: center; | |
border-radius: 6px; | |
padding: 5px 10px; | |
position: absolute; | |
z-index: 50; | |
bottom: 125%; | |
left: 50%; | |
margin-left: -110px; | |
opacity: 0; | |
transition: opacity 0.3s; | |
font-size: 12px; | |
} | |
.tooltip:hover .tooltiptext { | |
visibility: visible; | |
opacity: 1; | |
} | |
/* Password strength bar */ | |
.strength-bar-container { | |
width: 100px; | |
height: 8px; | |
background-color: #e0e0e0; | |
border-radius: 4px; | |
overflow: hidden; | |
} | |
.strength-bar { | |
height: 100%; | |
border-radius: 4px; | |
transition: width 0.5s ease-in-out, background-color 0.5s ease-in-out; | |
} | |
/* Progress Bar */ | |
#progress-container { | |
width: 100%; | |
background-color: #e0e0e0; | |
border-radius: 4px; | |
overflow: hidden; | |
height: 8px; | |
margin-top: 8px; | |
} | |
#progress-bar { | |
width: 0%; | |
height: 100%; | |
background-color: #4ade80; /* green-400 */ | |
transition: width 0.2s; | |
} | |
</style> | |
</head> | |
<body class="bg-gray-100 dark:bg-gray-900 dark:text-gray-200 font-sans text-gray-800 transition-all"> | |
<div class="container mx-auto max-w-6xl p-4 sm:p-6 md:p-8"> | |
<!-- Header dan Judul --> | |
<header class="text-center mb-8 relative"> | |
<h1 class="text-3xl sm:text-4xl font-bold text-blue-700 dark:text-blue-400">Bitwarden Password Analyzer</h1> | |
<p class="text-gray-600 dark:text-gray-400 mt-2">Alat analisis keamanan vault Bitwarden yang komprehensif.</p> | |
<div class="absolute top-0 right-0"> | |
<button id="theme-toggle" class="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"> | |
<svg id="theme-toggle-dark-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg> | |
<svg id="theme-toggle-light-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm-.707 10.607a1 1 0 010-1.414l.707-.707a1 1 0 111.414 1.414l-.707.707a1 1 0 01-1.414 0zM11 17a1 1 0 10-2 0v1a1 1 0 102 0v-1z"></path></svg> | |
</button> | |
</div> | |
</header> | |
<!-- Area Kontrol Utama --> | |
<div class="sticky top-0 bg-gray-100/80 dark:bg-gray-900/80 backdrop-blur-sm z-40 py-4 mb-6"> | |
<div class="flex flex-col sm:flex-row gap-4 items-center"> | |
<label for="fileInput" class="w-full sm:w-auto flex-grow cursor-pointer bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-3 text-center hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"> | |
<span id="fileLabel">1. Pilih File Ekspor Bitwarden (.json)</span> | |
<input type="file" id="fileInput" accept=".json" class="hidden-input" /> | |
</label> | |
<button id="downloadBtn" class="w-full sm:w-auto bg-green-600 text-white font-bold py-3 px-6 rounded-lg flex items-center justify-center gap-2 hover:bg-green-700 transition-all" style="display: none;"> | |
<div id="downloadSpinner" class="spinner" style="display: none;"></div> | |
<span id="downloadText">3. Unduh Ekspor</span> | |
</button> | |
</div> | |
<div id="progress-container" style="display: none;"><div id="progress-bar"></div></div> | |
<div id="notification" class="mt-4 text-center text-red-600 dark:text-red-400 font-medium h-6"></div> | |
</div> | |
<!-- Dasbor Analisis dan Kontrol Filter --> | |
<div id="analysis-dashboard" class="mb-8" style="display: none;"> | |
<h2 class="text-2xl font-bold text-gray-700 dark:text-gray-200 mb-4">Dasbor Analisis Keamanan</h2> | |
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 text-center"> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-3xl font-bold text-green-600 dark:text-green-400" id="health-score">0%</p><p class="text-sm text-gray-500 dark:text-gray-400">Skor Kesehatan</p><span class="tooltiptext">Persentase item unik. Semakin tinggi semakin baik!</span></div> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-2xl font-bold text-blue-600 dark:text-blue-400" id="total-items">0</p><p class="text-sm text-gray-500 dark:text-gray-400">Total Item</p><span class="tooltiptext">Jumlah total item login di vault Anda.</span></div> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-2xl font-bold text-red-600 dark:text-red-400" id="breached-passwords">0</p><p class="text-sm text-gray-500 dark:text-gray-400">Password Bocor</p><span class="tooltiptext">Password yang ditemukan dalam pelanggaran data (via HIBP). Risiko keamanan tertinggi!</span></div> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-2xl font-bold text-red-600 dark:text-red-400" id="reused-passwords">0</p><p class="text-sm text-gray-500 dark:text-gray-400">Digunakan Ulang</p><span class="tooltiptext">Jumlah password yang digunakan di lebih dari satu layanan. Ini adalah risiko keamanan.</span></div> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-2xl font-bold text-purple-600 dark:text-purple-400" id="weak-passwords">0</p><p class="text-sm text-gray-500 dark:text-gray-400">Password Lemah</p><span class="tooltiptext">Jumlah password dengan skor kekuatan rendah.</span></div> | |
<div class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow tooltip"><p class="text-2xl font-bold text-orange-600 dark:text-orange-400" id="duplicate-sets">0</p><p class="text-sm text-gray-500 dark:text-gray-400">Kredensial Duplikat</p><span class="tooltiptext">Jumlah grup item dengan username & password yang identik.</span></div> | |
</div> | |
<div class="mt-6 bg-white dark:bg-gray-800 p-4 rounded-lg shadow"> | |
<h3 class="text-lg font-semibold mb-3 text-gray-700 dark:text-gray-300">2. Laporan Keamanan & Aksi</h3> | |
<div class="flex flex-col md:flex-row gap-4 justify-between items-center"> | |
<!-- Kontrol Aksi Cepat --> | |
<div class="flex gap-2"> | |
<button id="bulk-delete-btn" class="bg-red-500 text-white text-sm font-bold py-2 px-4 rounded-md hover:bg-red-600 transition-all">Tandai Semua Duplikat</button> | |
<button id="reset-selection-btn" class="bg-gray-500 text-white text-sm font-bold py-2 px-4 rounded-md hover:bg-gray-600 transition-all">Reset Pilihan</button> | |
</div> | |
<!-- Kontrol Filter --> | |
<div id="filter-controls" class="flex flex-wrap gap-2 justify-center"> | |
<button class="filter-btn filter-btn-active" data-filter="duplicates">Duplikat</button> | |
<button class="filter-btn" data-filter="reused">Digunakan Ulang</button> | |
<button class="filter-btn" data-filter="breached">Bocor (HIBP)</button> | |
<button class="filter-btn" data-filter="weak">Lemah</button> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Area Hasil --> | |
<main id="results" class="space-y-6"> | |
<div id="initialMessage" class="text-center py-12 px-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm"> | |
<h2 class="text-2xl font-semibold mb-2">Selamat Datang!</h2> | |
<p class="text-gray-600 dark:text-gray-400">Mulai dengan memilih file ekspor JSON dari Bitwarden Anda.</p> | |
</div> | |
</main> | |
</div> | |
<script> | |
// --- State Management --- | |
let originalData = null; | |
let originalFileName = ""; | |
let deletedItems = new Set(); | |
let currentFilter = 'duplicates'; // 'duplicates', 'reused', 'weak', 'breached' | |
let domainGroupsCache = {}; | |
let reusedPasswordMap = new Map(); | |
let weakPasswordItems = []; | |
let breachedPasswordMap = new Map(); | |
// --- DOM Element References --- | |
const fileInput = document.getElementById("fileInput"); | |
const fileLabel = document.getElementById("fileLabel"); | |
const downloadBtn = document.getElementById("downloadBtn"); | |
const downloadSpinner = document.getElementById("downloadSpinner"); | |
const downloadText = document.getElementById("downloadText"); | |
const resultsDiv = document.getElementById("results"); | |
const initialMessage = document.getElementById("initialMessage"); | |
const notificationDiv = document.getElementById("notification"); | |
const analysisDashboard = document.getElementById('analysis-dashboard'); | |
const filterControls = document.getElementById('filter-controls'); | |
const progressContainer = document.getElementById('progress-container'); | |
const progressBar = document.getElementById('progress-bar'); | |
// --- Utility Functions --- | |
function sanitizeHTML(str) { if (typeof str !== 'string') return ''; return str.replace(/</g, "<").replace(/>/g, ">"); } | |
function showNotification(message, isError = false, duration = 3000) { | |
notificationDiv.textContent = message; | |
notificationDiv.className = isError ? 'mt-4 text-center text-red-600 dark:text-red-400 font-medium h-6' : 'mt-4 text-center text-blue-600 dark:text-blue-400 font-medium h-6'; | |
if (message && duration > 0) { | |
setTimeout(() => { notificationDiv.textContent = ''; }, duration); | |
} | |
} | |
function extractDomain(url) { | |
try { | |
const { hostname } = new URL(url); | |
if (hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/)) return hostname; | |
const parts = hostname.split("."); | |
if (parts.length > 2) { | |
const sld = parts.slice(-2).join("."); | |
if (sld.match(/(com|co|org|gov|edu|net|ac)\.\w{2}/)) return parts.slice(-3).join("."); | |
return sld; | |
} | |
return hostname; | |
} catch (error) { return url; } | |
} | |
// --- Core Application Logic --- | |
async function analyzeData(data) { | |
showNotification('Menganalisis data...', false, 0); | |
progressContainer.style.display = 'block'; | |
progressBar.style.width = '10%'; | |
await new Promise(r => setTimeout(r, 50)); | |
groupPasswords(data); | |
progressBar.style.width = '30%'; | |
await new Promise(r => setTimeout(r, 50)); | |
analyzePasswordReuse(data); | |
progressBar.style.width = '50%'; | |
await new Promise(r => setTimeout(r, 50)); | |
analyzeWeakPasswords(data); | |
progressBar.style.width = '70%'; | |
await new Promise(r => setTimeout(r, 50)); | |
await analyzeBreachedPasswords(data); | |
progressBar.style.width = '100%'; | |
showNotification('Analisis selesai!', false); | |
setTimeout(() => { progressContainer.style.display = 'none'; progressBar.style.width = '0%'; }, 1000); | |
} | |
function groupPasswords(data) { | |
const domainGroups = {}; | |
if (!data || !Array.isArray(data.items)) return; | |
data.items.forEach((item) => { | |
if (item.type === 1 && item.login?.uris?.length > 0) { | |
const domains = new Set(item.login.uris.map((uri) => extractDomain(uri.uri))); | |
domains.forEach((domain) => { | |
if (!domainGroups[domain]) domainGroups[domain] = { allItems: [], credentialGroups: {} }; | |
domainGroups[domain].allItems.push(item); | |
const credentialKey = `${item.login.username || ''}::${item.login.password || ''}`; | |
if (!domainGroups[domain].credentialGroups[credentialKey]) domainGroups[domain].credentialGroups[credentialKey] = []; | |
domainGroups[domain].credentialGroups[credentialKey].push(item); | |
}); | |
} | |
}); | |
domainGroupsCache = domainGroups; | |
} | |
function analyzePasswordReuse(data) { | |
const passwordMap = new Map(); | |
reusedPasswordMap.clear(); | |
if (!data || !Array.isArray(data.items)) return; | |
data.items.forEach(item => { | |
if (item.type === 1 && item.login?.password) { | |
const password = item.login.password; | |
if (!passwordMap.has(password)) { | |
passwordMap.set(password, []); | |
} | |
passwordMap.get(password).push(item); | |
} | |
}); | |
for (const [password, items] of passwordMap.entries()) { | |
const uniqueServices = new Set(items.map(item => item.login.uris[0] ? extractDomain(item.login.uris[0].uri) : item.name)); | |
if (uniqueServices.size > 1) { | |
reusedPasswordMap.set(password, items); | |
} | |
} | |
} | |
function analyzeWeakPasswords(data) { | |
weakPasswordItems = []; | |
if (!data || !Array.isArray(data.items)) return; | |
weakPasswordItems = data.items.filter(item => item.type === 1 && item.login?.password && calculatePasswordStrength(item.login.password).score < 50); | |
} | |
async function analyzeBreachedPasswords(data) { | |
breachedPasswordMap.clear(); | |
if (!data || !Array.isArray(data.items)) return; | |
const passwordToItemsMap = new Map(); | |
data.items.forEach(item => { | |
if (item.type === 1 && item.login?.password) { | |
if (!passwordToItemsMap.has(item.login.password)) { | |
passwordToItemsMap.set(item.login.password, []); | |
} | |
passwordToItemsMap.get(item.login.password).push(item); | |
} | |
}); | |
const uniquePasswords = Array.from(passwordToItemsMap.keys()); | |
for (const password of uniquePasswords) { | |
try { | |
const sha1Hash = await sha1(password); | |
const prefix = sha1Hash.substring(0, 5); | |
const suffix = sha1Hash.substring(5).toUpperCase(); | |
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); | |
if (response.status !== 200) continue; | |
const text = await response.text(); | |
const lines = text.split('\n'); | |
for (const line of lines) { | |
const [hashSuffix, count] = line.split(':'); | |
if (hashSuffix === suffix) { | |
breachedPasswordMap.set(password, { count: parseInt(count), items: passwordToItemsMap.get(password) }); | |
break; | |
} | |
} | |
} catch (e) { | |
console.error("Error checking HIBP:", e); | |
} | |
} | |
} | |
async function sha1(str) { | |
const buffer = new TextEncoder("utf-8").encode(str); | |
const digest = await crypto.subtle.digest("SHA-1", buffer); | |
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); | |
} | |
function calculatePasswordStrength(password) { | |
let score = 0; | |
if (!password) return { score: 0, label: 'Kosong', color: 'bg-gray-400' }; | |
// Length score | |
score += Math.min(password.length * 4, 40); | |
// Character type score | |
if (/[a-z]/.test(password)) score += 5; | |
if (/[A-Z]/.test(password)) score += 5; | |
if (/[0-9]/.test(password)) score += 5; | |
if (/[^a-zA-Z0-9]/.test(password)) score += 10; | |
// Bonus for mixed types | |
const types = (/[a-z]/.test(password) ? 1 : 0) + | |
(/[A-Z]/.test(password) ? 1 : 0) + | |
(/[0-9]/.test(password) ? 1 : 0) + | |
(/[^a-zA-Z0-9]/.test(password) ? 1 : 0); | |
if (types > 2) score += types * 5; | |
score = Math.min(100, score); | |
if (score < 30) return { score, label: 'Sangat Lemah', color: 'bg-red-500' }; | |
if (score < 50) return { score, label: 'Lemah', color: 'bg-orange-500' }; | |
if (score < 75) return { score, label: 'Sedang', color: 'bg-yellow-500' }; | |
return { score, label: 'Kuat', color: 'bg-green-500' }; | |
} | |
function updateDashboard() { | |
const totalItems = originalData?.items?.filter(i => i.type === 1).length || 0; | |
const duplicateSetsCount = Object.values(domainGroupsCache).reduce((acc, g) => acc + Object.values(g.credentialGroups).filter(s => s.length > 1).length, 0); | |
const totalDuplicateItems = Object.values(domainGroupsCache).reduce((acc, g) => { | |
const duplicateItemsInGroup = Object.values(g.credentialGroups).filter(s => s.length > 1).reduce((itemAcc, set) => itemAcc + set.length - 1, 0); | |
return acc + duplicateItemsInGroup; | |
}, 0); | |
const healthScore = totalItems > 0 ? Math.round(((totalItems - totalDuplicateItems) / totalItems) * 100) : 100; | |
document.getElementById('health-score').textContent = `${healthScore}%`; | |
document.getElementById('total-items').textContent = totalItems; | |
document.getElementById('breached-passwords').textContent = breachedPasswordMap.size; | |
document.getElementById('reused-passwords').textContent = reusedPasswordMap.size; | |
document.getElementById('weak-passwords').textContent = weakPasswordItems.length; | |
document.getElementById('duplicate-sets').textContent = duplicateSetsCount; | |
document.getElementById('items-to-delete').textContent = deletedItems.size; | |
} | |
function displayResults() { | |
resultsDiv.innerHTML = ""; | |
updateDashboard(); | |
if (currentFilter === 'duplicates') displayDuplicatesReport(); | |
else if (currentFilter === 'reused') displayReusedReport(); | |
else if (currentFilter === 'weak') displayWeakReport(); | |
else if (currentFilter === 'breached') displayBreachedReport(); | |
updateDownloadButtonState(); | |
} | |
function createCollapsibleSection(title, content) { | |
return `<details class="bg-white dark:bg-gray-800 shadow-md rounded-lg overflow-hidden"> | |
<summary class="p-4 cursor-pointer font-semibold text-lg text-gray-800 dark:text-gray-200">${title}</summary> | |
<div class="p-4 border-t border-gray-200 dark:border-gray-700">${content}</div> | |
</details>`; | |
} | |
function displayDuplicatesReport() { | |
let domainsToDisplay = Object.entries(domainGroupsCache).map(([domain, groups]) => ({ domain, duplicateSets: Object.values(groups.credentialGroups).filter(set => set.length > 1) })).filter(d => d.duplicateSets.length > 0); | |
if (domainsToDisplay.length === 0) { | |
resultsDiv.innerHTML = `<div class="text-center text-lg bg-white dark:bg-gray-800 p-8 rounded-lg shadow"><h3 class="text-2xl font-bold text-green-600 dark:text-green-400">Luar Biasa!</h3><p>Tidak ada kredensial duplikat yang ditemukan.</p></div>`; | |
return; | |
} | |
domainsToDisplay.sort((a, b) => b.duplicateSets.length - a.duplicateSets.length); | |
let finalHtml = ''; | |
domainsToDisplay.forEach(({ domain, duplicateSets }) => { | |
let contentHtml = ''; | |
duplicateSets.forEach((set) => { | |
const username = set[0].login.username; | |
const itemIds = set.map(item => item.id).join(','); | |
contentHtml += `<div class="border border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4 mb-4"> | |
<div class="flex flex-col sm:flex-row justify-between sm:items-center mb-2"> | |
<div><h3 class="font-semibold text-orange-800 dark:text-orange-300">Kredensial Duplikat (${set.length} item)</h3><p class="text-sm text-orange-700 dark:text-orange-400">Username: ${sanitizeHTML(username) || '<i>Kosong</i>'}</p></div> | |
<button class="keep-one-btn mt-2 sm:mt-0 bg-blue-500 text-white text-xs font-bold py-2 px-3 rounded-md hover:bg-blue-600 transition-all" data-ids="${itemIds}">Sisakan 1 Terbaru</button> | |
</div><ul class="space-y-2">${renderItems(set)}</ul></div>`; | |
}); | |
finalHtml += createCollapsibleSection(sanitizeHTML(domain) || "Tanpa Domain", contentHtml); | |
}); | |
resultsDiv.innerHTML = finalHtml; | |
} | |
function displayReusedReport() { | |
if (reusedPasswordMap.size === 0) { | |
resultsDiv.innerHTML = `<div class="text-center text-lg bg-white dark:bg-gray-800 p-8 rounded-lg shadow"><h3 class="text-2xl font-bold text-green-600 dark:text-green-400">Luar Biasa!</h3><p>Tidak ada kata sandi yang digunakan ulang.</p></div>`; | |
return; | |
} | |
const sortedReused = [...reusedPasswordMap.entries()].sort((a, b) => b[1].length - a[1].length); | |
let finalHtml = ''; | |
sortedReused.forEach(([password, items]) => { | |
const title = `Password digunakan ${items.length} kali`; | |
const contentHtml = `<ul class="space-y-2">${renderItems(items, false)}</ul>`; | |
finalHtml += createCollapsibleSection(title, contentHtml); | |
}); | |
resultsDiv.innerHTML = finalHtml; | |
} | |
function displayBreachedReport() { | |
if (breachedPasswordMap.size === 0) { | |
resultsDiv.innerHTML = `<div class="text-center text-lg bg-white dark:bg-gray-800 p-8 rounded-lg shadow"><h3 class="text-2xl font-bold text-green-600 dark:text-green-400">Luar Biasa!</h3><p>Tidak ada kata sandi Anda yang ditemukan dalam pelanggaran data yang diketahui.</p></div>`; | |
return; | |
} | |
const sortedBreached = [...breachedPasswordMap.entries()].sort((a, b) => b[1].count - a[1].count); | |
let finalHtml = ''; | |
sortedBreached.forEach(([password, { count, items }]) => { | |
const title = `Password Bocor! Terlihat <span class="text-red-500 font-bold">${count.toLocaleString()}</span> kali dalam pelanggaran.`; | |
const contentHtml = `<ul class="space-y-2">${renderItems(items, false)}</ul>`; | |
finalHtml += createCollapsibleSection(title, contentHtml); | |
}); | |
resultsDiv.innerHTML = finalHtml; | |
} | |
function displayWeakReport() { | |
if (weakPasswordItems.length === 0) { | |
resultsDiv.innerHTML = `<div class="text-center text-lg bg-white dark:bg-gray-800 p-8 rounded-lg shadow"><h3 class="text-2xl font-bold text-green-600 dark:text-green-400">Luar Biasa!</h3><p>Tidak ada kata sandi lemah yang ditemukan.</p></div>`; | |
return; | |
} | |
const title = `Ditemukan ${weakPasswordItems.length} Kata Sandi Lemah`; | |
const contentHtml = `<ul class="space-y-2 mt-2">${renderItems(weakPasswordItems, true)}</ul>`; | |
resultsDiv.innerHTML = createCollapsibleSection(title, contentHtml); | |
} | |
function renderItems(items, showReuseWarning = true) { | |
return items.sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)).map(item => { | |
const isDeleted = deletedItems.has(item.id); | |
const password = item.login.password || ''; | |
const strength = calculatePasswordStrength(password); | |
const isReused = showReuseWarning && reusedPasswordMap.has(password); | |
const isBreached = breachedPasswordMap.has(password); | |
let warningsHtml = ''; | |
if (isBreached) { | |
warningsHtml += `<div class="tooltip inline-block ml-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-shield-exclamation text-red-700 dark:text-red-500" viewBox="0 0 16 16"><path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.06.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.923-.38-1.87-.7-2.837-.855A1.117 1.117 0 0 0 8.5 1.5a1.117 1.117 0 0 0-.832.09zM8 4c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995A.905.905 0 0 1 8 4zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> | |
<span class="tooltiptext">Password ini ditemukan dalam pelanggaran data! Segera ganti!</span> | |
</div>`; | |
} else if (isReused) { | |
warningsHtml += `<div class="tooltip inline-block ml-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill text-orange-500" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> | |
<span class="tooltiptext">Password ini digunakan di ${reusedPasswordMap.get(password).length} layanan berbeda.</span> | |
</div>`; | |
} | |
return `<li class="flex flex-col sm:flex-row sm:items-start sm:justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0 ${isDeleted ? "opacity-40" : ""}"> | |
<div class="flex-grow mb-3 sm:mb-0 pr-4"> | |
<p class="font-semibold">${sanitizeHTML(item.name)}</p> | |
<p class="text-sm text-gray-600 dark:text-gray-400">${sanitizeHTML(item.login.username) || '<i>Username kosong</i>'}</p> | |
<div class="flex items-center gap-2 mt-1"> | |
<p class="text-sm">Password:</p> | |
<span class="font-mono bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-sm" data-password="${sanitizeHTML(password)}">********</span> | |
<button class="toggle-password-btn text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"> | |
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg> | |
</button> | |
${warningsHtml} | |
</div> | |
<div class="flex items-center gap-2 mt-2"> | |
<p class="text-sm">Kekuatan:</p> | |
<div class="strength-bar-container"><div class="strength-bar ${strength.color}" style="width: ${strength.score}%"></div></div> | |
<span class="text-xs font-semibold">${strength.label}</span> | |
</div> | |
</div> | |
<button class="delete-btn w-full sm:w-auto text-sm font-bold py-2 px-4 rounded-md transition-all self-center ${isDeleted ? 'bg-yellow-400 hover:bg-yellow-500 text-gray-800 dark:text-gray-900' : 'bg-red-500 hover:bg-red-600 text-white'}" data-id="${sanitizeHTML(item.id)}">${isDeleted ? "Urungkan" : "Hapus"}</button> | |
</li>`; | |
}).join(""); | |
} | |
function updateDownloadButtonState() { | |
const hasUnsavedChanges = deletedItems.size > 0; | |
downloadBtn.disabled = !hasUnsavedChanges; | |
downloadBtn.classList.toggle('opacity-50', !hasUnsavedChanges); | |
downloadBtn.classList.toggle('cursor-not-allowed', !hasUnsavedChanges); | |
} | |
// --- Event Handlers --- | |
async function handleFileSelect(event) { | |
const file = event.target.files[0]; | |
if (!file) return; | |
originalData = null; deletedItems.clear(); showNotification(""); | |
originalFileName = file.name; | |
fileLabel.textContent = `Memuat: ${file.name}`; | |
if(initialMessage) initialMessage.style.display = 'none'; | |
analysisDashboard.style.display = 'block'; | |
const reader = new FileReader(); | |
reader.onload = async (e) => { | |
try { | |
originalData = JSON.parse(e.target.result); | |
if (originalData.encrypted) throw new Error("File terenkripsi tidak didukung."); | |
await analyzeData(originalData); | |
displayResults(); | |
downloadBtn.style.display = "flex"; | |
fileLabel.textContent = `File: ${file.name}`; | |
} catch (error) { | |
showNotification(`Error: ${error.message}`, true); | |
fileLabel.textContent = "1. Pilih File Ekspor Bitwarden (.json)"; | |
downloadBtn.style.display = "none"; | |
analysisDashboard.style.display = 'none'; | |
} | |
}; | |
reader.readAsText(file); | |
} | |
function handleResultsClick(event) { | |
const target = event.target; | |
const deleteBtn = target.closest('.delete-btn'); | |
const toggleBtn = target.closest('.toggle-password-btn'); | |
const keepOneBtn = target.closest('.keep-one-btn'); | |
if (deleteBtn) { | |
const id = deleteBtn.dataset.id; | |
if (deletedItems.has(id)) deletedItems.delete(id); else deletedItems.add(id); | |
} else if (toggleBtn) { | |
const passwordSpan = toggleBtn.closest('div').querySelector('[data-password]'); | |
const isHidden = passwordSpan.textContent === '********'; | |
passwordSpan.textContent = isHidden ? passwordSpan.dataset.password : '********'; | |
} else if (keepOneBtn) { | |
const ids = keepOneBtn.dataset.ids.split(','); | |
const itemsInSet = originalData.items.filter(item => ids.includes(item.id)); | |
itemsInSet.sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
itemsInSet.slice(1).forEach(item => deletedItems.add(item.id)); | |
} | |
if (deleteBtn || keepOneBtn) displayResults(); | |
} | |
function handleFilterClick(event) { | |
const target = event.target.closest('.filter-btn'); | |
if (!target || target.classList.contains('filter-btn-active')) return; | |
currentFilter = target.dataset.filter; | |
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('filter-btn-active')); | |
target.classList.add('filter-btn-active'); | |
displayResults(); | |
} | |
async function downloadUpdatedJSON() { | |
if (!originalData || deletedItems.size === 0) return; | |
downloadBtn.disabled = true; downloadSpinner.style.display = "inline-block"; downloadText.textContent = "Menyiapkan..."; | |
await new Promise(resolve => setTimeout(resolve, 50)); | |
const updatedData = { ...originalData, items: originalData.items.filter(item => !deletedItems.has(item.id)) }; | |
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(updatedData, null, 2)); | |
const downloadAnchorNode = document.createElement("a"); | |
downloadAnchorNode.setAttribute("href", dataStr); | |
downloadAnchorNode.setAttribute("download", `${originalFileName.replace(/\.json$/, "")}_dibersihkan.json`); | |
document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); | |
showNotification(`${deletedItems.size} item telah dihapus. File baru telah diunduh!`, false); | |
originalData.items = updatedData.items; | |
deletedItems.clear(); | |
await analyzeData(originalData); | |
displayResults(); | |
downloadSpinner.style.display = "none"; downloadText.textContent = "3. Unduh Ekspor"; | |
} | |
function handleBulkDelete() { | |
if(!originalData) return; | |
let itemsMarked = 0; | |
Object.values(domainGroupsCache).forEach(group => { | |
const duplicateSets = Object.values(group.credentialGroups).filter(set => set.length > 1); | |
duplicateSets.forEach(set => { | |
set.sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
set.slice(1).forEach(item => { | |
if (!deletedItems.has(item.id)) { | |
deletedItems.add(item.id); | |
itemsMarked++; | |
} | |
}); | |
}); | |
}); | |
showNotification(`${itemsMarked} item duplikat ditandai untuk dihapus.`, false); | |
displayResults(); | |
} | |
function handleResetSelection() { | |
const count = deletedItems.size; | |
if (count > 0) { | |
deletedItems.clear(); | |
showNotification(`${count} pilihan item telah di-reset.`, false); | |
displayResults(); | |
} | |
} | |
// --- Theme Toggle --- | |
const themeToggleBtn = document.getElementById('theme-toggle'); | |
const darkIcon = document.getElementById('theme-toggle-dark-icon'); | |
const lightIcon = document.getElementById('theme-toggle-light-icon'); | |
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | |
lightIcon.classList.remove('hidden'); | |
document.documentElement.classList.add('dark'); | |
} else { | |
darkIcon.classList.remove('hidden'); | |
document.documentElement.classList.remove('dark'); | |
} | |
themeToggleBtn.addEventListener('click', function() { | |
darkIcon.classList.toggle('hidden'); | |
lightIcon.classList.toggle('hidden'); | |
if (localStorage.getItem('color-theme')) { | |
if (localStorage.getItem('color-theme') === 'light') { | |
document.documentElement.classList.add('dark'); | |
localStorage.setItem('color-theme', 'dark'); | |
} else { | |
document.documentElement.classList.remove('dark'); | |
localStorage.setItem('color-theme', 'light'); | |
} | |
} else { | |
if (document.documentElement.classList.contains('dark')) { | |
document.documentElement.classList.remove('dark'); | |
localStorage.setItem('color-theme', 'light'); | |
} else { | |
document.documentElement.classList.add('dark'); | |
localStorage.setItem('color-theme', 'dark'); | |
} | |
} | |
}); | |
// --- Event Listeners Initialization --- | |
fileInput.addEventListener("change", handleFileSelect); | |
resultsDiv.addEventListener("click", handleResultsClick); | |
filterControls.addEventListener("click", handleFilterClick); | |
downloadBtn.addEventListener("click", downloadUpdatedJSON); | |
document.getElementById('bulk-delete-btn').addEventListener('click', handleBulkDelete); | |
document.getElementById('reset-selection-btn').addEventListener('click', handleResetSelection); | |
window.addEventListener("beforeunload", (e) => { | |
if (deletedItems.size > 0) { | |
e.preventDefault(); e.returnValue = ""; return ""; | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment