Files
Bjorn/web/credentials.html

727 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 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>
<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>