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:
parent
fb0ca5c8cc
commit
84397a3fdd
6 changed files with 270 additions and 334 deletions
118
web/index.html
118
web/index.html
|
|
@ -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 & 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>
|
||||
|
|
|
|||
396
web/js/app.js
396
web/js/app.js
|
|
@ -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 --------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue