mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 16:14:57 +00:00
727 lines
26 KiB
HTML
727 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<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 d’identifiants */
|
||
.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>
|
||
<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, '"')})'>💾</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,'"')}" 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>
|