diff --git a/web/index.html b/web/index.html index 3761e0e..fedd027 100644 --- a/web/index.html +++ b/web/index.html @@ -36,42 +36,72 @@ - +
-
+

welcome

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.

- + +
+ + + + + +
@@ -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; }