BREAKING CHANGE: Complete refactor of architecture to prepare BJORN V2 release, APIs, assets, and UI, webapp, logics, attacks, a lot of new features...

This commit is contained in:
Fabien POLLY
2025-12-10 16:01:03 +01:00
parent a748f523a9
commit c1729756c0
927 changed files with 110752 additions and 9751 deletions

View File

@@ -1,53 +1,726 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bjorn Cyberviking - Credentials</title>
<link rel="icon" href="web/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="web/css/styles.css">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="images/apple-touch-icon.png">
<script src="web/scripts/credentials.js" defer></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Bjorn Cyberviking - Credentials</title>
<link rel="icon" href="web/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="web/css/global.css">
<link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" sizes="192x192" href="/web/images/icon-192x192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#333">
<script src="web/js/global.js"></script>
<style>
/* ========= Styles alignés sur global.css (pas de nouvelle palette) ========= */
:root{
/* aucun override agressif : seulement des fallbacks doux */
--_bg: var(--bg, #0b0c0f);
--_panel: var(--c-panel-2, rgba(16,22,22,.55));
--_border: var(--c-border, rgba(255,255,255,.08));
--_ink: var(--ink, #e9ecef);
--_muted: var(--muted, #a5adb6);
--_acid1: var(--acid, #00ff9a);
--_acid2: var(--acid-2, #18f0ff);
--_shadow: var(--shadow, 0 10px 26px rgba(0,0,0,.35));
}
/* fond + typographie harmonisés */
body{
background: var(--_bg);
color: var(--_ink);
font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','Inter',system-ui,sans-serif;
min-height:100vh; overflow-x:hidden;
}
/* conteneur principal */
.main{ padding:16px; }
/* barre de stats */
.stats-bar{
display:flex; gap:12px; flex-wrap:wrap;
padding:12px;
background: color-mix(in oklab, var(--_panel) 88%, transparent);
border:1px solid var(--_border);
border-radius:12px;
box-shadow: var(--_shadow);
backdrop-filter: blur(16px);
}
.stat-item{
display:flex; align-items:center; gap:8px;
padding:8px 12px;
border:1px solid var(--_border);
border-radius:10px;
background: color-mix(in oklab, var(--_panel) 70%, transparent);
}
.stat-icon{ font-size:1.1rem; opacity:.9 }
.stat-value{
font-weight:800;
background: linear-gradient(135deg, var(--_acid1), var(--_acid2));
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
}
.stat-label{ color: var(--_muted); font-size:.8rem }
/* recherche globale */
.global-search-container{ position:relative }
.global-search-input{
width:100%; padding:10px 14px; border-radius:12px;
border:1px solid var(--_border);
background: color-mix(in oklab, var(--_panel) 90%, transparent);
color: var(--_ink);
}
.global-search-input:focus{
outline:none;
border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border));
box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent);
}
.clear-global-button{
position:absolute; right:10px; top:50%; transform:translateY(-50%);
background:none; border:1px solid var(--_border);
color:#ef4444; border-radius:8px; padding:2px 6px; display:none;
}
.clear-global-button.show{ display:block }
/* tabs collants */
.tabs-container{
position:sticky; top:0; z-index:20;
display:flex; align-items:center; gap:8px;
padding:8px 12px; min-height:44px;
overflow-x:auto; -webkit-overflow-scrolling:touch;
background: color-mix(in oklab, var(--_panel) 92%, transparent);
border:1px solid var(--_border); border-radius:12px;
box-shadow: var(--_shadow);
}
.tabs-container::-webkit-scrollbar{ height:0 }
.tab{
padding:10px 18px; border-radius:10px; cursor:pointer;
color: var(--_muted); font-weight:700; font-size:.9rem;
border:1px solid transparent; white-space:nowrap; flex:0 0 auto;
}
.tab:hover{ background: rgba(255,255,255,.05); color: var(--_ink); border-color: var(--_border) }
.tab.active{
color: var(--_ink);
background: linear-gradient(135deg, color-mix(in oklab, var(--_acid2) 18%, transparent), color-mix(in oklab, var(--_acid1) 14%, transparent));
border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border));
}
.tab-badge{
margin-left:8px; padding:2px 6px; border-radius:999px;
background: rgba(255,255,255,.1); border:1px solid var(--_border);
font-size:.75rem;
}
/* grille & cartes services */
.credentials-container{ display:flex; flex-direction:column; gap:12px; scroll-padding-top:56px; }
.services-grid{ display:flex; flex-direction:column; gap:12px }
.service-card{
background: color-mix(in oklab, var(--_panel) 88%, transparent);
border:1px solid var(--_border);
border-radius:16px; overflow:hidden;
box-shadow: var(--_shadow);
}
.service-header{
display:flex; align-items:center; gap:8px; padding:12px;
cursor:pointer; user-select:none;
border-bottom:1px solid color-mix(in oklab, var(--_border) 65%, transparent);
}
.service-header:hover{ background: rgba(255,255,255,.04) }
.service-title{
flex:1; font-weight:800; letter-spacing:.2px; font-size:.95rem; text-transform:uppercase;
background: linear-gradient(135deg, var(--_acid1), var(--_acid2));
-webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.service-count{
font-weight:800; font-size:.8rem; padding:4px 8px; border-radius:10px;
background: rgba(255,255,255,.08); color: var(--_ink); border:1px solid var(--_border);
}
.service-card[data-credentials]:not([data-credentials="0"]) .service-count{
background: linear-gradient(135deg,#2e2e2e,#4CAF50);
box-shadow: inset 0 0 0 1px rgba(76,175,80,.35);
}
.search-container{ position:relative }
.search-input{
padding:6px 24px 6px 8px; border:none; border-radius:10px;
background: rgba(255,255,255,.06); color: var(--_ink); font-size:.82rem;
}
.search-input:focus{ outline:none; background: rgba(255,255,255,.1) }
.clear-button{
position:absolute; right:4px; top:50%; transform:translateY(-50%);
border:none; background:none; color:#ef4444; cursor:pointer; display:none;
}
.clear-button.show{ display:block }
.download-button{
border:1px solid var(--_border); background: rgba(255,255,255,.04);
color: var(--_muted); border-radius:8px; padding:4px 8px; cursor:pointer;
}
.download-button:hover{ color:#e99f00; filter:brightness(1.06) }
.collapse-indicator{ color: var(--_muted) }
.service-card.collapsed .service-content{ max-height:0; overflow:hidden }
.service-content{ padding:8px 12px }
/* éléments didentifiants */
.credential-item{
border:1px solid var(--_border); border-radius:10px; margin-bottom:6px; padding:8px;
background: rgba(255,255,255,.02);
display:grid; grid-template-columns: repeat(auto-fit, minmax(120px,1fr)); gap:8px;
}
.credential-field{ display:flex; align-items:center; gap:6px }
.field-label{ font-size:.78rem; color: var(--_muted) }
.field-value{
flex:1; padding:2px 6px; border-radius:8px; cursor:pointer;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
border:1px solid transparent;
}
.field-value:hover{ background: rgba(255,255,255,.06); border-color: var(--_border) }
/* bulles */
.bubble-blue{ background: linear-gradient(135deg,#1d2a32,#00c4d6); color:#fff }
.bubble-green{ background: linear-gradient(135deg,#1e2a24,#00b894); color:#fff }
.bubble-orange{ background: linear-gradient(135deg,#3b2f1a,#e7951a); color:#fff }
/* toast */
.copied-feedback{
position:fixed; left:50%; bottom:20px; transform:translateX(-50%);
padding:8px 12px; background:#4CAF50; color:#fff; border-radius:10px;
box-shadow: var(--_shadow); opacity:0; transition:opacity .25s; z-index:9999;
}
.copied-feedback.show{ opacity:1 }
</style>
</head>
<body>
<div class="toolbar" id="mainToolbar">
<button type="button" onclick="window.location.href='/index.html'" title="Playground">
<img src="/web/images/console_icon.png" alt="Bjorn" style="height: 50px;">
</button>
<button type="button" onclick="window.location.href='/config.html'" title="Config">
<img src="/web/images/config_icon.png" alt="Icon_config" style="height: 50px;">
</button>
<button type="button" onclick="window.location.href='/network.html'" title="Network">
<img src="/web/images/network_icon.png" alt="Icon_network" style="height: 50px;">
</button>
<button type="button" onclick="window.location.href='/netkb.html'" title="NetKB">
<img src="/web/images/netkb_icon.png" alt="Icon_netkb" style="height: 50px;">
</button>
<button type="button" onclick="window.location.href='/credentials.html'" title="Credentials">
<img src="/web/images/cred_icon.png" alt="Icon_cred" style="height: 50px;">
</button>
<button type="button" onclick="window.location.href='/loot.html'" title="Loot">
<img src="/web/images/loot_icon.png" alt="Icon_loot" style="height: 50px;">
</button>
</div>
<div class="console-toolbar">
<button type="button" class="toolbar-button" onclick="adjustCredFontSize(-1)" title="-">
<img src="/web/images/less.png" alt="Icon_less" style="height: 50px;">
</button>
<button id="toggle-toolbar" type="button" class="toolbar-button" onclick="toggleCredToolbar()" data-open="false">
<img id="toggle-icon" src="/web/images/hide.png" alt="Toggle Toolbar" style="height: 50px;">
</button>
<button type="button" class="toolbar-button" onclick="adjustCredFontSize(1)" title="+">
<img src="/web/images/plus.png" alt="Icon_plus" style="height: 50px;">
</button>
</div>
<div class="credentials-container">
<h1 id="cred-title">Credentials</h1>
<div id="credentials-table">
<!-- Les tableaux seront insérés ici par JavaScript -->
</div>
<main class="main" id="main">
<div class="credentials-container">
<div class="stats-bar">
<div class="stat-item"><span class="stat-icon">🧩</span><span class="stat-value" id="stat-services">0</span><span class="stat-label">services</span></div>
<div class="stat-item"><span class="stat-icon">🔐</span><span class="stat-value" id="stat-creds">0</span><span class="stat-label">credentials</span></div>
<div class="stat-item"><span class="stat-icon">🖥️</span><span class="stat-value" id="stat-hosts">0</span><span class="stat-label">unique hosts</span></div>
</div>
<div class="global-search-container">
<input type="text" id="global-search-input" class="global-search-input" placeholder="Search Credentials..." oninput="filterAllServices()" />
<button class="clear-global-button" onclick="clearGlobalSearch()"></button>
</div>
<!-- Tabs -->
<div class="tabs-container" id="cred-tabs"></div>
<div class="services-grid" id="credentials-grid"></div>
</div>
<div class="copied-feedback">Copied to clipboard!</div>
</main>
<script>
let fontSize = 12;
/* =========================
Global state
========================= */
let currentCategory = 'all';
let searchGlobal = '';
let serviceData = []; // [{ service, category, credentials:{headers,rows} }]
/* =========================
Helpers
========================= */
function toCaps(s){ return (s||'').toUpperCase(); }
function slugify(s){ return (s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); }
function normalizeMac(v){
if (!v) return null;
const raw = String(v).toLowerCase().replace(/[^0-9a-f]/g,'');
if (raw.length !== 12) return null;
return raw.match(/.{2}/g).join(':');
}
/* =========================
Persistence (cards & tabs)
========================= */
const LS_CARD_PREFIX = 'cred:card:collapsed:'; // per service
const LS_TAB_PREFIX = 'cred:tab:autoexpand:'; // per category (tab)
function setCardCollapsed(service, collapsed){
try { localStorage.setItem(LS_CARD_PREFIX+service, collapsed ? '1' : '0'); } catch {}
}
function getCardCollapsed(service){
try {
const v = localStorage.getItem(LS_CARD_PREFIX+service);
return v === null ? null : (v === '1');
} catch { return null; }
}
function setTabAutoExpand(cat, on){ try { localStorage.setItem(LS_TAB_PREFIX+cat, on?'1':'0'); } catch {} }
function isTabAutoExpand(cat){ try { return localStorage.getItem(LS_TAB_PREFIX+cat) === '1'; } catch { return false; } }
/** Apply persisted collapse states (card-by-card) and tab auto-expand. */
function applyPersistedCollapse(){
document.querySelectorAll('.service-card').forEach(card=>{
const svc = card.dataset.service;
const st = getCardCollapsed(svc); // null => no explicit user choice yet
if (st === true) card.classList.add('collapsed');
if (st === false) card.classList.remove('collapsed');
});
// Auto-expand for the active tab: only open cards without an explicit preference
if (isTabAutoExpand(currentCategory)){
document.querySelectorAll('.service-card').forEach(card=>{
const svc = card.dataset.service;
if (getCardCollapsed(svc) === null){
card.classList.remove('collapsed');
}
});
}
}
/* =========================
Touch-friendly horizontal drag (no pointer capture)
========================= */
function enableTabsDragScroll(el){
let isDown = false, startX = 0, startLeft = 0, moved = false;
const down = (e) => {
isDown = true;
moved = false;
startX = e.pageX || (e.touches && e.touches[0].pageX) || 0;
startLeft = el.scrollLeft;
};
const move = (e) => {
if (!isDown) return;
const x = e.pageX || (e.touches && e.touches[0].pageX) || 0;
const dx = x - startX;
if (Math.abs(dx) > 3) moved = true; // small threshold to distinguish click
el.scrollLeft = startLeft - dx;
};
const up = () => { isDown = false; };
el.addEventListener('pointerdown', down, {passive:true});
window.addEventListener('pointermove', move, {passive:true});
window.addEventListener('pointerup', up, {passive:true});
window.addEventListener('pointercancel', up, {passive:true});
// If a drag happened, swallow the synthetic click
el.addEventListener('click', (e) => {
if (moved) { e.preventDefault(); e.stopPropagation(); moved = false; }
});
}
document.addEventListener('DOMContentLoaded', () => {
const tabsEl = document.getElementById('cred-tabs');
if (tabsEl) enableTabsDragScroll(tabsEl);
});
/* =========================
Categories / Tabs
========================= */
function getCategories(){
const set = new Set();
serviceData.forEach(s => set.add(s.category));
return Array.from(set);
}
/** Count credentials (rows) that match current global search, per category and total. */
function computeBadgeCounts(){
const map = { all: 0 };
getCategories().forEach(cat => map[cat] = 0);
const needle = (searchGlobal || '').toLowerCase();
serviceData.forEach(svc => {
const rows = svc.credentials.rows || [];
let matchedCount;
if (!needle) {
matchedCount = rows.length;
} else {
matchedCount = rows.reduce((acc, row) => {
const text = Object.values(row).join(' ').toLowerCase();
return acc + (text.includes(needle) ? 1 : 0);
}, 0);
}
map.all += matchedCount;
map[svc.category] = (map[svc.category] || 0) + matchedCount;
});
return map;
}
function renderTabs(){
const tabs = document.getElementById('cred-tabs');
const counts = computeBadgeCounts();
const cats = ['all', ...getCategories()];
tabs.innerHTML = cats.map(cat=>{
const label = (cat==='all'?'All':toCaps(cat));
const count = counts[cat] || 0;
const active = (cat===currentCategory) ? 'active':'';
return `<div class="tab ${active}" data-cat="${cat}">
${label} <span class="tab-badge">${count}</span>
</div>`;
}).join('');
// Click via delegation (robust with drag)
tabs.onclick = (e) => {
const tab = e.target.closest('.tab');
if (!tab) return;
tabs.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
tab.classList.add('active');
currentCategory = tab.dataset.cat;
// Keep the active tab centered (mobile nicety)
tab.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
// Auto-expand for the selected tab (persist)
setTabAutoExpand(currentCategory, true);
renderServices(); // rebuild cards for this tab
applyPersistedCollapse(); // reapply collapse + tab auto-expand
updateBadges();
};
}
function updateBadges(){
const counts = computeBadgeCounts();
document.querySelectorAll('#cred-tabs .tab').forEach(tab=>{
const cat = tab.getAttribute('data-cat');
const badge = tab.querySelector('.tab-badge');
if (badge) badge.textContent = counts[cat] || 0;
});
}
/* =========================
Stats bar (incl. unique hosts by MAC)
========================= */
function updateStatsBar(){
const totalServices = serviceData.length;
const totalCreds = serviceData.reduce((a,s)=>a + (s.credentials.rows?.length || 0), 0);
const macSet = new Set();
serviceData.forEach(s=>{
(s.credentials.rows||[]).forEach(r=>{
// look for a MAC-looking field
let macVal = null;
for (const [k,v] of Object.entries(r)) {
const key = (k||'').toLowerCase();
if (key === 'mac' || key === 'mac address' || key === 'mac_address' || key.includes('mac')) {
macVal = v; break;
}
}
const norm = normalizeMac(macVal);
if (norm) macSet.add(norm);
});
});
document.getElementById('stat-services').textContent = totalServices;
document.getElementById('stat-creds').textContent = totalCreds;
document.getElementById('stat-hosts').textContent = macSet.size;
}
/* =========================
Cards / Rows
========================= */
function createCredentialCard(service, credentials) {
const credCount = credentials.rows.length;
const borderColor = credCount > 0 ? '#4CAF50' : '#d3d3d3';
return `
<div class="service-card collapsed"
data-service="${service}"
data-credentials="${credCount}"
style="border-color: ${borderColor}">
<div class="service-header" onclick="toggleServiceCollapse(this)">
<span class="service-title">${toCaps(service)}</span>
<span class="service-count" style="background:${credCount>0?'linear-gradient(135deg,#2e2e2e,#4CAF50)':'none'};font-weight:bold;">
Credentials: ${credCount}
</span>
<div class="search-container">
<input type="text" class="search-input"
data-service="${service}"
placeholder="Search..."
oninput="filterCredentials(this, '${service}')"
onclick="event.stopPropagation()"
onkeyup="toggleClearButton(this)" />
<button class="clear-button" onclick="clearSearch(this)">✖</button>
</div>
<button class="download-button" onclick='downloadCredentials(event, "${service}", ${JSON.stringify(credentials).replace(/"/g, '&quot;')})'>💾</button>
<span class="collapse-indicator">▼</span>
</div>
<div class="service-content">
${createCredentialsContent(credentials)}
</div>
</div>
`;
}
function createCredentialsContent(credentials) {
return credentials.rows.map(row =>
`<div class="credential-item">
${Object.entries(row).map(([key, value]) => {
const bubbleClass = getBubbleClass(key);
const val = (value ?? '').toString();
return `
<div class="credential-field">
<span class="field-label">${key}</span>
<div class="field-value ${val.trim()?bubbleClass:''}"
data-value="${val.replace(/"/g,'&quot;')}" onclick="copyToClipboard(this)"
title="Click to copy">${val}</div>
</div>`;
}).join('')}
</div>`
).join('');
}
function getBubbleClass(key) {
const k = (key||'').toLowerCase();
if (k === 'port') return 'bubble-orange';
if (['ip address','ip','map','hostname','mac address','mac'].includes(k)) return 'bubble-blue';
return 'bubble-green';
}
/* =========================
Parse backend HTML (/list_credentials)
========================= */
function parseTable(table) {
const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim());
const rows = Array.from(table.querySelectorAll('tr')).slice(1).map(row => {
const cells = Array.from(row.querySelectorAll('td'));
return Object.fromEntries(headers.map((header, index) => [
header,
(cells[index]?.textContent || '').trim()
]));
});
return { headers, rows };
}
/* =========================
Fetch + Render (with sticky tabs)
========================= */
function fetchCredentials(){
const globalSearchValue = document.getElementById('global-search-input').value.toLowerCase();
searchGlobal = globalSearchValue;
fetch('/list_credentials')
.then(r=>r.text())
.then(html=>{
const doc = new DOMParser().parseFromString(html,'text/html');
const tables = doc.querySelectorAll('table');
serviceData = [];
tables.forEach(table=>{
const titleEl = table.previousElementSibling;
if (titleEl && titleEl.textContent) {
const raw = titleEl.textContent.toLowerCase().replace('.csv','').trim();
const credentials = parseTable(table);
serviceData.push({
service: raw,
category: raw, // category == service name (dynamic)
credentials
});
}
});
// sort by most credentials first
serviceData.sort((a,b)=> (b.credentials.rows?.length||0) - (a.credentials.rows?.length||0));
updateStatsBar();
renderTabs();
renderServices();
applyPersistedCollapse(); // survive periodic refresh
attachSearchListeners();
})
.catch(err=>console.error('Error:',err));
}
function renderServices(){
const grid = document.getElementById('credentials-grid');
const needle = (searchGlobal||'').toLowerCase();
// Filter services by global search (title OR any row content)
const searched = serviceData.filter(svc=>{
if (!needle) return true;
const titleMatch = svc.service.includes(needle);
const rowMatch = svc.credentials.rows.some(r => Object.values(r).join(' ').toLowerCase().includes(needle));
return titleMatch || rowMatch;
});
// Filter by active category (tab)
const byCat = searched.filter(svc => currentCategory==='all' || svc.category===currentCategory);
if (byCat.length === 0) {
grid.innerHTML = `<div style="text-align:center;color:var(--_muted);padding:40px;">
<div style="font-size:3rem;margin-bottom:16px;opacity:.5;">🔍</div>No credentials</div>`;
updateBadges();
return;
}
grid.innerHTML = byCat.map(s => createCredentialCard(s.service, s.credentials)).join('');
// If global search active, only show matching rows inside cards and auto-open them
if (needle) {
document.querySelectorAll('.service-card').forEach(card=>{
card.classList.remove('collapsed');
card.querySelectorAll('.credential-item').forEach(it=>{
const t = it.textContent.toLowerCase();
it.style.display = t.includes(needle)?'':'none';
});
});
}
updateBadges();
}
/* =========================
Global search
========================= */
function updateGlobalClearButton(){
const btn = document.querySelector('.clear-global-button');
if (searchGlobal && searchGlobal.length>0) btn.classList.add('show'); else btn.classList.remove('show');
}
function filterAllServices() {
searchGlobal = document.getElementById('global-search-input').value.toLowerCase();
renderServices();
applyPersistedCollapse();
updateGlobalClearButton();
}
function clearGlobalSearch() {
document.getElementById('global-search-input').value = '';
searchGlobal = '';
renderServices();
applyPersistedCollapse();
updateGlobalClearButton();
document.querySelectorAll('.service-card').forEach(card => card.classList.add('collapsed'));
}
/* =========================
Per-card search
========================= */
let searchTerms = {};
let initialCollapsedState = {};
function filterCredentials(input, service) {
const filter = input.value.toLowerCase();
searchTerms[service] = filter;
const card = document.querySelector(`.service-card[data-service="${service}"]`);
if (!card) return;
const items = card.querySelectorAll('.credential-item');
if (!(service in initialCollapsedState)) {
initialCollapsedState[service] = card.classList.contains('collapsed');
}
if (filter.length > 0) card.classList.remove('collapsed');
items.forEach(item=>{
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filter) ? '' : 'none';
});
}
function reapplySearchFilters(){
Object.keys(searchTerms).forEach(service=>{
const filter = searchTerms[service] || '';
const card = document.querySelector(`.service-card[data-service="${service}"]`);
if (!card) return;
const items = card.querySelectorAll('.credential-item');
items.forEach(item=>{
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filter) ? '' : 'none';
});
const searchInput = card.querySelector('.search-input');
if (searchInput) searchInput.value = filter;
});
}
function attachSearchListeners(){
document.querySelectorAll('.service-card').forEach(card=>{
const service = card.dataset.service;
const searchInput = card.querySelector('.search-input');
if (searchInput) {
searchInput.value = searchTerms[service] || '';
searchInput.addEventListener('input', ()=>filterCredentials(searchInput, service));
}
});
}
function toggleClearButton(input) {
const clearButton = input.nextElementSibling;
if (input.value.trim().length > 0) clearButton.classList.add('show'); else clearButton.classList.remove('show');
}
function clearSearch(button) {
const input = button.previousElementSibling;
const service = input.getAttribute('data-service');
input.value = '';
filterCredentials(input, service);
if (service in initialCollapsedState) {
const card = document.querySelector(`.service-card[data-service="${service}"]`);
if (card) {
if (initialCollapsedState[service]) card.classList.add('collapsed');
else card.classList.remove('collapsed');
}
delete initialCollapsedState[service];
}
toggleClearButton(input);
}
/* =========================
UX bits
========================= */
function toggleServiceCollapse(header) {
const card = header.closest('.service-card');
const nowCollapsed = !card.classList.contains('collapsed');
card.classList.toggle('collapsed');
const svc = card.dataset.service;
if (svc) setCardCollapsed(svc, nowCollapsed); // remember user's choice
}
function downloadCredentials(event, service, credentials) {
event.stopPropagation();
if (!credentials.rows || credentials.rows.length===0) return;
const headers = Object.keys(credentials.rows[0]);
let csv = headers.join(',') + '\n';
credentials.rows.forEach(row=>{
const values = headers.map(h=>{
const v = (row[h] ?? '').toString();
return v.includes(',') ? `"${v.replace(/"/g,'""')}"` : v;
});
csv += values.join(',') + '\n';
});
const blob = new Blob([csv], {type:'text/csv'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${service}_credentials.csv`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function showCopyFeedback() {
const feedback = document.querySelector('.copied-feedback');
feedback.classList.add('show');
setTimeout(() => feedback.classList.remove('show'), 1500);
}
function copyToClipboard(el) {
const text = el.getAttribute('data-value') || '';
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta);
ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
showCopyFeedback();
const bg = el.style.background;
el.style.background='#4CAF50'; setTimeout(()=>el.style.background=bg, 500);
}
/* =========================
Boot
========================= */
document.addEventListener('DOMContentLoaded', ()=>{
document.getElementById('global-search-input').addEventListener('input', filterAllServices);
fetchCredentials();
setInterval(fetchCredentials, 30000);
});
</script>
</body>
</html>