feat: restructure settings into identity, server, backup and advanced tabs

Drops the per-server pin list down to the one server your account is
bound to, adds an enforced network timeout, and replaces the global
status toast inside settings with inline feedback and a reusable
confirm dialog for rotate/log out. Mail-fetching preferences move into
the server tab; the now-superseded claim/recall forms are gone since
onboarding covers both.
This commit is contained in:
randogoth 2026-10-08 21:24:05 +03:00
parent fb0ca5c8cc
commit 84397a3fdd
6 changed files with 270 additions and 334 deletions

View file

@ -80,15 +80,21 @@ const fmtRowTime = (seconds) => {
};
// A copy button beats asking the user to select 64 hex characters by hand.
// Flashes its own label rather than a toast — settings' rows need feedback
// that doesn't float away from the row that caused it.
function copyButton(text, what) {
return el("button", { type: "button", className: "copy", textContent: "copy", onclick: async () => {
try {
await navigator.clipboard.writeText(text);
status(`${what} copied to the clipboard`);
} catch {
status(`the clipboard is unavailable; select and copy ${what} by hand`, true);
}
} });
return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy",
onclick: async (event) => {
const button = event.currentTarget;
const resting = button.textContent;
try {
await navigator.clipboard.writeText(text);
button.textContent = "copied";
} catch {
button.textContent = "failed";
}
setTimeout(() => { button.textContent = resting; }, 1500);
} });
}
// The stored account as an address object, default port included when custom.
@ -136,8 +142,9 @@ async function connect(addr, { requirePin }) {
if (requirePin && !pinned)
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
`trusted channel, then pin it under settings → server.`);
const stream = await connectStream(addr.host, addr.port);
const opened = await openSession(stream, addr.host, pinned);
const timeoutMs = store.timeoutSeconds() * 1000;
const stream = await connectStream(addr.host, addr.port, timeoutMs);
const opened = await openSession(stream, addr.host, pinned, timeoutMs);
if (!pinned)
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
`Lookups from this session are UNVERIFIED.`, true);
@ -305,8 +312,9 @@ $("server-form").onsubmit = async (event) => {
// else a session proceeds unpinned, but here the whole point of the
// fetch is that nothing is pinned yet — the toast would just sit over
// the button that is about to pin it.
const stream = await connectStream(host, DEFAULT_PORT);
const opened = await openSession(stream, host, null);
const timeoutMs = store.timeoutSeconds() * 1000;
const stream = await connectStream(host, DEFAULT_PORT, timeoutMs);
const opened = await openSession(stream, host, null, timeoutMs);
opened.session.stream.close();
serverFetchedKey = { host, key: opened.serverStatic };
renderServerScreen();
@ -1053,7 +1061,7 @@ function openContact(address) {
textContent: known.verified ? "verified key" : "key pinned on first use" })),
field("fingerprint", fingerprint(known.key)),
field("public key", b32encode(known.key)),
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")),
field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")),
el("div", { className: "seed-card" },
el("h4", { textContent: "accept token" }),
el("p", { className: "hint", textContent: isAccepted
@ -1181,7 +1189,7 @@ for (const button of settingsTabs) button.onclick = () => showTab(button.dataset
function openSettings() {
renderSettings();
showTab("account");
showTab("identity");
if (!settings.open) settings.showModal();
// showModal() focuses the first focusable node, which is the close button;
// the current tab says where you are instead.
@ -1199,106 +1207,94 @@ $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.c
// call this rather than reason about which panel it touched.
function renderSettings() {
$("leave-on-server").checked = store.leaveOnServer();
renderAccountPanel();
$("backup-error").textContent = "";
renderIdentityPanel();
renderServerPanel();
renderAdvancedPanel();
}
function renderServerPanel() {
const pins = store.allPins();
$("pins").replaceChildren(...pins.map(({ host, key }) =>
el("li", {},
el("span", { textContent: host }),
el("span", { className: "key", textContent: key }),
el("button", { type: "button", textContent: "remove",
onclick: () => { store.unpinServer(host); renderSettings(); } }))));
if (!pins.length)
$("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" }));
// A reusable Cancel/confirm modal for the advanced panel's two irreversible
// actions. <dialog>'s own cancel path (Escape, backdrop click) counts as
// Cancel, same as clicking the button.
function confirmDialog({ title, body, confirmLabel, danger = false }) {
return new Promise((resolve) => {
$("confirm-title").textContent = title;
$("confirm-text").textContent = body;
const ok = $("confirm-ok");
ok.textContent = confirmLabel;
ok.className = danger ? "danger" : "filled";
const dialog = $("confirm");
let decided = false;
const finish = (result) => { decided = true; resolve(result); dialog.close(); };
$("confirm-cancel").onclick = () => finish(false);
ok.onclick = () => finish(true);
dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true });
dialog.showModal();
});
}
function renderAccountPanel() {
function renderServerPanel() {
const account = store.account();
const host = account?.host ?? null;
const key = host ? store.serverPin(host) : null;
$("server-connection").replaceChildren(
field("Host", host ?? "not registered yet", !host, host ? copyButton(host, "host") : null),
field("Public Key", key ? b32encode(key) : "not pinned yet", !key,
key ? copyButton(b32encode(key), "public key") : null),
el("div", { className: "spin-row" },
el("div", { className: "spin-text" },
el("span", { className: "check-title", textContent: "Timeout" }),
el("span", { className: "hint", textContent: "Seconds before a network call gives up" })),
el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(),
onchange: (event) => {
store.setTimeoutSeconds(event.target.value);
event.target.value = store.timeoutSeconds(); // reflects clamping or an ignored non-numeric edit
} })));
}
function renderIdentityPanel() {
const me = store.identity();
const account = store.account();
// Unregistered, claiming an address is the whole point of this panel;
// registered, it is a rarely wanted escape hatch, so it folds away.
$("account-claim").open = !account;
$("account-claim-summary").textContent = account ? "use a different address" : "claim your address";
const card = $("account-card");
card.replaceChildren();
const secret = $("identity-card");
const fields = $("identity-fields");
fields.replaceChildren();
const secret = $("identity-secret");
secret.replaceChildren();
if (!me) return;
const share = account ? accountAddress().uri(me.publicKey) : null;
card.append(
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
field("fingerprint", fingerprint(me.publicKey)),
field("share", share ?? "claim an address to get one", !share,
share ? copyButton(share, "your address") : null));
const address = account ? `${account.user}@${account.host}` : "not registered yet";
const key = b32encode(me.publicKey);
const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one";
fields.append(
field("Address", address, !account, account ? copyButton(address, "address") : null),
field("Key", key, false, copyButton(key, "key")),
field("Fingerprint", fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")),
field("URI", uri, !account, account ? copyButton(uri, "uri") : null));
const masterBytes = store.master();
if (!masterBytes) return;
// Revealed on request rather than on open: the panel a new user is sent to
// first is a poor place to paint the only secret there is across the screen.
const masked = "\u2022".repeat(64);
const master = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
const showing = master.textContent !== masked;
master.textContent = showing ? masked : hex(masterBytes);
reveal.textContent = showing ? "reveal" : "hide";
} });
const seedText = hex(masterBytes);
const seed = el("div", { className: "seed", textContent: masked });
const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret",
onclick: (event) => {
const showing = seed.textContent !== masked;
seed.textContent = showing ? masked : seedText;
event.currentTarget.textContent = showing ? "reveal" : "hide";
event.currentTarget.title = showing ? "reveal the recovery secret" : "hide the recovery secret";
} });
secret.append(
el("div", { className: "seed-card" },
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the secret")),
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
"it loses the address with it — there is no reset. Keep it in a password manager." })),
el("label", { className: "check-row" },
el("input", { type: "checkbox", checked: store.masterSaved(), onchange: (event) => {
store.setMasterSaved(event.target.checked);
} }),
el("span", {},
el("span", { className: "check-title", textContent: "I have saved it somewhere safe" }),
el("span", { className: "hint", textContent: "Only ticks the setup checklist off — " +
"nothing here can check, so say so once it is true." }))));
secret.append(el("div", { className: "seed-card" },
el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")),
el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " +
"it loses the address with it — there is no reset. Keep it in a password manager." })));
}
function renderAdvancedPanel() {
const card = $("advanced-card");
card.replaceChildren();
const me = store.identity();
if (!me) return;
const rotations = store.rotations();
const acceptedCount = store.allAccepted().filter(a => a.active).length;
card.append(...[
el("h3", { textContent: "this identity" }),
field("rotations", String(rotations)),
field("accepted", String(acceptedCount) +
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
rotations ? el("p", { className: "hint", textContent:
"Earlier keys are re-derived on demand from your secret, so mail sealed to a superseded key " +
"stays readable." }) : null,
el("h3", { textContent: "rotate" }),
el("p", { className: "hint", textContent:
"Issues a fresh key — the next one derivable from your secret — with a signed " +
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
"the old key stays readable. Rotation is not revocation." }),
el("button", { type: "button", className: "outline", textContent: "rotate identity",
onclick: rotateIdentity }),
el("h3", { className: "danger-head", textContent: "log out" }),
el("p", { className: "hint", textContent:
"Removes this identity from this browser entirely: the secret, pins, contacts and every " +
"cached message. Without a backup of the secret there is no way back in. Mail already " +
"fetched here may have no other copy anywhere, unless \u201cleave mail on server\u201d was on; " +
"whatever is still on the server comes back on the first fetch after logging back in." }),
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
onclick: logout }),
].filter(node => node != null));
$("rotations-count").replaceChildren(field("Completed", String(store.rotations())));
$("rotate-error").textContent = "";
$("logout-error").textContent = "";
}
// A pin is keyed by bare host — connect() strips the port before looking one
@ -1316,30 +1312,6 @@ function cleanHost(hostText) {
return host;
}
// Direct, deliberate entry of a specific key (unlike importData's
// conflict-averse merge), so overwrite is the expected behavior here, not
// something to guard against.
function pinServerFromText(hostText, keyText) {
const host = cleanHost(hostText);
const key = b32decode(keyText.trim());
if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`);
store.pinServer(host, key);
return host;
}
$("pin-form").onsubmit = (event) => {
event.preventDefault();
const form = event.target;
try {
const host = pinServerFromText(form.host.value, form.key.value);
status(`pinned ${host}`);
renderSettings();
form.reset();
} catch (err) {
status(err.message, true);
}
};
$("import-form").onsubmit = (event) => {
event.preventDefault();
const form = event.target;
@ -1356,64 +1328,10 @@ $("import-form").onsubmit = (event) => {
}
};
$("register-form").onsubmit = async (event) => {
event.preventDefault();
const form = event.target;
const submit = form.querySelector("button");
const resting = submit.textContent;
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
submit.textContent = "registering…";
try {
const me = store.identity();
if (!me) throw new SmolError("no identity yet");
const addr = parseAddress(form.address.value.trim());
const opened = await connect(addr, { requirePin: true });
try {
await registerOp(opened.session, opened.serverStatic,
{ username: addr.user, identity: me, token: form.token.value.trim() });
} finally {
opened.session.stream.close();
}
store.setAccount(addr);
status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
renderAccount();
renderSettings();
form.reset();
} catch (err) {
status(err.message, true);
} finally {
submit.disabled = false;
submit.textContent = resting;
}
};
// Restoring a master brings back the identity, not the memory of what address a
// *different* browser registered it under — "account" is local-only state,
// never asked of the server. This binds it without REGISTER: RESOLVE the
// address and require it name this exact key, so a typo or someone else's
// address can't silently misfile outgoing Reply-To / fetch under it. Shared
// by the settings recall form and the optional address field on restore.
async function recallAccount(text) {
const me = store.identity();
if (!me) throw new SmolError("no identity yet");
const addr = parseAddress(text.trim());
const opened = await connect(addr, { requirePin: true });
let current;
try {
({ identity: current } = await resolveOp(opened.session, addr.user));
} finally {
opened.session.stream.close();
}
if (!timingSafeEqual(current, me.publicKey))
throw new SmolError(`${addr.short} resolves to a different key — not this identity`);
store.setAccount(addr);
return addr;
}
// §2: a master alone does not say which rotation index a server has bound,
// so restoring resolves the address and walks indices 0..MAX_CHAIN until one
// derives the key RESOLVE returned, correcting restore-identity's default
// guess of index 0. Also binds "account", like recallAccount().
// guess of index 0. Also binds "account" — onboarding's only path to it now.
async function recallAndDiscoverRotation(masterBytes, text) {
const addr = parseAddress(text.trim());
const opened = await connect(addr, { requirePin: true });
@ -1438,80 +1356,89 @@ async function recallAndDiscoverRotation(masterBytes, text) {
return addr;
}
$("recall-form").onsubmit = async (event) => {
event.preventDefault();
const form = event.target;
const submit = form.querySelector("button");
const resting = submit.textContent;
submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
submit.textContent = "recalling…";
try {
const me = store.identity();
const addr = await recallAccount(form.address.value);
status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
renderAccount();
renderSettings();
form.reset();
} catch (err) {
status(err.message, true);
} finally {
submit.disabled = false;
submit.textContent = resting;
}
};
// The File System Access API gives a real save dialog with a suggested name;
// where it is unavailable (Firefox, Safari), a plain download is the
// equivalent a browser can offer. Either way nothing is shown on success —
// the file landing where the user put it is the confirmation.
$("export-data").onclick = async () => {
$("backup-error").textContent = "";
try {
const data = await store.exportData();
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
URL.revokeObjectURL(url);
// No counts in the message: the file itself is sealed precisely so an
// observer learns nothing from it, and a summary here would just move
// that same leak from the file to the screen right next to it.
status("exported — sealed to this identity; only a browser with this master restored can open it");
const filename = `gsmol-export-${stamp}.json`;
const text = JSON.stringify(data);
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({ suggestedName: filename,
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
const writable = await handle.createWritable();
await writable.write(text);
await writable.close();
} else {
const blob = new Blob([text], { type: "application/json" });
const url = URL.createObjectURL(blob);
Object.assign(document.createElement("a"), { href: url, download: filename }).click();
URL.revokeObjectURL(url);
}
} catch (err) {
status(err.message, true);
if (err.name !== "AbortError") $("backup-error").textContent = err.message; // AbortError: picker cancelled
}
};
$("import-data-trigger").onclick = () => $("import-data").click();
// Shared by both the File System Access path and the <input type=file>
// fallback it reduces to where that API is unavailable.
async function importBackupText(text) {
if (!text.trim()) return; // empty or whitespace-only content does nothing
await store.importData(JSON.parse(text));
renderSettings();
renderFolder();
}
$("import-data-trigger").onclick = async () => {
$("backup-error").textContent = "";
if (!window.showOpenFilePicker) return $("import-data").click();
try {
const [handle] = await window.showOpenFilePicker({
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
await importBackupText(await (await handle.getFile()).text());
} catch (err) {
if (err.name !== "AbortError") $("backup-error").textContent = err.message;
}
};
$("import-data").onchange = async (event) => {
const file = event.target.files[0];
event.target.value = ""; // same file re-selected twice must still fire change
if (!file) return;
try {
const data = JSON.parse(await file.text());
const s = await store.importData(data);
const conflicts = s.pinsConflicted + s.contactsConflicted;
status([
`imported ${s.mailAdded} message(s), ${s.pinsAdded} server key(s), ${s.contactsAdded} contact(s)`,
conflicts ? `${conflicts} entr${conflicts === 1 ? "y" : "ies"} already known under a ` +
"different key — kept what was already here, skipped the import's version" : null,
s.malformed ? `${s.malformed} malformed entr${s.malformed === 1 ? "y" : "ies"} skipped` : null,
].filter(Boolean).join("\n"), Boolean(conflicts || s.malformed));
renderSettings();
renderFolder();
await importBackupText(await file.text());
} catch (err) {
status(`import failed: ${err.message}`, true);
$("backup-error").textContent = err.message;
}
};
// A confirm dialog's body repeats its group's description verbatim, read
// straight from the panel rather than duplicated here so the two can never
// drift apart. textContent carries the HTML source's line-wrap whitespace,
// which this collapses back to plain prose.
const panelText = (id) => $(id).textContent.replace(/\s+/g, " ").trim();
// §7: rotate to the next index's derived key and rebind the account with a
// signed certificate. The superseded key stays derivable from the master,
// since mail sealed to it stays readable with nothing else.
async function rotateIdentity(event) {
async function rotateIdentity() {
const me = store.identity();
const master = store.master();
const addr = accountAddress();
if (!me || !master || !addr) return status("rotate needs a registered account", true);
if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " +
"chain. The old key is kept, since mail sealed to it stays readable with nothing else."))
$("rotate-error").textContent = "";
if (!me || !master || !addr) {
$("rotate-error").textContent = "rotate needs a registered account";
return;
const button = event.target;
}
const confirmed = await confirmDialog({
title: "Rotate the identity?", body: panelText("rotations-description"), confirmLabel: "Rotate" });
if (!confirmed) return;
const button = $("rotate-identity");
const resting = button.textContent;
button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
button.textContent = "rotating…";
try {
@ -1527,27 +1454,32 @@ async function rotateIdentity(event) {
store.advanceRotation();
// The key set grew, so a cached "not one of our keys" failure may be stale.
openedCache.clear();
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount();
renderSettings(); // rebuilds the advanced panel, this button included
renderSettings();
renderFolder();
} catch (err) {
$("rotate-error").textContent = err.message;
} finally {
button.disabled = false;
button.textContent = "rotate identity";
status(err.message, true);
button.textContent = resting;
}
}
$("rotate-identity").onclick = rotateIdentity;
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
async function logout() {
if (!window.confirm(
"Log out and erase this browser?\n\n" +
"This deletes the secret, pins, contacts and every cached message from this browser. Make sure " +
"the secret is backed up elsewhere first — without it there is no way back in."
)) return;
await store.clearAll();
location.reload();
$("logout-error").textContent = "";
const confirmed = await confirmDialog({ title: "Log out and delete all data?",
body: panelText("logout-description"), confirmLabel: "Log Out", danger: true });
if (!confirmed) return;
try {
await store.clearAll();
location.reload();
} catch (err) {
$("logout-error").textContent = err.message;
}
}
$("logout").onclick = logout;
// --- boot --------------------------------------------------------------------------