theseus/approval.html

118 lines
7.9 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Approval</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;
/* Darker acid for light backgrounds — ~5.5:1 on white. */
--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(460px, 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; letter-spacing: .1px; margin: 0 0 10px; }
.origin { display: inline-flex; align-items: center; gap: 8px; max-width: 100%;
background: var(--surface2); border: 1px solid rgb(from var(--acid) r g b / .35); color: var(--acid);
border-radius: 8px; padding: 6px 10px; margin-bottom: 12px;
font: 13px/1.3 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
.origin .lbl { color: var(--dim); font: 11px system-ui, sans-serif; white-space: nowrap; }
.body { white-space: pre-wrap; color: var(--ink); margin-bottom: 12px; }
.rows { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin-bottom: 12px;
background: var(--surface2); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
.rows .k { color: var(--dim); font-size: 12px; white-space: nowrap; }
.rows .v { word-break: break-all; }
.rows .v.mono { font: 12.5px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
.rows .v.strong { font-weight: 650; font-size: 14px; }
/* A long message or a long list of rows scrolls inside the box; the
origin stays at the top and the buttons stay on screen. Without this a
dapp could push Approve and the end of a message out of view. */
.promptbox { max-height: calc(100vh - 64px); overflow-y: auto; }
.body { max-height: 30vh; overflow-y: auto; }
.rows .v { max-height: 40vh; overflow-y: auto; white-space: pre-wrap; }
.pact { position: sticky; bottom: -14px; background: var(--surface); padding: 8px 0 2px; }
label.chk { display: flex; align-items: center; gap: 8px; color: var(--mut); font-size: 12.5px; margin-bottom: 12px; cursor: pointer; }
label.chk select { flex: 1; padding: 5px 8px; border-radius: 6px; background: var(--surface2); color: var(--ink);
border: 1px solid var(--line); font: inherit; font-size: 12.5px; }
.pact { display: flex; gap: 6px; justify-content: flex-end; }
.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:hover { border-color: rgb(from var(--acid) r g b / .35); }
.pbtn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn.danger { background: var(--danger); color: #0b0e14; border-color: transparent; font-weight: 650; }
.pbtn:focus-visible { outline: 2px solid rgb(from var(--acid) r g b / .6); outline-offset: 1px; }
</style>
</head>
<body>
<script>
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" })[c]);
let current = null;
function finish(action, checked) {
if (!current) return;
const { reqId } = current;
const extra = document.getElementById("sel") ? document.getElementById("sel").value : "";
current = null;
document.body.innerHTML = "";
window.approval.pick(reqId, action, checked, extra);
}
window.approval.onShow((req) => {
current = req;
const rows = Array.isArray(req.rows) ? req.rows : [];
const actions = Array.isArray(req.actions) && req.actions.length ? req.actions : [{ id: "ok", label: "OK", primary: true }];
const hasCancel = actions.some((a) => a.id === "cancel");
document.body.innerHTML =
`<div class="promptmask"><div class="promptbox" role="dialog" aria-modal="true">
Vault: PIN setup steps, 6-8 digit PINs, save and offer logins, keep sign-ins 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.
2026-10-04 20:23:43 +02:00
${req.builtin
? `<div class="who"><span>🔑</span><span class="addon">${esc(req.addonName || "Theseus")}</span></div>`
: `<div class="who"><span>🧩</span><span class="addon">${esc(req.addonName || req.addonId)}</span><span>·</span><span>asks for your approval</span></div>`}
<h1 class="title">${esc(req.title || "Approve?")}</h1>
${req.origin ? `<div class="origin"><span class="lbl">from</span><span>${esc(req.origin)}</span></div>` : ""}
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
${rows.length ? `<div class="rows">${rows.map((r) => `<div class="k">${esc(r.label)}</div><div class="v${r.mono ? " mono" : ""}${r.strong ? " strong" : ""}">${esc(r.value)}</div>`).join("")}</div>` : ""}
${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"> ${esc(req.checkbox.label || "Always allow")}</label>` : ""}
${req.select ? `<label class="chk"><span>${esc(req.select.label)}</span><select id="sel">${req.select.options.map((o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`).join("")}</select></label>` : ""}
<div class="pact">
${hasCancel ? "" : `<button class="pbtn" type="button" data-id="cancel">Cancel</button>`}
${actions.map((a) => `<button class="pbtn${a.primary ? " primary" : ""}${a.danger ? " danger" : ""}" type="button" data-id="${esc(a.id)}">${esc(a.label || a.id)}</button>`).join("")}
</div>
</div></div>`;
const mask = document.querySelector(".promptmask");
mask.addEventListener("mousedown", (e) => { if (e.target === mask) finish("cancel", false); });
// Nothing but Cancel works for the first moment. A page can raise this
// overlay without a gesture and knows where the primary button will be,
// so "double-click here" put the second click on Send. A click has to
// arrive after the dialog has been on screen long enough to be seen.
const ARM_MS = 800;
const armAt = Date.now() + ARM_MS;
const acts = Array.from(document.querySelectorAll('button[data-id]')).filter((b) => b.dataset.id !== "cancel");
acts.forEach((b) => { b.disabled = true; b.style.opacity = "0.45"; });
setTimeout(() => { if (current === req) acts.forEach((b) => { b.disabled = false; b.style.opacity = ""; }); }, ARM_MS);
document.querySelectorAll("button[data-id]").forEach((b) => {
b.addEventListener("click", () => {
if (b.dataset.id !== "cancel" && Date.now() < armAt) return;
finish(b.dataset.id, !!document.getElementById("chk")?.checked);
});
});
// Focus the non-destructive default so Enter never blindly approves a
// spend; the user has to tab or click onto the primary action.
const first = document.querySelector('button[data-id="cancel"]') || document.querySelector("button");
setTimeout(() => first && first.focus(), 0);
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") finish("cancel", false); });
</script>
</body>
</html>