288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
const api = (path, opts = {}) =>
|
|
fetch(path, {
|
|
headers: { "Content-Type": "application/json" },
|
|
...opts,
|
|
}).then(r => {
|
|
if (r.status === 204 || !r.body) return { ok: true };
|
|
return r.json().then(d => {
|
|
if (!r.ok) throw new Error(d.error || "Request failed");
|
|
return d;
|
|
});
|
|
});
|
|
|
|
const pinTimers = {};
|
|
|
|
function fmtCountdown(ms) {
|
|
if (ms <= 0) return "00:00:00:00";
|
|
const d = Math.floor(ms / 86400000);
|
|
const totalSec = Math.floor((ms % 86400000) / 1000);
|
|
const h = Math.floor(totalSec / 3600);
|
|
const m = Math.floor((totalSec % 3600) / 60);
|
|
const s = totalSec % 60;
|
|
const dd = String(d).padStart(2, "0");
|
|
const hh = String(h).padStart(2, "0");
|
|
const mm = String(m).padStart(2, "0");
|
|
const ss = String(s).padStart(2, "0");
|
|
return `${dd}:${hh}:${mm}:${ss}`;
|
|
}
|
|
|
|
async function loadPins() {
|
|
const el = document.getElementById("pins-list");
|
|
try {
|
|
const pins = await api("/api/pins");
|
|
if (!pins.length) {
|
|
el.innerHTML = '<p class="empty-state">No PINs stored yet.</p>';
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<table class="pin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Label</th>
|
|
<th>Status</th>
|
|
<th>Countdown</th>
|
|
<th>Codes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${pins.map(p => `
|
|
<tr>
|
|
<td>#${p.id}</td>
|
|
<td>${esc(p.label) || "<span class='muted'>(none)</span>"}</td>
|
|
<td>${p.locked
|
|
? `<span class="badge badge-locked">LOCKED</span>`
|
|
: `<span class="badge badge-unlocked">UNLOCKED</span>`}</td>
|
|
<td class="countdown" id="cd-${p.id}" data-until="${p.lock_until}">${fmtCountdown(new Date(p.lock_until) - Date.now())}</td>
|
|
<td>${p.remaining_codes}/${4}</td>
|
|
<td>
|
|
<button class="btn btn-sm" onclick="accessPin(${p.id})">Access</button>
|
|
<button class="btn btn-sm" onclick="deletePin(${p.id}, '${esc(p.label)}')"
|
|
${p.revealed ? '' : 'disabled title="Must be revealed first"'}
|
|
style="${p.revealed ? 'color:var(--danger)' : 'color:var(--muted);opacity:0.4'}">
|
|
Del
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>`;
|
|
|
|
pins.forEach(p => {
|
|
if (pinTimers[p.id]) clearInterval(pinTimers[p.id]);
|
|
pinTimers[p.id] = setInterval(() => {
|
|
const cell = document.getElementById(`cd-${p.id}`);
|
|
if (!cell) {
|
|
clearInterval(pinTimers[p.id]);
|
|
return;
|
|
}
|
|
const remaining = new Date(p.lock_until) - Date.now();
|
|
cell.textContent = fmtCountdown(remaining);
|
|
}, 1000);
|
|
});
|
|
} catch (e) {
|
|
el.innerHTML = `<p class="error-msg">${esc(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
document.getElementById("add-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const label = document.getElementById("label").value;
|
|
const lockDays = parseInt(document.getElementById("lock-days").value) || 30;
|
|
|
|
try {
|
|
const result = await api("/api/pins", {
|
|
method: "POST",
|
|
body: JSON.stringify({ label, lock_days: lockDays }),
|
|
});
|
|
|
|
const content = document.getElementById("new-pin-content");
|
|
const pinId = "pin-" + result.id;
|
|
content.innerHTML = `
|
|
<p class="muted text-center" style="margin-bottom:0.3rem">Your PIN</p>
|
|
<div class="pin-display masked" id="${pinId}">••••</div>
|
|
<div class="text-center" style="margin-bottom:1rem">
|
|
<button class="btn btn-sm" id="${pinId}-btn" onclick="togglePin('${pinId}', '${result.pin}', '${pinId}-btn')">👁 Reveal PIN</button>
|
|
</div>
|
|
<div class="warning-box">
|
|
Save your recovery codes now. They will <strong>not</strong> be shown again.
|
|
</div>
|
|
<div class="recovery-list">
|
|
${result.recovery_codes.map((c, i) => `
|
|
<div class="recovery-item">
|
|
<strong>[${i + 1}]</strong>
|
|
<code id="rc-${i}">${c}</code>
|
|
<button class="copy-btn" onclick="copyText('rc-${i}')" title="Copy">📋</button>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
<p class="muted text-center" style="margin-top:0.75rem;font-size:0.8rem">
|
|
Access locked until: ${new Date(result.lock_until).toLocaleDateString()}
|
|
</p>
|
|
<div class="text-center" style="margin-top:1rem">
|
|
<button class="btn btn-primary" onclick="doPrint()">🖸 Print Sheet</button>
|
|
</div>`;
|
|
|
|
schedulePrint(result);
|
|
document.getElementById("new-pin-result").classList.remove("hidden");
|
|
document.getElementById("label").value = "";
|
|
loadPins();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
});
|
|
|
|
function accessPin(id) {
|
|
const title = document.getElementById("modal-title");
|
|
const body = document.getElementById("modal-body");
|
|
title.textContent = `Access PIN #${id}`;
|
|
body.innerHTML = `
|
|
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem">
|
|
Enter a recovery code to bypass the lock:
|
|
</p>
|
|
<form id="access-form">
|
|
<div class="form-group">
|
|
<textarea id="bypass-input" placeholder="Type 64-character recovery code here..." rows="2" required onpaste="return false"></textarea>
|
|
</div>
|
|
<div id="access-error" class="error-msg" style="margin-bottom:0.5rem"></div>
|
|
<button type="submit" class="btn btn-primary" style="width:100%">Unlock PIN</button>
|
|
</form>`;
|
|
document.getElementById("modal-overlay").classList.remove("hidden");
|
|
|
|
const bypassInput = document.getElementById("bypass-input");
|
|
bypassInput.addEventListener("paste", (e) => { e.preventDefault(); });
|
|
bypassInput.addEventListener("drop", (e) => { e.preventDefault(); });
|
|
|
|
document.getElementById("access-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const code = bypassInput.value.trim();
|
|
const errEl = document.getElementById("access-error");
|
|
errEl.textContent = "";
|
|
|
|
try {
|
|
const result = await api(`/api/pins/${id}/access`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ bypass_code: code }),
|
|
});
|
|
body.innerHTML = `
|
|
<div class="text-center">
|
|
<p class="muted" style="margin-bottom:0.3rem">Your PIN</p>
|
|
<div class="pin-display">${result.pin}</div>
|
|
<button class="btn btn-sm" onclick="copyTextDirect('${result.pin}')" style="margin-top:0.5rem">
|
|
Copy PIN 📋
|
|
</button>
|
|
</div>`;
|
|
loadPins();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function deletePin(id, label) {
|
|
const lbl = label || `#${id}`;
|
|
if (!confirm(`Delete PIN for "${lbl}"?`)) return;
|
|
try {
|
|
await api(`/api/pins/${id}`, { method: "DELETE" });
|
|
loadPins();
|
|
} catch (e) {
|
|
alert(e.message);
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById("modal-overlay").classList.add("hidden");
|
|
}
|
|
|
|
document.getElementById("modal-overlay").addEventListener("click", (e) => {
|
|
if (e.target.id === "modal-overlay") closeModal();
|
|
});
|
|
|
|
function copyText(elId) {
|
|
const text = document.getElementById(elId).textContent;
|
|
navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
function togglePin(elId, pin, btnId) {
|
|
const el = document.getElementById(elId);
|
|
const btn = document.getElementById(btnId);
|
|
if (el.classList.contains("masked")) {
|
|
el.textContent = pin;
|
|
el.classList.remove("masked");
|
|
btn.innerHTML = "👁 Hide PIN";
|
|
} else {
|
|
el.textContent = "\u2022\u2022\u2022\u2022";
|
|
el.classList.add("masked");
|
|
btn.innerHTML = "👁 Reveal PIN";
|
|
}
|
|
}
|
|
|
|
function copyTextDirect(text) {
|
|
navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
let _printData = null;
|
|
|
|
function schedulePrint(result) {
|
|
_printData = result;
|
|
}
|
|
|
|
function doPrint() {
|
|
if (!_printData) return;
|
|
const r = _printData;
|
|
const w = window.open("", "_blank", "width=600,height=600");
|
|
const dateStr = new Date(r.lock_until).toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric"});
|
|
const labelStr = r.label || "(no label)";
|
|
const codeRows = r.recovery_codes.map((c, i) =>
|
|
`<div class="code-row"><div class="code-num">${i+1}</div><div class="code-text">${c}</div></div>`
|
|
).join("\n");
|
|
w.document.write(`<!DOCTYPE html>
|
|
<html><head><title>PinVault - PIN #${r.id}</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:'Courier New',monospace;padding:0.5in}
|
|
.print-card{border:3px solid #000;border-radius:8px;padding:0.4in;max-width:5in}
|
|
.header{text-align:center;margin-bottom:0.3in}
|
|
.header h1{font-size:1.2rem;letter-spacing:0.1em;margin-bottom:0.1in}
|
|
.header .subtitle{font-size:0.65rem;color:#555}
|
|
.pin-section{text-align:center;margin:0.25in 0;padding:0.15in 0;border-top:1px dashed #999;border-bottom:1px dashed #999}
|
|
.pin-section .label{font-size:0.6rem;text-transform:uppercase;letter-spacing:0.15em;color:#333;margin-bottom:0.1in}
|
|
.pin-section .pin{font-size:2.5rem;font-weight:bold;letter-spacing:0.35rem}
|
|
.info{font-size:0.55rem;color:#666;text-align:center;margin:0.15in 0}
|
|
.codes-section{margin-top:0.2in}
|
|
.codes-section h2{font-size:0.7rem;text-transform:uppercase;letter-spacing:0.1em;margin-bottom:0.15in;text-align:center}
|
|
.code-row{display:flex;align-items:stretch;margin-bottom:0.12in;page-break-inside:avoid}
|
|
.code-num{font-size:0.7rem;font-weight:bold;width:0.6in;text-align:center;border-right:2px solid #000;padding:0.1in 0.05in;display:flex;align-items:center}
|
|
.code-text{flex:1;padding:0.1in 0.15in;font-size:0.72rem;word-break:break-all;line-height:1.4}
|
|
.footer{text-align:center;margin-top:0.25in;font-size:0.5rem;color:#999;border-top:1px dashed #ccc;padding-top:0.1in}
|
|
@page{size:letter;margin:0.5in}
|
|
@media print{body{padding:0}.print-card{border:2px solid #000}}
|
|
</style></head><body>
|
|
<div class="print-card">
|
|
<div class="header">
|
|
<h1>PINVAULT</h1>
|
|
<div class="subtitle">PIN #${r.id} · ${labelStr}</div>
|
|
</div>
|
|
<div class="pin-section">
|
|
<div class="label">Access PIN</div>
|
|
<div class="pin">${r.pin}</div>
|
|
</div>
|
|
<div class="info">Locked until: ${dateStr}</div>
|
|
<div class="codes-section">
|
|
<h2>Recovery Codes</h2>
|
|
${codeRows}
|
|
</div>
|
|
<div class="footer">One-time use. Store securely.</div>
|
|
</div></body></html>`);
|
|
w.document.close();
|
|
setTimeout(() => { w.print(); }, 400);
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
loadPins(); |