Encrypted mail with no account and no password. Your identity is one secret,
kept in this browser and nowhere else.
-
The next screen walks you through the three things left to set up.
-
+
+
+
+
+
write this seed down
+
It is the only secret, and it is shown once.
+
+
+
+
+
-
restore
+
restore from a seed
Paste the secret you saved. Your keys come back from it — it is all this client
needs.
-
+
+
+
+
+
set up the server
+
+
+
+
+
+
choose your address
+
+
+
+
+
+
@@ -92,17 +122,6 @@
-
-
diff --git a/web/js/app.js b/web/js/app.js
index 5465593..681e21f 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -162,88 +162,223 @@ 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";
+// Generate-or-recover an identity, establish trust in a server, then bind a
+// name to it — three concerns, three screens, no bleed-through between them.
+const SETUP_SECTIONS = {
+ intro: "setup-intro",
+ "seed-shown": "setup-seed",
+ "seed-restore": "setup-restore",
+ "server-setup": "setup-server",
+ "account-setup": "setup-account",
+};
+
+function showSetupStage(stage) {
+ for (const [key, id] of Object.entries(SETUP_SECTIONS)) $(id).hidden = key !== stage;
+ if (stage === "server-setup") renderServerScreen();
}
-$("show-restore").onclick = () => showSetupCard("restore");
-$("show-create").onclick = () => showSetupCard("create");
+// Discards the identity screens 2 and 4 can still walk away from — nothing is
+// bound to an account yet, so nothing is lost: the same seed typed again on
+// screen 3 regenerates the identical identity.
+function discardSetupIdentity() {
+ store.discardIdentity();
+ serverFetchedKey = null;
+ $("server-host").value = "";
+ showSetupStage("intro");
+}
+
+// A deployment that baked in a server key already knows its own host, so
+// screen 4 starts from it rather than asking again.
+function enterServerSetup() {
+ if (!$("server-host").value && PRESET_SERVER) $("server-host").value = PRESET_SERVER.host;
+ showSetupStage("server-setup");
+}
$("create-identity").onclick = () => {
- store.setIdentity(newMaster());
- showApp();
- // 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.");
+ const seed = newMaster();
+ store.setIdentity(seed);
+ $("setup-seed-value").textContent = hex(seed);
+ showSetupStage("seed-shown");
};
-$("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 secret is 64 hex characters", true);
- const masterBytes = Uint8Array.from(text.match(/../g), b => parseInt(b, 16));
+$("show-restore").onclick = () => showSetupStage("seed-restore");
+
+$("seed-saved").onclick = () => {
+ store.setMasterSaved(true);
+ enterServerSetup();
+};
+$("seed-back").onclick = () => discardSetupIdentity();
+
+// A master secret in either of its two interchangeable encodings: 64 hex
+// characters, or the base32 form used everywhere else a key is shown.
+function parseSeedText(text) {
+ text = text.trim();
+ if (/^[0-9a-fA-F]{64}$/.test(text)) return unhex(text);
try {
- // §2: a master alone does not say which rotation index a server has
- // bound; assume 0 (a never-rotated identity, the common case) until an
- // address lets us confirm it below. Either way the accepted-correspondent
- // set is unknown, so it must not overwrite the server's (syncOk: false).
- store.restoreMaster(masterBytes, 0);
+ const bytes = b32decode(text);
+ if (bytes.length === 32) return bytes;
+ } catch { /* fall through to the error below */ }
+ throw new SmolError("not a valid secret — paste the 64 hex characters or the base32 form");
+}
+
+$("restore-form").onsubmit = (event) => {
+ event.preventDefault();
+ $("restore-error").textContent = "";
+ let seed;
+ try {
+ seed = parseSeedText($("restore-master").value);
} catch (err) {
- return status(err.message, true);
+ $("restore-error").textContent = err.message;
+ return;
}
- showApp();
- // The key/address fields are both optional, and neither can block the
- // identity restore that already succeeded above — a typo, an offline server
- // or an unregistered address just leaves that one step to retry from
- // settings afterward. No separate host field: it's already the part of the
- // address after the @, so asking for it twice would just be one more place
- // to enter it wrong (exactly how "pinned randogoth@smol.place" happened —
- // a whole address typed into what was meant to be a bare host). Pinning has
- // to happen before recall is attempted, since recall requires a pin.
- const keyText = $("restore-key").value.trim();
- const addressText = $("restore-address").value.trim();
- const lines = ["identity restored."];
- let warn = false;
-
- if (keyText) {
- if (!addressText) {
- lines.push("enter the address too — its server is the part after the @.");
- warn = true;
- } else {
- try {
- const host = parseAddress(addressText).host;
- pinServerFromText(host, keyText);
- lines.push(`pinned ${host}.`);
- } catch (err) {
- lines.push(err.message);
- warn = true;
- }
- }
- }
-
- if (addressText) {
- try {
- const addr = await recallAndDiscoverRotation(masterBytes, addressText);
- lines.push(`recalled ${addr.short}.`);
- renderAccount();
- renderSettings();
- } catch (err) {
- lines.push(`couldn't recall that address: ${err.message}`);
- warn = true;
- }
- }
-
- status(lines.join("\n"), warn);
+ // §2: a master alone does not say which rotation index a server has bound;
+ // account-setup's "existing address" mode discovers it once a server can be
+ // asked. Either way the accepted-correspondent set is unknown, so it must
+ // not overwrite the server's (syncOk: false).
+ store.restoreMaster(seed, 0);
+ enterServerSetup();
};
+$("restore-back").onclick = () => {
+ $("restore-master").value = "";
+ $("restore-error").textContent = "";
+ showSetupStage("intro");
+};
+
+// Holds a key fetched this screen visit but not yet pinned, tied to the host
+// it came from — editing the host field makes renderServerScreen() stop
+// showing it, since it only ever matches the host currently typed.
+let serverFetchedKey = null;
+// The host screen 4 ends on, carried into screen 5 to build the full address.
+let setupHost = null;
+
+function renderServerScreen() {
+ const host = $("server-host").value.trim().toLowerCase();
+ const pinned = host ? store.serverPin(host) : null;
+ const fetched = host && serverFetchedKey?.host === host ? serverFetchedKey.key : null;
+ const key = pinned ?? fetched;
+ const area = $("server-key-area");
+ area.hidden = !key;
+ area.replaceChildren();
+ if (key) {
+ area.append(
+ field("fingerprint", fingerprint(key)),
+ field("key", b32encode(key)),
+ el("p", { className: "hint", textContent: "If the server operator published a key or " +
+ "fingerprint, make sure it matches before trusting it." }));
+ }
+ $("server-action").textContent = key ? "continue" : "fetch the server's key";
+ $("server-error").textContent = "";
+}
+
+$("server-host").oninput = renderServerScreen;
+
+function setServerBusy(busy) {
+ for (const node of [$("server-host"), $("server-action"), $("server-back")]) node.disabled = busy;
+}
+
+$("server-form").onsubmit = async (event) => {
+ event.preventDefault();
+ $("server-error").textContent = "";
+ let host;
+ try {
+ host = cleanHost($("server-host").value);
+ } catch (err) {
+ $("server-error").textContent = err.message;
+ return;
+ }
+ const pinned = store.serverPin(host);
+ const fetched = serverFetchedKey?.host === host ? serverFetchedKey.key : null;
+ if (pinned || fetched) {
+ if (!pinned) store.pinServer(host, fetched);
+ serverFetchedKey = null;
+ setupHost = host;
+ showSetupStage("account-setup");
+ return;
+ }
+ setServerBusy(true);
+ try {
+ // Bypassed connect(): its "UNVERIFIED" status toast is right everywhere
+ // 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);
+ opened.session.stream.close();
+ serverFetchedKey = { host, key: opened.serverStatic };
+ renderServerScreen();
+ } catch (err) {
+ $("server-error").textContent = err.message;
+ } finally {
+ setServerBusy(false);
+ }
+};
+
+$("server-back").onclick = () => discardSetupIdentity();
+
+let accountMode = "new";
+
+function setAccountMode(mode) {
+ accountMode = mode;
+ for (const button of document.querySelectorAll("#account-mode button"))
+ button.classList.toggle("active", button.dataset.mode === mode);
+ $("account-token-label").hidden = mode !== "new";
+ $("account-action").textContent = mode === "new" ? "register" : "restore access";
+ $("account-error").textContent = "";
+}
+
+for (const button of document.querySelectorAll("#account-mode button"))
+ button.onclick = () => setAccountMode(button.dataset.mode);
+
+function setAccountBusy(busy) {
+ for (const node of [$("account-user"), $("account-token"), $("account-action"), $("account-back"),
+ ...document.querySelectorAll("#account-mode button")])
+ node.disabled = busy;
+}
+
+$("account-form").onsubmit = async (event) => {
+ event.preventDefault();
+ $("account-error").textContent = "";
+ const me = store.identity();
+ const button = $("account-action");
+ const resting = button.textContent;
+ let addr;
+ try {
+ addr = parseAddress(`${$("account-user").value.trim()}@${setupHost}`);
+ } catch (err) {
+ $("account-error").textContent = err.message;
+ return;
+ }
+ setAccountBusy(true);
+ button.textContent = accountMode === "new" ? "registering…" : "restoring…";
+ try {
+ if (accountMode === "new") {
+ const opened = await connect(addr, { requirePin: true });
+ try {
+ await registerOp(opened.session, opened.serverStatic,
+ { username: addr.user, identity: me, token: $("account-token").value.trim() });
+ } finally {
+ opened.session.stream.close();
+ }
+ store.setAccount(addr);
+ } else {
+ await recallAndDiscoverRotation(store.master(), addr.short);
+ }
+ showApp();
+ status(`welcome, ${addr.short}`);
+ } catch (err) {
+ $("account-error").textContent = err.message;
+ } finally {
+ setAccountBusy(false);
+ button.textContent = resting;
+ }
+};
+
+$("account-back").onclick = () => showSetupStage("server-setup");
+
// --- header ---------------------------------------------------------------------
function renderAccount() {
@@ -1044,18 +1179,13 @@ function showTab(name) {
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) {
+function openSettings() {
renderSettings();
- showTab(tab);
+ showTab("account");
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();
@@ -1063,61 +1193,6 @@ $("settings-close").onclick = () => settings.close();
$("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.checked);
-// --- 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
@@ -1127,7 +1202,6 @@ function renderSettings() {
renderAccountPanel();
renderServerPanel();
renderAdvancedPanel();
- renderSetupBanner();
}
function renderServerPanel() {
@@ -1184,7 +1258,6 @@ function renderAccountPanel() {
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" }),
@@ -1228,21 +1301,26 @@ function renderAdvancedPanel() {
].filter(node => node != null));
}
-// Shared by the settings pin form and the optional host/key fields on setup —
-// same validation, same unconditional pin. Both are 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) {
+// A pin is keyed by bare host — connect() strips the port before looking one
+// up (parseAddress splits user@host:port into three), so "user@host" or
+// "host:port" here would silently pin a string nothing ever looks up again,
+// exactly the mistake that produced "pinned " followed by a
+// confusing "no pinned key" from recall right after. Shared by the settings
+// pin form and the onboarding server screen.
+function cleanHost(hostText) {
const host = hostText.trim().toLowerCase();
- // A pin is keyed by bare host — connect() strips the port before looking one
- // up (parseAddress splits user@host:port into three), so "user@host" or
- // "host:port" here would silently pin a string nothing ever looks up again,
- // exactly the mistake that produced "pinned " followed by a
- // confusing "no pinned key" from recall right after.
if (host.includes("@"))
throw new SmolError(`"${host}" looks like an address, not a server — the part after the @ goes here`);
if (host.includes(":"))
throw new SmolError(`"${host}": a pin is by host only, no port — just the part before the colon`);
+ 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);
@@ -1487,13 +1565,18 @@ function applyPresetServer() {
} catch { /* malformed preset: leave unpinned rather than block boot */ }
}
-// A preset deployment already knows its own server, so asking again on the
-// setup screen would be redundant — and confusing in the other direction
-// ("why do I need to type this if it's supposed to be automatic?"). A generic
-// deployment has no preset, so this stays the only way to pin from onboarding.
-if (PRESET_SERVER) $("restore-key-label").hidden = true;
-
applyPresetServer();
applyTheme();
-showSetupCard("create");
-store.identity() ? showApp() : showSetup();
+
+// An identity not yet bound to an account always resumes at the server
+// screen, however it was reached — there is nothing a banner needs to remind
+// the user of afterward, since showApp() is unreachable until both are done.
+if (!store.identity()) {
+ showSetupStage("intro");
+ showSetup();
+} else if (!store.account()) {
+ enterServerSetup();
+ showSetup();
+} else {
+ showApp();
+}
diff --git a/web/js/store.js b/web/js/store.js
index a8afe0a..3bb8c4d 100644
--- a/web/js/store.js
+++ b/web/js/store.js
@@ -165,7 +165,7 @@ export function setLeaveOnServer(value) {
}
// The user's own word that the master is written down somewhere else. Nothing
-// can verify it, so it only drives the setup checklist — never a guard.
+// can verify it, so it is a settings-panel reminder, never a guard.
export function masterSaved() {
return load().masterSaved ?? false;
}
@@ -174,14 +174,19 @@ export function setMasterSaved(value) {
update(state => { state.masterSaved = value; });
}
-// Whether the setup checklist was dismissed. Remaining steps stay reachable
-// from settings, so hiding it is a preference, not a loss.
-export function setupDismissed() {
- return load().setupDismissed ?? false;
-}
-
-export function setSetupDismissed(value) {
- update(state => { state.setupDismissed = value; });
+// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over",
+// before anything is bound to an account. Pins and contacts are left alone —
+// server trust is not identity-scoped.
+export function discardIdentity() {
+ if (account()) throw new Error("already bound to an account; log out instead");
+ update(state => {
+ delete state.master;
+ delete state.rotations;
+ delete state.syncOk;
+ delete state.afterTime;
+ delete state.afterId;
+ delete state.masterSaved;
+ });
}
export function cursor() {
diff --git a/web/style.css b/web/style.css
index b6e402f..783b520 100644
--- a/web/style.css
+++ b/web/style.css
@@ -384,62 +384,25 @@ main { flex: 1; min-height: 0; display: flex; background: var(--bg); }
}
.link:hover { color: var(--accent); background: none; }
-/* --- setup checklist ------------------------------------------------------------ */
-
-/* Over the mailbox the missing steps are holding up, rather than inside the
- dialog the user would have to already know to open. */
-.setup-banner {
- flex: none;
- margin: 10px 10px 0;
- padding: 12px 14px 10px;
- background: var(--accent-soft);
+/* A two-way choice feeding one piece of state (screen 5's new/existing
+ address mode) — same shape as .settings-tabs, laid out horizontally. */
+.mode-toggle {
+ display: flex;
+ gap: 2px;
+ padding: 3px;
+ background: var(--raised);
border-radius: var(--radius);
}
-.setup-banner-head { display: flex; align-items: center; gap: 10px; }
-.setup-banner-head h2 {
+.mode-toggle button {
flex: 1;
- margin: 0;
- font-size: 12px;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--accent);
-}
-.setup-banner-count { font-size: 12px; color: var(--accent); }
-.setup-banner .icon-only { padding: 4px; color: var(--accent); }
-.setup-banner .icon-only:hover { background: color-mix(in srgb, var(--accent) 14%, transparent); }
-
-.setup-steps { list-style: none; margin: 6px 0 0; padding: 0; }
-.setup-step { display: flex; align-items: flex-start; gap: 10px; padding: 8px 0; }
-.setup-step + .setup-step { border-top: 1px solid color-mix(in srgb, var(--accent) 16%, transparent); }
-.setup-mark {
- flex: none;
- width: 18px;
- height: 18px;
- margin-top: 1px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--accent);
- border-radius: 50%;
- color: var(--ok-fg);
-}
-.setup-step.done .setup-mark { background: var(--ok-bg); border-color: var(--ok-bg); }
-.setup-mark .icon { width: 13px; height: 13px; stroke-width: 3; }
-.setup-step-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
-.setup-step-title { font-size: 13px; font-weight: 500; }
-/* A finished step keeps its place in the order but stops explaining itself. */
-.setup-step.done .setup-step-title { color: var(--muted); font-weight: 400; }
-.setup-step.done .hint { display: none; }
-.setup-step-action {
- flex: none;
- padding: 5px 12px;
- font-size: 12px;
+ padding: 8px 10px;
+ font-size: 13px;
font-weight: 500;
- background: var(--surface);
- box-shadow: var(--shadow);
+ color: var(--fg);
+ border-radius: calc(var(--radius) - 3px);
}
-.setup-step-action:hover { background: var(--surface); }
+.mode-toggle button:hover { background: var(--hover); }
+.mode-toggle button.active { background: var(--accent-soft); color: var(--accent); }
/* --- dialogs --------------------------------------------------------------------- */
@@ -638,7 +601,6 @@ dialog::backdrop { background: var(--backdrop); }
.view { padding: 16px; }
.toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
.setup-card { padding: 22px 18px 14px; }
- .setup-banner { margin: 8px 8px 0; }
/* The tab rail turns into a scrollable strip rather than eating half the
width of a phone-sized dialog. */
.settings-body { flex-direction: column; min-height: 0; }