571 lines
22 KiB
HTML
571 lines
22 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<title>Local CredVault (No Cloud, No Build)</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--bg: #0b1220;
|
||
--card: #111827;
|
||
--soft: #1f2937;
|
||
--text: #e5e7eb;
|
||
--muted: #9ca3af;
|
||
--accent: #60a5fa;
|
||
--danger: #ef4444;
|
||
--ok: #34d399;
|
||
--warn: #f59e0b;
|
||
--border: #2a3444;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial;
|
||
background: linear-gradient(180deg, #0b1220, #0d1322);
|
||
color: var(--text);
|
||
}
|
||
header {
|
||
padding: 18px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky; top: 0; backdrop-filter: blur(6px);
|
||
background: rgba(11,18,32,.8);
|
||
z-index: 10;
|
||
}
|
||
header h1 { margin: 0 0 6px 0; font-size: 18px; }
|
||
header p { margin: 0; color: var(--muted); font-size: 13px; }
|
||
main { max-width: 1100px; margin: 0 auto; padding: 20px 16px 64px; }
|
||
.grid { display: grid; gap: 14px; }
|
||
@media (min-width: 960px) {
|
||
.grid-2 { grid-template-columns: 1.1fr .9fr; }
|
||
}
|
||
section.card {
|
||
background: linear-gradient(180deg, var(--card), #0e1627);
|
||
border: 1px solid var(--border);
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
box-shadow: 0 6px 24px rgba(0,0,0,.25);
|
||
}
|
||
h2 { margin: 0 0 10px 0; font-size: 16px; }
|
||
.row { display: grid; grid-template-columns: 140px 1fr; gap: 10px; align-items: center; margin: 8px 0; }
|
||
.row > label { color: var(--muted); font-size: 12px; }
|
||
input[type="text"], input[type="password"], textarea, input[type="number"] {
|
||
width: 100%; padding: 10px 12px; border-radius: 10px; background: #0b1629; border: 1px solid #213049;
|
||
color: var(--text); outline: none; font-size: 14px;
|
||
}
|
||
textarea { min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, Monaco, "Liberation Mono", "Courier New", monospace; }
|
||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
|
||
button {
|
||
background: linear-gradient(180deg, #1f2b41, #1a2436);
|
||
color: var(--text); border: 1px solid #2a3a56; border-radius: 10px; padding: 9px 12px;
|
||
cursor: pointer; font-weight: 600; font-size: 14px;
|
||
}
|
||
button:hover { border-color: #3b537e; }
|
||
.btn-accent { background: linear-gradient(180deg, #2a4a85, #1c3360); border-color: #3c5da0; }
|
||
.btn-danger { background: linear-gradient(180deg, #5a1a1a, #451313); border-color: #6f2323; }
|
||
.btn-soft { background: linear-gradient(180deg, #233148, #1a2436); border-color: #2f405f; }
|
||
.muted { color: var(--muted); }
|
||
.list { display: grid; gap: 10px; }
|
||
.cred {
|
||
border: 1px solid var(--border); border-radius: 12px; padding: 12px;
|
||
background: linear-gradient(180deg, #0d1729, #0b1524);
|
||
}
|
||
.cred-head { display: flex; gap: 8px; align-items: center; justify-content: space-between; }
|
||
.site { font-weight: 700; font-size: 15px; }
|
||
.kv { display: grid; grid-template-columns: 120px 1fr auto; gap: 10px; align-items: center; margin-top: 8px; }
|
||
.code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, Monaco, monospace; font-weight: 700; font-size: 20px; letter-spacing: 2px; }
|
||
.tag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border: 1px solid #28406b; border-radius: 999px; color: #a7c4ff; background: #0b1a33; font-size: 11px; }
|
||
.progress { height: 4px; background: #111826; border-radius: 999px; overflow: hidden; }
|
||
.bar { height: 100%; background: linear-gradient(90deg, #2cc5ff, #60a5fa); width: 0%; transition: width .2s linear; }
|
||
.sep { height: 1px; background: var(--border); margin: 10px 0; }
|
||
.small { font-size: 12px; }
|
||
.danger { color: var(--danger); }
|
||
.ok { color: var(--ok); }
|
||
.warn { color: var(--warn); }
|
||
.pill { border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--muted); }
|
||
.right { text-align: right; }
|
||
details > summary { cursor: pointer; color: var(--muted); margin: 6px 0; }
|
||
.flex { display: flex; gap: 8px; align-items: center; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Local CredVault — No Cloud, No Build</h1>
|
||
<p class="small">Stores credentials in-memory. Encrypt/decrypt to share/backup. TOTP built-in (SHA‑1, 6 digits, 30s).</p>
|
||
</header>
|
||
<main class="grid grid-2">
|
||
<!-- Left: Editor & List -->
|
||
<section class="card">
|
||
<h1>Your Credentials</h1>
|
||
<h2>Add or Update Credential</h2>
|
||
<div class="row">
|
||
<label for="site">Site Key</label>
|
||
<input id="site" type="text" placeholder="e.g., github.com">
|
||
</div>
|
||
<div class="row">
|
||
<label for="username">Username</label>
|
||
<input id="username" type="text" placeholder="username or email">
|
||
</div>
|
||
<div class="row">
|
||
<label for="password">Password</label>
|
||
<div class="flex" style="width:100%">
|
||
<input id="password" type="password" placeholder="••••••••" style="flex:1">
|
||
<button class="btn-soft" id="togglePass">Show</button>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<label for="codegen">TOTP Secret (Base32)</label>
|
||
<div class="flex" style="width:100%">
|
||
<input id="codegen" type="password" placeholder="Base32 secret like JBSW... (optional)" style="flex:1">
|
||
<button class="btn-soft" id="toggleSecret">Show</button>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<label for="custom">Custom KVP (JSON)</label>
|
||
<textarea id="custom" placeholder='{"note":"personal","group":"dev"}'></textarea>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-accent" id="addBtn">Add / Update</button>
|
||
<button class="btn-danger" id="clearForm">Clear Form</button>
|
||
</div>
|
||
|
||
<div class="sep"></div>
|
||
<h2>Vault</h2>
|
||
<div id="vaultList" class="list"></div>
|
||
</section>
|
||
|
||
<!-- Right: Encrypt / Decrypt -->
|
||
<section class="card">
|
||
<h2>Encrypt & Save</h2>
|
||
<div class="row">
|
||
<label for="encPass">Master Password</label>
|
||
<input id="encPass" type="password" placeholder="Choose a strong password">
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-accent" id="encryptBtn">Encrypt Vault</button>
|
||
<button class="btn-soft" id="copyBlobBtn">Copy Encrypted Blob</button>
|
||
</div>
|
||
<div class="row">
|
||
<label for="blobOut">Encrypted Blob</label>
|
||
<textarea id="blobOut" placeholder="Click 'Encrypt Vault' to produce a blob here" readonly></textarea>
|
||
</div>
|
||
|
||
<div class="sep"></div>
|
||
|
||
<h2>Load & Decrypt</h2>
|
||
<div class="row">
|
||
<label for="decPass">Master Password</label>
|
||
<input id="decPass" type="password" placeholder="Password used to encrypt">
|
||
</div>
|
||
<div class="row">
|
||
<label for="blobIn">Encrypted Blob</label>
|
||
<textarea id="blobIn" placeholder="Paste encrypted blob here"></textarea>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-accent" id="decryptBtn">Decrypt & Load</button>
|
||
<button class="btn-danger" id="wipeBtn" title="Clears in-memory vault (not the blob)">Wipe In-Memory Vault</button>
|
||
</div>
|
||
|
||
<details>
|
||
<summary>Encryption Details</summary>
|
||
<ul class="small">
|
||
<li>Key Derivation: PBKDF2 (SHA‑256), default 200,000 iterations</li>
|
||
<li>Symmetric Cipher: AES‑GCM (256‑bit), 12‑byte IV</li>
|
||
<li>Envelope contains: version, kdf, iterations, salt(b64), iv(b64), cipher(b64)</li>
|
||
<li>No cloud; no storage. Refreshing the page clears memory.</li>
|
||
</ul>
|
||
</details>
|
||
</section>
|
||
</main>
|
||
|
||
<script>
|
||
/************** Types (for reference) *****************
|
||
* type CredSet = [username: string, password: string, codegen: string, custom_kvp: Record<string, string>];
|
||
* type CredVault = Record<string, CredSet>;
|
||
*****************************************************/
|
||
|
||
// In-memory vault
|
||
/** @type {Record<string, [string, string, string, Record<string,string>]>} */
|
||
let VAULT = Object.create(null);
|
||
|
||
// ===== Utilities =====
|
||
const enc = new TextEncoder();
|
||
const dec = new TextDecoder();
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function zeroPad(num, len) {
|
||
let s = String(num);
|
||
while (s.length < len) s = "0" + s;
|
||
return s;
|
||
}
|
||
|
||
function bytesToBase64(arr) {
|
||
let bin = "";
|
||
const bytes = new Uint8Array(arr);
|
||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
||
return btoa(bin);
|
||
}
|
||
function base64ToBytes(b64) {
|
||
const bin = atob(b64);
|
||
const len = bin.length;
|
||
const bytes = new Uint8Array(len);
|
||
for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
|
||
return bytes;
|
||
}
|
||
|
||
// Base32 (RFC 4648) decoder for TOTP secrets
|
||
const B32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||
/** @param {string} s */
|
||
function base32Decode(s) {
|
||
s = s.toUpperCase().replace(/=+$/,"").replace(/\s+/g,"");
|
||
let bits = "";
|
||
for (let i = 0; i < s.length; i++) {
|
||
const idx = B32_ALPHABET.indexOf(s[i]);
|
||
if (idx === -1) { throw new Error("Invalid Base32 character"); }
|
||
bits += idx.toString(2).padStart(5, "0");
|
||
}
|
||
const bytes = [];
|
||
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
||
bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
||
}
|
||
return new Uint8Array(bytes);
|
||
}
|
||
|
||
// TOTP generation (RFC 6238 default SHA-1, 6 digits, 30s)
|
||
async function generateTOTP(secretBase32, timeStep=30, digits=6, algo="SHA-1") {
|
||
try {
|
||
const keyBytes = base32Decode(secretBase32);
|
||
const counter = Math.floor(Date.now() / 1000 / timeStep);
|
||
const msg = new ArrayBuffer(8);
|
||
const v = new DataView(msg);
|
||
// 8-byte big-endian counter
|
||
v.setUint32(4, counter, false); // high 4 bytes 0, low 4 bytes counter
|
||
const cryptoKey = await crypto.subtle.importKey(
|
||
"raw", keyBytes, { name: "HMAC", hash: { name: algo } }, false, ["sign"]
|
||
);
|
||
const mac = await crypto.subtle.sign("HMAC", cryptoKey, msg);
|
||
const macBytes = new Uint8Array(mac);
|
||
const offset = macBytes[macBytes.length - 1] & 0x0f;
|
||
const code = ((macBytes[offset] & 0x7f) << 24) |
|
||
((macBytes[offset + 1] & 0xff) << 16) |
|
||
((macBytes[offset + 2] & 0xff) << 8) |
|
||
(macBytes[offset + 3] & 0xff);
|
||
const hotp = code % (10 ** digits);
|
||
return zeroPad(hotp, digits);
|
||
} catch (e) {
|
||
return ""; // invalid secret or crypto unavailable
|
||
}
|
||
}
|
||
|
||
function secondsToNextStep(step=30) {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
return step - (now % step);
|
||
}
|
||
|
||
// Crypto: KDF (PBKDF2-SHA256), AES-GCM
|
||
async function deriveKey(password, salt, iterations=200000) {
|
||
const baseKey = await crypto.subtle.importKey(
|
||
"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
|
||
);
|
||
return await crypto.subtle.deriveKey(
|
||
{ name: "PBKDF2", salt, iterations, hash: "SHA-256" },
|
||
baseKey,
|
||
{ name: "AES-GCM", length: 256 },
|
||
false,
|
||
["encrypt", "decrypt"]
|
||
);
|
||
}
|
||
|
||
async function encryptVault(password, iterations=200000) {
|
||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const key = await deriveKey(password, salt, iterations);
|
||
const plaintext = enc.encode(JSON.stringify(VAULT));
|
||
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||
const envelope = {
|
||
version: 1,
|
||
kdf: "PBKDF2-SHA256",
|
||
iterations,
|
||
salt: bytesToBase64(salt),
|
||
iv: bytesToBase64(iv),
|
||
cipher: bytesToBase64(new Uint8Array(cipher))
|
||
};
|
||
return JSON.stringify(envelope, null, 2);
|
||
}
|
||
|
||
async function decryptVault(password, blobText) {
|
||
const env = JSON.parse(blobText);
|
||
if (!env || env.version !== 1) throw new Error("Unsupported or invalid blob");
|
||
const salt = base64ToBytes(env.salt);
|
||
const iv = base64ToBytes(env.iv);
|
||
const cipher = base64ToBytes(env.cipher);
|
||
const key = await deriveKey(password, salt, env.iterations);
|
||
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, cipher);
|
||
const txt = dec.decode(plainBuf);
|
||
/** @type {Record<string, [string,string,string,Record<string,string>]>} */
|
||
const obj = JSON.parse(txt);
|
||
return obj;
|
||
}
|
||
|
||
// ===== UI Wiring =====
|
||
const siteEl = $("site");
|
||
const usernameEl = $("username");
|
||
const passwordEl = $("password");
|
||
const codegenEl = $("codegen");
|
||
const customEl = $("custom");
|
||
|
||
const togglePass = $("togglePass");
|
||
const toggleSecret = $("toggleSecret");
|
||
togglePass.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const type = passwordEl.getAttribute("type") === "password" ? "text" : "password";
|
||
passwordEl.setAttribute("type", type);
|
||
togglePass.textContent = type === "password" ? "Show" : "Hide";
|
||
});
|
||
toggleSecret.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
const type = codegenEl.getAttribute("type") === "password" ? "text" : "password";
|
||
codegenEl.setAttribute("type", type);
|
||
toggleSecret.textContent = type === "password" ? "Show" : "Hide";
|
||
});
|
||
|
||
$("addBtn").addEventListener("click", () => {
|
||
const site = siteEl.value.trim();
|
||
if (!site) { alert("Site key is required (e.g., github.com)"); return; }
|
||
const username = usernameEl.value;
|
||
const password = passwordEl.value;
|
||
const codegen = codegenEl.value.trim();
|
||
let custom = {};
|
||
if (customEl.value.trim()) {
|
||
try { custom = JSON.parse(customEl.value); }
|
||
catch (e) { alert("Custom KVP must be valid JSON"); return; }
|
||
}
|
||
VAULT[site] = [username, password, codegen, custom];
|
||
renderVault();
|
||
// keep form in place for further edits (or clear if you want)
|
||
});
|
||
|
||
$("clearForm").addEventListener("click", () => {
|
||
siteEl.value = "";
|
||
usernameEl.value = "";
|
||
passwordEl.value = "";
|
||
codegenEl.value = "";
|
||
customEl.value = "";
|
||
});
|
||
|
||
$("encryptBtn").addEventListener("click", async () => {
|
||
const pw = $("encPass").value;
|
||
if (!pw) { alert("Enter a master password to encrypt"); return; }
|
||
try {
|
||
$("blobOut").value = "Encrypting...";
|
||
const blob = await encryptVault(pw);
|
||
$("blobOut").value = blob;
|
||
} catch (e) {
|
||
$("blobOut").value = `Error: ${e.message}`;
|
||
}
|
||
});
|
||
|
||
$("copyBlobBtn").addEventListener("click", async () => {
|
||
const val = $("blobOut").value.trim();
|
||
if (!val) { alert("Nothing to copy."); return; }
|
||
try {
|
||
await navigator.clipboard.writeText(val);
|
||
alert("Encrypted blob copied to clipboard.");
|
||
} catch (e) {
|
||
alert("Copy failed: " + e.message);
|
||
}
|
||
});
|
||
|
||
$("decryptBtn").addEventListener("click", async () => {
|
||
const pw = $("decPass").value;
|
||
const blobText = $("blobIn").value.trim();
|
||
if (!pw || !blobText) { alert("Provide both encrypted blob and password."); return; }
|
||
try {
|
||
const obj = await decryptVault(pw, blobText);
|
||
// Validate structure roughly
|
||
if (typeof obj !== "object" || Array.isArray(obj)) throw new Error("Decrypted data not a vault object");
|
||
// Replace in-memory vault
|
||
VAULT = Object.create(null);
|
||
for (const [k,v] of Object.entries(obj)) {
|
||
if (!Array.isArray(v) || v.length !== 4) continue;
|
||
const [u,p,c,custom] = v;
|
||
VAULT[k] = [String(u||""), String(p||""), String(c||""), typeof custom === "object" && custom ? custom : {}];
|
||
}
|
||
renderVault();
|
||
alert("Decryption successful. Vault restored in memory.");
|
||
} catch (e) {
|
||
alert("Decrypt failed: " + e.message);
|
||
}
|
||
});
|
||
|
||
$("wipeBtn").addEventListener("click", () => {
|
||
if (!confirm("This clears the in-memory vault. Continue?")) return;
|
||
VAULT = Object.create(null);
|
||
renderVault();
|
||
});
|
||
|
||
function renderVault() {
|
||
const list = $("vaultList");
|
||
list.innerHTML = "";
|
||
const sites = Object.keys(VAULT).sort();
|
||
if (sites.length === 0) {
|
||
list.innerHTML = `<p class="muted small">No entries yet. Add one above.</p>`;
|
||
return;
|
||
}
|
||
for (const site of sites) {
|
||
const [u, p, c, custom] = VAULT[site];
|
||
const card = document.createElement("div");
|
||
card.className = "cred";
|
||
card.innerHTML = `
|
||
<div class="cred-head">
|
||
<div class="flex">
|
||
<span class="site">${escapeHtml(site)}</span>
|
||
${c ? `<span class="tag">TOTP</span>` : ``}
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-soft" data-act="edit">Edit</button>
|
||
<button class="btn-danger" data-act="delete">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kv">
|
||
<div class="muted small">Username</div>
|
||
<div>${escapeHtml(u || "")}</div>
|
||
<div class="right"><button class="btn-soft" data-act="copy-username">Copy</button></div>
|
||
|
||
<div class="muted small">Password</div>
|
||
<div><input type="password" value="${escapeHtmlAttr(p || "")}" style="width:100%"></div>
|
||
<div class="right">
|
||
<button class="btn-soft" data-act="toggle-password">Show</button>
|
||
<button class="btn-soft" data-act="copy-password">Copy</button>
|
||
</div>
|
||
|
||
<div class="muted small">TOTP Code</div>
|
||
<div class="code" data-role="totp">${c ? "…" : `<span class="muted">—</span>`}</div>
|
||
<div class="right">
|
||
<span class="pill small" data-role="ttl"> </span>
|
||
<button class="btn-soft" data-act="copy-totp" ${c ? "" : "disabled"}>Copy</button>
|
||
</div>
|
||
|
||
<div class="muted small">Custom</div>
|
||
<div><pre class="small" style="margin:0;white-space:pre-wrap">${escapeHtml(JSON.stringify(custom || {}, null, 2))}</pre></div>
|
||
<div></div>
|
||
</div>
|
||
|
||
<div class="progress" ${c ? "" : 'style="opacity:.25"'}><div class="bar" data-role="bar"></div></div>
|
||
`;
|
||
// events
|
||
card.querySelector('[data-act="edit"]').addEventListener("click", () => {
|
||
siteEl.value = site;
|
||
usernameEl.value = u || "";
|
||
passwordEl.value = p || "";
|
||
codegenEl.value = c || "";
|
||
customEl.value = JSON.stringify(custom || {}, null, 2);
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
});
|
||
card.querySelector('[data-act="delete"]').addEventListener("click", () => {
|
||
if (!confirm(`Delete "${site}"?`)) return;
|
||
delete VAULT[site];
|
||
renderVault();
|
||
});
|
||
card.querySelector('[data-act="copy-username"]').addEventListener("click", async () => {
|
||
await navigator.clipboard.writeText(u || "");
|
||
toast("Username copied.");
|
||
});
|
||
const pwInput = card.querySelector('input[type="password"], input[type="text"]');
|
||
card.querySelector('[data-act="toggle-password"]').addEventListener("click", (e) => {
|
||
const btn = e.currentTarget;
|
||
const t = pwInput.getAttribute("type") === "password" ? "text" : "password";
|
||
pwInput.setAttribute("type", t);
|
||
btn.textContent = t === "password" ? "Show" : "Hide";
|
||
});
|
||
card.querySelector('[data-act="copy-password"]').addEventListener("click", async () => {
|
||
await navigator.clipboard.writeText(p || "");
|
||
toast("Password copied.");
|
||
});
|
||
card.querySelector('[data-act="copy-totp"]').addEventListener("click", async () => {
|
||
const codeEl = card.querySelector('[data-role="totp"]');
|
||
const txt = (codeEl.textContent || "").trim();
|
||
if (txt && /^\d{6,8}$/.test(txt)) {
|
||
await navigator.clipboard.writeText(txt);
|
||
toast("TOTP copied.");
|
||
}
|
||
});
|
||
|
||
// attach totp updater refs
|
||
card.dataset.site = site;
|
||
card.dataset.secret = c || "";
|
||
card._totpEl = card.querySelector('[data-role="totp"]');
|
||
card._ttlEl = card.querySelector('[data-role="ttl"]');
|
||
card._barEl = card.querySelector('[data-role="bar"]');
|
||
|
||
$("vaultList").appendChild(card);
|
||
}
|
||
// kick the updater (will continue ticking)
|
||
tickTOTP();
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
function escapeHtmlAttr(s) {
|
||
return escapeHtml(s).replace(/"/g, """);
|
||
}
|
||
|
||
let _tickHandle = null;
|
||
async function tickTOTP() {
|
||
if (_tickHandle) return; // already ticking
|
||
async function loop() {
|
||
const list = $("vaultList");
|
||
const cards = list.querySelectorAll(".cred");
|
||
for (const card of cards) {
|
||
const secret = card.dataset.secret || "";
|
||
const totpEl = card._totpEl;
|
||
const ttlEl = card._ttlEl;
|
||
const barEl = card._barEl;
|
||
if (secret) {
|
||
const code = await generateTOTP(secret, 30, 6, "SHA-1");
|
||
const secs = secondsToNextStep(30);
|
||
if (code) {
|
||
totpEl.textContent = code;
|
||
ttlEl.textContent = `${secs}s`;
|
||
const pct = Math.floor(((30 - secs) / 30) * 100);
|
||
if (barEl) barEl.style.width = pct + "%";
|
||
ttlEl.style.color = secs <= 5 ? "var(--warn)" : "var(--muted)";
|
||
totpEl.style.color = secs <= 5 ? "var(--warn)" : "var(--text)";
|
||
} else {
|
||
totpEl.innerHTML = `<span class="danger small">Invalid secret</span>`;
|
||
ttlEl.textContent = "";
|
||
if (barEl) barEl.style.width = "0%";
|
||
}
|
||
} else {
|
||
if (totpEl) totpEl.innerHTML = `<span class="muted">—</span>`;
|
||
if (ttlEl) ttlEl.textContent = "";
|
||
if (barEl) barEl.style.width = "0%";
|
||
}
|
||
}
|
||
_tickHandle = setTimeout(loop, 500);
|
||
}
|
||
loop();
|
||
}
|
||
|
||
function toast(msg) {
|
||
// Minimal non-intrusive alert replacement
|
||
console.log(msg);
|
||
}
|
||
|
||
// Initial render
|
||
renderVault();
|
||
|
||
// Warn on unload (since it's in-memory-only)
|
||
window.addEventListener("beforeunload", (e) => {
|
||
if (Object.keys(VAULT).length > 0) {
|
||
e.preventDefault();
|
||
e.returnValue = "";
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |