The PIN could only be six digits and was set from three bare inputs; the unlock prompt sat at the top of the page; and the password manager only filled when you found the key chip, never offered to save, and "Clear cookies on quit" signed you out of every site, including the ones whose login the vault already holds. - PINs are 6 to 8 digits. The PIN record stores its length so pads draw the right number of dots and submit on the last digit; a PIN of the wrong length is refused without a strike, so an older Aegis pad cannot burn the count against an 8-digit PIN. - Settings sets a PIN in steps: master password, choose the PIN on a pad (6/7/8), repeat it, done. The locked vault opens Theseus's own prompt, which is now centred, with the PIN pad or the master password field. - After a sign-in or sign-up form is sent and the page moves on, Theseus offers to save (or update) the login, with an optional "ask for my PIN or password before filling it". Focusing a login form offers the saved logins under it; on a locked vault it offers to unlock first. A failed login (the password field still showing) gets no offer. - "Keep sign-ins for sites in your vault" (on): the quit clear spares the cookies and site storage of sites with a saved login. Their hostnames are kept sealed with the OS keystore so the list is readable at quit while the vault is locked. Verified end to end on a scratch profile: signed in, restarted, still signed in; another site's cookie was cleared.
159 lines
8.5 KiB
HTML
159 lines
8.5 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; }
|
|
.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 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;
|
|
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;
|
|
if (r && r.pinLength) pinLength = r.pinLength;
|
|
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 < pinLength) pin += d;
|
|
error = "";
|
|
render();
|
|
if (pin.length === pinLength) 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", {}, 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)));
|
|
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";
|
|
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;
|
|
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>
|