feat: recall-on-restore, logout, and mail/contact export-import
This commit is contained in:
parent
a3378def50
commit
3576e48b99
4 changed files with 216 additions and 16 deletions
108
web/js/app.js
108
web/js/app.js
|
|
@ -157,7 +157,7 @@ $("create-identity").onclick = () => {
|
|||
openSettings();
|
||||
};
|
||||
|
||||
$("restore-identity").onclick = () => {
|
||||
$("restore-identity").onclick = async () => {
|
||||
const text = $("restore-seed").value.trim();
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a seed is 64 hex characters", true);
|
||||
const seedBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
|
||||
|
|
@ -167,7 +167,23 @@ $("restore-identity").onclick = () => {
|
|||
return status(err.message, true);
|
||||
}
|
||||
showApp();
|
||||
status("identity restored.");
|
||||
// The address field is optional: restoring must succeed on its own even if
|
||||
// this fails (server not pinned yet, offline, typo) — recall can always be
|
||||
// retried from settings afterward.
|
||||
const addressText = $("restore-address").value.trim();
|
||||
let message = "identity restored.", warn = false;
|
||||
if (addressText) {
|
||||
try {
|
||||
const addr = await recallAccount(addressText);
|
||||
message += `\nrecalled ${addr.short}`;
|
||||
renderAccount();
|
||||
renderSettings();
|
||||
} catch (err) {
|
||||
message += `\ncouldn't recall that address: ${err.message}`;
|
||||
warn = true;
|
||||
}
|
||||
}
|
||||
status(message, warn);
|
||||
};
|
||||
|
||||
// --- header ---------------------------------------------------------------------
|
||||
|
|
@ -824,7 +840,15 @@ function renderSettings() {
|
|||
"Rotating issues a fresh key with a signed certificate proving it replaces this one. " +
|
||||
"Contacts accept the change from that chain, and the old seed is kept, since mail sealed " +
|
||||
"to it is readable with nothing else. Rotation is not revocation (SPEC.md \u00a77)." }),
|
||||
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity })),
|
||||
el("button", { type: "button", textContent: "rotate identity", onclick: rotateIdentity }),
|
||||
el("p", { className: "hint", textContent:
|
||||
"Log out to remove this identity from this browser entirely: the seed, pins, contacts and " +
|
||||
"every cached message. Without a backup of the seed there is no way back in — same as any " +
|
||||
"other loss of it. Fetching deletes from the server once acknowledged (SPEC.md §5), so a " +
|
||||
"message already fetched here has no other copy anywhere; anything still unfetched stays " +
|
||||
"on the server and comes back on the next fetch after logging back in." }),
|
||||
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
|
||||
onclick: logout })),
|
||||
].filter(node => node != null));
|
||||
}
|
||||
|
||||
|
|
@ -887,24 +911,31 @@ $("register-form").onsubmit = async (event) => {
|
|||
// *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.
|
||||
// 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;
|
||||
}
|
||||
|
||||
$("recall-form").onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
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 });
|
||||
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);
|
||||
const addr = await recallAccount(form.address.value);
|
||||
status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`);
|
||||
renderAccount();
|
||||
renderSettings();
|
||||
|
|
@ -914,6 +945,40 @@ $("recall-form").onsubmit = async (event) => {
|
|||
}
|
||||
};
|
||||
|
||||
$("export-data").onclick = async () => {
|
||||
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);
|
||||
status(`exported ${data.inbox.length + data.sent.length} messages, ` +
|
||||
`${Object.keys(data.contacts).length} contacts, ${Object.keys(data.servers).length} server keys`);
|
||||
};
|
||||
|
||||
$("import-data-trigger").onclick = () => $("import-data").click();
|
||||
|
||||
$("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();
|
||||
} catch (err) {
|
||||
status(`import failed: ${err.message}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
async function rotateIdentity() {
|
||||
const me = store.identity();
|
||||
const addr = accountAddress();
|
||||
|
|
@ -942,6 +1007,17 @@ async function rotateIdentity() {
|
|||
}
|
||||
}
|
||||
|
||||
// Unlike rotate, nothing is kept: this is the seed's only local copy, gone.
|
||||
async function logout() {
|
||||
if (!window.confirm(
|
||||
"Log out and erase this browser?\n\n" +
|
||||
"This deletes the seed, pins, contacts and every cached message from this browser. Make sure " +
|
||||
"the seed is backed up elsewhere first — without it there is no way back in."
|
||||
)) return;
|
||||
await store.clearAll();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// --- boot --------------------------------------------------------------------------
|
||||
|
||||
// A deployment MAY bake in a server key (the NixOS module's `presetServer`),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue