Moving the pointer across the keypad lit each key it crossed with a bright acid border and digit, and every filled dot glowed, which read as the pad flashing while a PIN was typed. Keys now only lighten slightly on hover and press in a little when clicked; dots fill without the glow.
188 lines
10 KiB
HTML
188 lines
10 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: center; padding: 16px; }
|
|
.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: scale(.97); 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; }
|
|
/* No glow, and keys only lighten on hover: a bright acid border lighting
|
|
up on whichever key the pointer crosses read as the pad flashing. */
|
|
.pinpad .pindot.on { background: var(--acid); border-color: var(--acid); }
|
|
.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 { transition: background-color .08s, transform .06s; }
|
|
.pinpad .pinkeys button:hover { background: rgb(from var(--surface2) calc(r + 10) calc(g + 10) calc(b + 12)); }
|
|
.pinpad .pinkeys button:active { transform: scale(.96); }
|
|
.pinpad .pinkeys button.util { background: transparent; font-size: 13px; color: var(--dim); }
|
|
/* Two lines reserved, so a message appearing never changes the dialog's
|
|
height (it is centred, so any height change moves the whole pad). */
|
|
.pinpad .pinerr { color: var(--danger); font-size: 12px; line-height: 16px; text-align: center; min-height: 32px; max-width: 220px; }
|
|
.pinpad .pinkeys.busy { opacity: .55; pointer-events: none; }
|
|
.promptbox.still { animation: none; }
|
|
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; line-height: 18px; min-height: 36px; 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 pinLength = 6; // 6-8; the PIN record says which, so the pad submits on the last digit
|
|
let busy = false;
|
|
let error = "";
|
|
|
|
function done(cancelled) {
|
|
if (!req) return;
|
|
const id = req.reqId;
|
|
req = null;
|
|
ui = null;
|
|
document.body.replaceChildren();
|
|
if (cancelled) window.unlock.cancel(id);
|
|
}
|
|
|
|
async function submit(value) {
|
|
if (!req || busy) return;
|
|
busy = true;
|
|
paint();
|
|
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; ui = null; document.body.replaceChildren(); return; }
|
|
error = (r && r.error) || "Could not unlock";
|
|
if (r && r.mode) mode = r.mode;
|
|
if (r && r.pinLength) pinLength = r.pinLength;
|
|
pin = "";
|
|
paint(); // re-renders only if the answer switched mode or PIN length
|
|
}
|
|
|
|
function press(d) {
|
|
if (busy) return;
|
|
if (d === "back") pin = pin.slice(0, -1);
|
|
else if (d === "clear") pin = "";
|
|
else if (pin.length < pinLength) pin += d;
|
|
error = "";
|
|
paint();
|
|
if (pin.length === pinLength) submit(pin);
|
|
}
|
|
|
|
// Keypresses and answers update the dialog in place. Rebuilding it on
|
|
// every press replayed its pop-in animation and re-centred it whenever the
|
|
// message changed height, so the pad jumped under the user's finger.
|
|
let ui = null; // { mode, pinLength, dots[], err, go }
|
|
function paint() {
|
|
if (!req) return;
|
|
if (!ui || ui.mode !== mode || ui.pinLength !== pinLength) return render();
|
|
if (ui.dots) ui.dots.forEach((d, i) => d.classList.toggle("on", i < pin.length));
|
|
ui.err.textContent = error;
|
|
if (ui.pad) ui.pad.classList.toggle("busy", busy);
|
|
if (ui.go) { ui.go.textContent = busy ? "Checking…" : "Unlock"; ui.go.disabled = busy; }
|
|
}
|
|
|
|
function render() {
|
|
if (!req) return;
|
|
const replaying = !!ui; // a mode switch inside the same prompt: no second pop-in
|
|
ui = { mode, pinLength };
|
|
const head = [
|
|
el("div", { class: "who" }, el("span", {}, "🔒"), el("span", { class: "addon" }, req.addonName || "Theseus"), el("span", {}, "·"),
|
|
el("span", {}, req.confirm ? "asks you to confirm it's you" : "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: pinLength }, (_, 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)));
|
|
ui.dots = [...dots.children];
|
|
ui.pad = pad;
|
|
ui.err = el("div", { class: "pinerr", role: "alert" }, error);
|
|
bodyEls = [el("div", { class: "pinpad" }, dots, pad, ui.err),
|
|
el("div", { class: "pact" },
|
|
el("button", { class: "link", type: "button", onclick: () => { mode = "password"; error = ""; pin = ""; 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", "");
|
|
ui.go = go;
|
|
ui.err = el("div", { class: "err", role: "alert" }, error);
|
|
const form = el("form", { onsubmit: (e) => { e.preventDefault(); if (input.value) submit(input.value); } }, input,
|
|
ui.err,
|
|
el("div", { class: "pact" },
|
|
req.pinSet && !(req.lockedMs > 0) && mode === "password" ? el("button", { class: "link", type: "button", onclick: () => { mode = "pin"; error = ""; pin = ""; 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: replaying ? "promptbox still" : "promptbox", role: "dialog", "aria-modal": "true" }, ...head, ...bodyEls)));
|
|
}
|
|
|
|
window.unlock.onShow((r) => {
|
|
req = r;
|
|
mode = r.pinSet && !(r.lockedMs > 0) ? "pin" : "password";
|
|
pinLength = r.pinLength || 6;
|
|
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;
|
|
ui = null; // a new prompt: build it fresh, with its pop-in
|
|
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>
|