Theseus and Aegis each wrapped the same master password under their
own PIN: two offline targets, two guess budgets, and two PINs to keep
in step. The vault PIN is now the only one. Built-in add-ons get
api.vault.pin {status, unlock, set, clear} (advertised by
features.vaultPin); unlock(pin) opens the vault in main and answers
only { ok } or why not, so the master password stays in main.
The policy is the one Aegis's PIN screens describe: five wrong PINs
lock the PIN for 15 minutes, every further wrong one locks it again,
and the master password always works. The unlock prompt uses the same
PIN pad and the same wording as Aegis, and Settings says so.
155 lines
8.3 KiB
HTML
155 lines
8.3 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Unlock</title>
|
|
<style>
|
|
:root { color-scheme: light dark;
|
|
--surface:#1c222c; --surface2:#0f1621; --line:rgba(255,255,255,.12);
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; }
|
|
@media (prefers-color-scheme: light) {
|
|
:root { --surface:#ffffff; --surface2:#f1f4fa; --line:rgba(0,0,0,.12);
|
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid: #0AC18E; }
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; background: transparent; }
|
|
body { font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: var(--ink); }
|
|
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
|
display: grid; place-items: start center; padding-top: 48px; }
|
|
.promptbox { background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
|
padding: 16px 18px 14px; width: min(380px, calc(100vw - 32px));
|
|
box-shadow: 0 20px 60px #000d; animation: pop .12s ease-out; }
|
|
@keyframes pop { from { transform: translateY(-6px); opacity: 0; } to { transform: none; opacity: 1; } }
|
|
.who { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 11.5px; margin-bottom: 8px; }
|
|
.who .addon { color: var(--mut); }
|
|
.title { font-size: 15px; font-weight: 650; margin: 0 0 4px; }
|
|
.reason { color: var(--mut); margin: 0 0 14px; }
|
|
/* The same PIN pad as Aegis (panel.html .pinpad): one PIN, one screen. */
|
|
.pinpad { display: flex; flex-direction: column; align-items: center; gap: 14px; margin: 6px 0 12px; }
|
|
.pinpad .pindots { display: flex; gap: 12px; }
|
|
.pinpad .pindot { width: 12px; height: 12px; border-radius: 50%; border: 1.5px solid var(--dim);
|
|
background: transparent; transition: background .12s, border-color .12s; }
|
|
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid);
|
|
box-shadow: 0 0 6px rgb(from var(--acid) r g b / .5); }
|
|
.pinpad .pinkeys { display: grid; grid-template-columns: repeat(3, 62px); gap: 8px; }
|
|
.pinpad .pinkeys button { height: 46px; border-radius: 10px; border: 1px solid var(--line);
|
|
background: var(--surface2); color: var(--ink); font: 500 18px system-ui, sans-serif; cursor: pointer; }
|
|
.pinpad .pinkeys button:hover { border-color: var(--acid); color: var(--acid); }
|
|
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
|
|
.pinpad .pinerr { color: var(--danger); font-size: 12px; text-align: center; min-height: 16px; }
|
|
input[type=password] { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line);
|
|
background: var(--surface2); color: var(--ink); font: inherit; font-size: 14px; margin-bottom: 10px; }
|
|
input:focus, button:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
|
|
.err { color: var(--danger); font-size: 12.5px; min-height: 18px; margin-bottom: 6px; text-align: center; }
|
|
.pact { display: flex; gap: 6px; justify-content: space-between; align-items: center; }
|
|
.link { background: none; border: none; color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px; padding: 0; text-decoration: underline; }
|
|
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
|
|
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
|
|
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
|
|
.pbtn:disabled { opacity: .5; cursor: default; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script>
|
|
// Built with createElement/textContent only: the add-on supplies the
|
|
// reason text, and it must never be able to inject markup into a prompt
|
|
// that asks for the master password.
|
|
const el = (tag, attrs = {}, ...kids) => {
|
|
const n = document.createElement(tag);
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|
if (k === "class") n.className = v;
|
|
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
|
|
else n.setAttribute(k, v);
|
|
}
|
|
for (const c of kids.flat()) if (c != null && c !== false) n.append(c instanceof Node ? c : String(c));
|
|
return n;
|
|
};
|
|
let req = null;
|
|
let mode = "pin";
|
|
let pin = "";
|
|
let busy = false;
|
|
let error = "";
|
|
|
|
function done(cancelled) {
|
|
if (!req) return;
|
|
const id = req.reqId;
|
|
req = null;
|
|
document.body.replaceChildren();
|
|
if (cancelled) window.unlock.cancel(id);
|
|
}
|
|
|
|
async function submit(value) {
|
|
if (!req || busy) return;
|
|
busy = true;
|
|
render();
|
|
const r = await window.unlock.submit(req.reqId, mode, value).catch((e) => ({ ok: false, error: e.message }));
|
|
busy = false;
|
|
if (r && r.ok) { req = null; document.body.replaceChildren(); return; }
|
|
error = (r && r.error) || "Could not unlock";
|
|
if (r && r.mode) mode = r.mode;
|
|
pin = "";
|
|
render();
|
|
}
|
|
|
|
function press(d) {
|
|
if (busy) return;
|
|
if (d === "back") pin = pin.slice(0, -1);
|
|
else if (d === "clear") pin = "";
|
|
else if (pin.length < 6) pin += d;
|
|
error = "";
|
|
render();
|
|
if (pin.length === 6) submit(pin);
|
|
}
|
|
|
|
function render() {
|
|
if (!req) return;
|
|
const head = [
|
|
el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"), el("span", {}, "asks to unlock your vault")),
|
|
el("h1", { class: "title" }, mode === "pin" ? "Enter your PIN" : "Enter your master password"),
|
|
req.reason ? el("p", { class: "reason" }, req.reason) : null,
|
|
];
|
|
let bodyEls;
|
|
if (mode === "pin") {
|
|
const dots = el("div", { class: "pindots" }, ...Array.from({ length: 6 }, (_, i) => el("span", { class: i < pin.length ? "pindot on" : "pindot" })));
|
|
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
|
|
const pad = el("div", { class: "pinkeys" }, ...keys.map((k) =>
|
|
el("button", { type: "button", class: k === "back" || k === "clear" ? "util" : "", onclick: () => press(k), "aria-label": k === "back" ? "Delete" : k === "clear" ? "Clear" : k },
|
|
k === "back" ? "⌫" : k === "clear" ? "Clear" : k)));
|
|
bodyEls = [el("div", { class: "pinpad" }, dots, pad, el("div", { class: "pinerr" }, error)),
|
|
el("div", { class: "pact" },
|
|
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; render(); } }, "Use master password"),
|
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"))];
|
|
} else {
|
|
const input = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
|
|
const go = el("button", { class: "pbtn primary", type: "submit" }, busy ? "Checking…" : "Unlock");
|
|
if (busy) go.setAttribute("disabled", "");
|
|
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
|
el("div", { class: "err" }, error),
|
|
el("div", { class: "pact" },
|
|
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; render(); } }, "Use PIN") : el("span"),
|
|
el("div", { style: "display:flex;gap:6px" },
|
|
el("button", { class: "pbtn", type: "button", onclick: () => done(true) }, "Cancel"), go)));
|
|
bodyEls = [form];
|
|
setTimeout(() => input.focus(), 0);
|
|
}
|
|
document.body.replaceChildren(el("div", { class: "promptmask", onmousedown: (e) => { if (e.target.classList.contains("promptmask")) done(true); } },
|
|
el("div", { class: "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
|
|
}
|
|
|
|
window.unlock.onShow((r) => {
|
|
req = r;
|
|
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
|
pin = "";
|
|
error = r.pinSet && r.lockedMs > 0 ? `Too many failed attempts. Try again in ${Math.max(1, Math.ceil(r.lockedMs / 60000))} min or use the master password.` : "";
|
|
busy = false;
|
|
render();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (!req) return;
|
|
if (e.key === "Escape") return done(true);
|
|
if (mode === "pin" && /^[0-9]$/.test(e.key)) press(e.key);
|
|
else if (mode === "pin" && e.key === "Backspace") press("back");
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|