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

@ -152,9 +152,9 @@
</form>
</dialog>
<!-- Tabs, not one scroll: the account, the server, a mailbox preference, the
backup file and the irreversible operations have nothing to say to each
other, and reading all five at once was the overwhelming part. -->
<!-- Tabs, not one scroll: identity, server, the backup file and the
irreversible operations have nothing to say to each other, and reading
all four at once was the overwhelming part. -->
<dialog id="settings">
<header class="dialog-head">
<h2>settings</h2>
@ -164,97 +164,85 @@
<div class="settings-body">
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
<button type="button" data-tab="account" class="active" aria-current="true">account</button>
<button type="button" data-tab="identity" class="active" aria-current="true">identity</button>
<button type="button" data-tab="server">server</button>
<button type="button" data-tab="mail">mail</button>
<button type="button" data-tab="data">backup</button>
<button type="button" data-tab="backup">backup</button>
<button type="button" data-tab="advanced">advanced</button>
</nav>
<div id="settings-panels" class="settings-panels">
<section class="panel" data-panel="account">
<div id="account-card"></div>
<details id="account-claim">
<summary id="account-claim-summary">claim your address</summary>
<p class="hint">Pick a username on a server you have pinned. This is what people write to.</p>
<form id="register-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<label>invite token<input name="token" placeholder="only if your server asks"></label>
<button class="filled">register</button>
</form>
<p class="hint">Already registered this identity in another browser? Look the address up
instead — this checks it resolves to this identity and claims nothing.</p>
<form id="recall-form">
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
<button>look it up</button>
</form>
</details>
<section class="panel" data-panel="identity">
<div id="identity-fields"></div>
<h3>recovery secret</h3>
<div id="identity-card"></div>
<div id="identity-secret"></div>
</section>
<section class="panel" data-panel="server" hidden>
<h3>your server</h3>
<p class="hint">Pinning a server's public key is what lets this client tell your real server
from anything pretending to be it. Ask its operator for the key over a channel you already
trust.</p>
<details>
<summary>why does this matter?</summary>
<p class="hint">Nothing else authenticates the server, so an unpinned host could be anyone —
registering, looking up an address and fetching all refuse to run against one.</p>
</details>
<ul id="pins" class="kv"></ul>
<form id="pin-form">
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
<button class="filled">pin</button>
</form>
</section>
<h3>connection</h3>
<div id="server-connection"></div>
<section class="panel" data-panel="mail" hidden>
<h3>fetching</h3>
<label class="check-row">
<input type="checkbox" id="leave-on-server">
<span>
<span class="check-title">leave mail on server</span>
<span class="hint">Keep a copy on the server after fetching, so another browser can
fetch it too. Deleting a message here removes it from both.</span>
<span class="check-title">Leave mail on server</span>
<span class="hint">Keep a copy on the server after fetching, so another client can fetch
it too. When this is enabled, deleting a message removes it from this client and the
server.</span>
</span>
</label>
<details>
<summary>what happens by default?</summary>
<p class="hint">Fetching acknowledges each message once it has been verified, and the server
drops its copy — so mail you have read lives only in this browser. Back it up under
"backup", or turn the option above on.</p>
</details>
</section>
<section class="panel" data-panel="data" hidden>
<h3>backup &amp; transfer</h3>
<p class="hint">One file holding your mail, contacts and pinned servers — for moving to
another browser, or for keeping what the server no longer has.</p>
<div class="backup-row">
<button type="button" id="export-data" class="filled">export</button>
<button type="button" id="import-data-trigger" class="outline">import</button>
<section class="panel" data-panel="backup" hidden>
<p class="hint">One file holding your mail, contacts and server info for moving to another
client, or for keeping what the server no longer has. It is sealed to this identity and
deliberately leaves the secret itself out.</p>
<div class="action-row">
<button type="button" id="export-data" class="filled">Export</button>
<span class="hint">Write a backup file</span>
</div>
<div class="action-row">
<button type="button" id="import-data-trigger" class="outline">Import</button>
<span class="hint">Restore from a backup file</span>
<input id="import-data" type="file" accept="application/json" hidden>
</div>
<details>
<summary>what is in the file, and is it safe?</summary>
<p class="hint">It is sealed to this identity: only a browser with this secret restored can
open it. It deliberately leaves the secret itself out — that belongs in a password
manager, not in a file in your downloads folder.</p>
</details>
<span id="backup-error" class="error"></span>
</section>
<section class="panel" data-panel="advanced" hidden>
<div id="advanced-card"></div>
<h3>rotations</h3>
<p id="rotations-description" class="hint">Issues a fresh key 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.</p>
<div id="rotations-count"></div>
<button type="button" id="rotate-identity" class="outline">Rotate identity</button>
<span id="rotate-error" class="error"></span>
<h3 class="danger-head">log out</h3>
<p id="logout-description" class="hint">Removes this identity from this client 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 "leave mail on
server" was on. Whatever is still on the server comes back on the first fetch after logging
back in.</p>
<button type="button" id="logout" class="danger">Log out and delete all data</button>
<span id="logout-error" class="error"></span>
</section>
</div>
</div>
</dialog>
<dialog id="confirm">
<div class="confirm-body">
<h2 id="confirm-title"></h2>
<p id="confirm-text"></p>
</div>
<footer>
<button type="button" id="confirm-cancel" autofocus>Cancel</button>
<button type="button" id="confirm-ok"></button>
</footer>
</dialog>
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="js/app.js"></script>
</body>

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 --------------------------------------------------------------------------

View file

@ -279,11 +279,12 @@ export function walkChain(username, pinned, current, chain) {
// Reassembles a byte stream (WebSocket or TCP) into exact-length reads.
class ByteStream {
constructor(stream) {
constructor(stream, timeoutMs = 0) {
this.chunks = [];
this.length = 0;
this.closed = null;
this.waiters = [];
this.timeoutMs = timeoutMs;
stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); };
stream.onClose = () => this.fail(new SmolError("server closed the connection"));
}
@ -305,7 +306,18 @@ class ByteStream {
async readExact(n) {
if (this.closed) throw this.closed;
if (this.length < n) {
await new Promise((resolve, reject) => this.waiters.push({ need: n, resolve, reject }));
await new Promise((resolve, reject) => {
const waiter = { need: n, resolve, reject };
this.waiters.push(waiter);
if (!this.timeoutMs) return;
// Settings' timeout, applied per read: a stalled handshake or request
// otherwise waits here forever, since nothing else ever rejects it.
setTimeout(() => {
if (!this.waiters.includes(waiter)) return; // already settled elsewhere
this.waiters = this.waiters.filter(w => w !== waiter);
reject(new SmolError(`no response from the server within ${this.timeoutMs / 1000}s`));
}, this.timeoutMs);
});
if (this.closed) throw this.closed;
}
const out = new Uint8Array(n);
@ -369,8 +381,8 @@ export class Session {
// Handshake plus §4 pinning. Returns the session, the server's static key as
// revealed by the handshake, and whether that key was already pinned.
export async function openSession(stream, host, pinned = null) {
const wire = new ByteStream(stream);
export async function openSession(stream, host, pinned = null, timeoutMs = 0) {
const wire = new ByteStream(stream, timeoutMs);
const nx = new NxInitiator();
const m1 = nx.writeMessage1();
stream.send(concat(u16BE(m1.length), m1));

View file

@ -141,12 +141,6 @@ export function pinServer(host, keyBytes) {
});
}
export function unpinServer(host) {
update(state => {
delete (state.servers || {})[host];
});
}
export function allPins() {
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
}
@ -164,14 +158,17 @@ export function setLeaveOnServer(value) {
update(state => { state.leaveOnServer = value; });
}
// The user's own word that the master is written down somewhere else. Nothing
// can verify it, so it is a settings-panel reminder, never a guard.
export function masterSaved() {
return load().masterSaved ?? false;
// Seconds a network call waits for a response before giving up. Applied per
// read, not per operation, so a slow-but-trickling exchange is not cut off
// just because it spans several reads.
export function timeoutSeconds() {
return load().timeoutSeconds ?? 30;
}
export function setMasterSaved(value) {
update(state => { state.masterSaved = value; });
export function setTimeoutSeconds(value) {
const n = Math.trunc(Number(value));
if (!Number.isFinite(n)) return; // non-numeric input is ignored, not an error
update(state => { state.timeoutSeconds = Math.min(600, Math.max(1, n)); });
}
// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over",
@ -185,7 +182,6 @@ export function discardIdentity() {
delete state.syncOk;
delete state.afterTime;
delete state.afterId;
delete state.masterSaved;
});
}

View file

@ -4,7 +4,7 @@
const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
export function connectStream(host, port) {
export function connectStream(host, port, timeoutMs = 0) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`);
socket.binaryType = "arraybuffer";
@ -14,8 +14,15 @@ export function connectStream(host, port) {
onData: null,
onClose: null,
};
socket.onopen = () => resolve(stream);
socket.onerror = () => reject(new Error(`cannot reach the bridge at ${BRIDGE}`));
// Settings' timeout only governs this; the WebSocket may otherwise never
// fire open, error, or close if something between here and the bridge
// just drops packets.
const timer = timeoutMs ? setTimeout(() => {
socket.close();
reject(new Error(`could not reach ${host}:${port} within ${timeoutMs / 1000}s`));
}, timeoutMs) : null;
socket.onopen = () => { clearTimeout(timer); resolve(stream); };
socket.onerror = () => { clearTimeout(timer); reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); };
socket.onmessage = event => stream.onData?.(new Uint8Array(event.data));
socket.onclose = () => stream.onClose?.();
});

View file

@ -437,6 +437,12 @@ dialog::backdrop { background: var(--backdrop); }
#compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; }
#compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; }
#confirm { max-width: min(420px, calc(100vw - 32px)); }
#confirm .confirm-body { padding: 22px 22px 4px; }
#confirm .confirm-body h2 { margin: 0 0 8px; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; }
#confirm .confirm-body p { margin: 0; font-size: 13px; color: var(--muted); white-space: pre-wrap; }
#confirm footer { display: flex; gap: 8px; justify-content: flex-end; padding: 18px 22px 22px; }
/* Without this every action read exactly like "cancel" and "close". */
.filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
@ -504,18 +510,6 @@ dialog::backdrop { background: var(--backdrop); }
#settings label input { font-family: var(--mono); font-size: 13px; }
#settings details { margin: 12px 0 2px; }
/* Unregistered this is the panel's whole point, so it is a card rather than a
footnote; registered, the same block collapses to one line. */
#account-claim {
margin: 18px 0 4px;
padding: 12px 14px;
background: var(--raised);
border: 1px solid var(--border);
border-radius: var(--radius);
}
#account-claim > summary { font-size: 13px; font-weight: 600; color: var(--accent); }
#account-claim form + .hint { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); }
/* A checkbox with a multi-line label, e.g. "leave mail on server" — the box
sits at the label's cap height rather than centered against the full wrapped
block, which is what made it look adrift. */
@ -536,12 +530,19 @@ dialog::backdrop { background: var(--backdrop); }
.seed-card .row { display: flex; align-items: center; gap: 8px; }
.seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; }
.seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); }
.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.kv { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; }
.kv li { display: flex; gap: 10px; padding: 5px 0; align-items: baseline; }
.kv li + li { border-top: 1px solid var(--divider); }
.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; }
/* A button whose result is explained underneath rather than beside it — each
of export/import needs its own line, so side-by-side ran out of room. */
.action-row { margin: 14px 0; }
.action-row .hint { margin: 4px 0 0; }
/* A title/subtitle pair with a control at the end, e.g. the timeout spinner —
same shape as .check-row but for a value instead of a toggle. */
.spin-row { display: flex; align-items: center; gap: 10px; margin: 14px 0; }
.spin-row .spin-text { flex: 1; min-width: 0; }
.spin-row .spin-text .check-title { display: block; }
.spin-row .spin-text .hint { display: block; margin: 2px 0 0; }
.spin-row input[type="number"] { width: 72px; font-family: var(--mono); font-size: 13px; text-align: right; }
.copy { font-size: 12px; padding: 4px 10px; }