init
This commit is contained in:
commit
3c6df0e32f
571
index.html
Normal file
571
index.html
Normal file
@ -0,0 +1,571 @@
|
||||
<!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">
|
||||
<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>
|
||||
<p class="small muted">Each entry follows: <code>CredSet = [username, password, codegen, custom_kvp]</code></p>
|
||||
<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>
|
||||
Loading…
Reference in New Issue
Block a user