feat: rework first run as a setup checklist and split settings into tabs

This commit is contained in:
randogoth 2026-10-06 11:57:04 +03:00
parent 6f82a84b69
commit 1710d7b144
5 changed files with 604 additions and 232 deletions

View file

@ -135,7 +135,7 @@ async function connect(addr, { requirePin }) {
const pinned = store.serverPin(addr.host);
if (requirePin && !pinned)
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
`trusted channel, then pin it in settings.`);
`trusted channel, then pin it under settings → server.`);
const stream = await connectStream(addr.host, addr.port);
const opened = await openSession(stream, addr.host, pinned);
if (!pinned)
@ -162,19 +162,33 @@ function showApp() {
$("search").hidden = false;
$("top-actions").hidden = false;
renderAccount();
renderSetupBanner();
renderFolder();
}
// Create or restore is the only question this screen asks, so only the card
// being answered is on screen.
function showSetupCard(which) {
$("setup-create").hidden = which !== "create";
$("setup-restore").hidden = which !== "restore";
}
$("show-restore").onclick = () => showSetupCard("restore");
$("show-create").onclick = () => showSetupCard("create");
$("create-identity").onclick = () => {
store.setIdentity(newMaster());
showApp();
status("identity created. Back up the master in settings now; it is the only secret.");
openSettings();
// Deliberately not openSettings(): being dropped into the whole dialog was
// the overwhelming part. The checklist over the mailbox leads back into it
// one step at a time instead.
status("identity created — the checklist shows the three things left to do.");
};
$("restore-identity").onclick = async () => {
$("restore-form").onsubmit = async (event) => {
event.preventDefault();
const text = $("restore-master").value.trim();
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master is 64 hex characters", true);
if (!/^[0-9a-fA-F]{64}$/.test(text)) return status("a master secret is 64 hex characters", true);
const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
try {
// §2: a master alone does not say which rotation index a server has
@ -201,7 +215,7 @@ $("restore-identity").onclick = async () => {
if (keyText) {
if (!addressText) {
lines.push("enter the address above too — its server is derived from the part after the @.");
lines.push("enter the address too — its server is the part after the @.");
warn = true;
} else {
try {
@ -748,7 +762,7 @@ $("fetch").onclick = async () => {
const master = store.master();
const addr = accountAddress();
if (!me || !master) return status("no identity yet", true);
if (!addr) return status("not registered; register in settings first", true);
if (!addr) return status("not registered yet — claim an address in settings", true);
const button = $("fetch"), label = button.querySelector(".label");
const resting = label.textContent;
button.disabled = true; // one session at a time; the icon spins while it is
@ -1014,35 +1028,110 @@ async function blockContact(address) {
// --- settings ---------------------------------------------------------------------
const settings = $("settings");
const settingsTabs = [...$("settings-tabs").querySelectorAll("button")];
const settingsPanels = [...$("settings-panels").querySelectorAll(".panel")];
function openSettings() {
renderSettings();
settings.showModal();
function showTab(name) {
for (const button of settingsTabs) {
const active = button.dataset.tab === name;
button.classList.toggle("active", active);
if (active) button.setAttribute("aria-current", "true");
else button.removeAttribute("aria-current");
}
for (const panel of settingsPanels) panel.hidden = panel.dataset.panel !== name;
$("settings-panels").scrollTop = 0;
}
$("settings-open").onclick = openSettings;
for (const button of settingsTabs) button.onclick = () => showTab(button.dataset.tab);
// `tab` is how a checklist step lands on the panel that completes it, and
// `into` the id within that panel to bring into view — the account panel is
// taller than the dialog, so "back up your secret" would otherwise open on the
// register form instead.
function openSettings(tab = "account", into = null) {
renderSettings();
showTab(tab);
if (!settings.open) settings.showModal();
// showModal() focuses the first focusable node, which is the close button;
// the current tab says where you are instead.
$("settings-tabs").querySelector("button.active").focus();
if (into) $(into).scrollIntoView({ block: "center" });
}
$("settings-open").onclick = () => openSettings();
$("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
// Registering against an unpinned server is refused, so the dialog is ordered
// by what depends on what; the chips say how far along that order you are.
function step(id, done) {
const chip = $(id);
chip.classList.toggle("done", done);
chip.textContent = done ? "\u2713" : chip.dataset.n;
// --- setup checklist --------------------------------------------------------------
// What a fresh identity still owes, in the order the protocol forces:
// registering against an unpinned server is refused, so the server precedes
// the address. Each step names the settings tab that finishes it.
function setupSteps() {
return [
{
title: "back up your secret",
why: "The only way back into this identity, and nothing can reset it.",
action: "back it up",
tab: "account",
into: "identity-card",
done: store.masterSaved(),
},
{
title: "pin your server",
why: "Lets this client tell your real server from anything pretending to be it.",
action: "pin it",
tab: "server",
done: store.allPins().length > 0,
},
{
title: "claim your address",
why: "The address people write to, on the server you pinned.",
action: "claim it",
tab: "account",
done: Boolean(store.account()),
},
];
}
function renderSetupBanner() {
const banner = $("setup-banner");
const steps = setupSteps();
const left = steps.filter(step => !step.done).length;
banner.hidden = !store.identity() || !left || store.setupDismissed();
if (banner.hidden) return;
$("setup-banner-count").textContent = `${steps.length - left} of ${steps.length}`;
$("setup-steps").replaceChildren(...steps.map(step =>
el("li", { className: step.done ? "setup-step done" : "setup-step" },
el("span", { className: "setup-mark" }, step.done ? icon("check") : null),
el("div", { className: "setup-step-text" },
el("span", { className: "setup-step-title", textContent: step.title }),
el("span", { className: "hint", textContent: step.why })),
step.done ? null : el("button", { type: "button", className: "setup-step-action",
textContent: step.action, onclick: () => openSettings(step.tab, step.into) }))));
}
$("setup-dismiss").onclick = () => {
store.setSetupDismissed(true);
renderSetupBanner();
status("hidden — the remaining steps stay in settings");
};
// --- settings panels --------------------------------------------------------------
// One renderer per panel, all of them cheap, so every mutation below can just
// call this rather than reason about which panel it touched.
function renderSettings() {
const me = store.identity();
const account = store.account();
const pins = store.allPins();
$("leave-on-server").checked = store.leaveOnServer();
renderAccountPanel();
renderServerPanel();
renderAdvancedPanel();
renderSetupBanner();
}
step("step-server", pins.length > 0);
step("step-address", Boolean(account));
function renderServerPanel() {
const pins = store.allPins();
$("pins").replaceChildren(...pins.map(({ host, key }) =>
el("li", {},
el("span", { textContent: host }),
@ -1051,17 +1140,34 @@ function renderSettings() {
onclick: () => { store.unpinServer(host); renderSettings(); } }))));
if (!pins.length)
$("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" }));
}
const card = $("identity-card");
function renderAccountPanel() {
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");
secret.replaceChildren();
if (!me) return;
const masterBytes = store.master();
const share = account ? accountAddress().uri(me.publicKey) : null;
const rotations = store.rotations();
const acceptedCount = store.allAccepted().filter(a => a.active).length;
// Settings opens by itself right after an identity is created, which is a poor
// moment to paint the only secret across the screen — so it is asked for.
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 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: () => {
@ -1070,38 +1176,55 @@ function renderSettings() {
reveal.textContent = showing ? "reveal" : "hide";
} });
card.append(...[
field("fingerprint", fingerprint(me.publicKey)),
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
field("shareable", share ?? "register above to get one", !share,
share ? copyButton(share, "your address") : null),
secret.append(
el("div", { className: "seed-card" },
el("h4", { textContent: "recovery master" }),
el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the master")),
el("p", { textContent: "The only secret there is. Anyone who holds it can read your mail and " +
"write as you, and losing it loses the address with it — there is no reset. Copy it " +
"somewhere safe now." })),
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);
renderSetupBanner();
} }),
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." }))));
}
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)),
rotations ? el("p", { className: "hint",
textContent: "Earlier keys are re-derived on demand from the master, so mail sealed to a " +
"superseded key stays readable." }) : null,
field("accepted correspondents", String(acceptedCount) +
field("accepted", String(acceptedCount) +
(store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")),
el("details", {},
el("summary", { textContent: "advanced" }),
el("p", { className: "hint", textContent:
"Rotating issues a fresh key — the next one derivable from the master — with a signed " +
"certificate proving it replaces this one. Contacts accept the change from that chain, and " +
"the old key stays readable with nothing else. Rotation is not revocation." }),
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 master, pins, contacts and " +
"every cached message. Without a backup of the master there is no way back in — same as any " +
"other loss of it. Fetching deletes from the server once acknowledged unless \u201cleave mail " +
"on server\u201d is on, so a message already fetched here may have no other " +
"copy anywhere; anything still on the server comes back on the next fetch after logging back in." }),
el("button", { type: "button", className: "danger", textContent: "log out & erase this browser",
onclick: logout })),
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));
}
@ -1328,7 +1451,7 @@ async function rotateIdentity(event) {
openedCache.clear();
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
renderAccount();
renderSettings(); // rebuilds the identity card, this button included
renderSettings(); // rebuilds the advanced panel, this button included
renderFolder();
} catch (err) {
button.disabled = false;
@ -1341,8 +1464,8 @@ async function rotateIdentity(event) {
async function logout() {
if (!window.confirm(
"Log out and erase this browser?\n\n" +
"This deletes the master, pins, contacts and every cached message from this browser. Make sure " +
"the master is backed up elsewhere first — without it there is no way back in."
"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();
@ -1372,5 +1495,5 @@ if (PRESET_SERVER) $("restore-key-label").hidden = true;
applyPresetServer();
applyTheme();
showSetupCard("create");
store.identity() ? showApp() : showSetup();
status(store.identity() ? "" : "create an identity to begin");