Skip to content

Instantly share code, notes, and snippets.

@bangundwir
Created July 18, 2025 10:33
Show Gist options
  • Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
<!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, "&lt;").replace(/>/g, "&gt;"); }
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