321 lines
12 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')">&#x1f441; 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">&#x1f4cb;</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()">&#x1f5b8; Print Sheet</button>
</div>`;
schedulePrint(result);
document.getElementById("new-pin-result").classList.remove("hidden");
document.getElementById("label").value = "";
loadPins();
} catch (err) {
alert(err.message);
}
});
async function accessPin(id) {
const title = document.getElementById("modal-title");
const body = document.getElementById("modal-body");
title.textContent = `Access PIN #${id}`;
const pins = await api("/api/pins");
const pin = pins.find(p => p.id === id);
if (pin && !pin.locked) {
body.innerHTML = `
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem">
This PIN is unlocked and ready to view.
</p>
<button id="reveal-btn" class="btn btn-primary" style="width:100%">Reveal PIN</button>`;
document.getElementById("modal-overlay").classList.remove("hidden");
document.getElementById("reveal-btn").addEventListener("click", async () => {
try {
const result = await api(`/api/pins/${id}/access`, {
method: "POST",
body: JSON.stringify({}),
});
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 &#x1f4cb;
</button>
</div>`;
loadPins();
} catch (err) {
body.innerHTML = `<p class="error-msg">${esc(err.message)}</p>`;
}
});
} else {
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 &#x1f4cb;
</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 = "&#x1f441; Hide PIN";
} else {
el.textContent = "\u2022\u2022\u2022\u2022";
el.classList.add("masked");
btn.innerHTML = "&#x1f441; 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} &middot; ${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();