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
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 --------------------------------------------------------------------------
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue