oxedyne/daimond/www/js/passcode.js
30.7 KiB, 1 run
created by r2519314175:1407, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | /* passcode.js — the beta passcode, and the refusal that sends somebody to it. |
| 2 | * |
| 3 | * WHAT WAS MISSING. The gateway has minted beta passcodes, spent them inside |
| 4 | * the one critical section that writes the account, and refused every stranger |
| 5 | * without one, for as long as `/api/passcode/redeem` has existed. Nothing in the |
| 6 | * browser ever called it. A person holding a code had no box to put it in, and a |
| 7 | * person refused for not holding one was told nothing at all: `bootstrap()` |
| 8 | * turned the 403 into `offline`, and the app dropped quietly into BYOK-only |
| 9 | * mode looking broken. This file is both halves — the sentence, and the door. |
| 10 | * |
| 11 | * TWO PLACES, ONE SCREEN. The refusal raises the dialog itself, once, because |
| 12 | * somebody who has just been refused is looking at the app right now and |
| 13 | * deserves to be told by it rather than to work it out. The same dialog is |
| 14 | * reachable afterwards from the Credits drawer, which is where this app already |
| 15 | * answers "what account have I got" — because the person who gets a code a week |
| 16 | * later has long since dismissed the dialog. There is one dialog and one set of |
| 17 | * words; the drawer carries a button to it, not a second copy of it. |
| 18 | * |
| 19 | * WHAT IS NOT DRAWN. The passcode field appears only where it could actually |
| 20 | * work: an identity that exists and is unlocked (the redemption is signed by the |
| 21 | * device key, so a locked app has nothing to sign with) and no account yet. A |
| 22 | * field that would refuse the moment it is used is the trap this codebase keeps |
| 23 | * falling into, and it is cheaper not to draw it. |
| 24 | * |
| 25 | * The gateway contract lives in gateway.js, next to the registration it IS — |
| 26 | * `DaimondGateway.redeemPasscode`. This file collects a code, says what came |
| 27 | * back, and owns no protocol of its own. |
| 28 | */ |
| 29 | (function () { |
| 30 | 'use strict'; |
| 31 | |
| 32 | /// What the app says. |
| 33 | function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; } |
| 34 | |
| 35 | function el(tag, cls, text) { |
| 36 | var e = document.createElement(tag); |
| 37 | if (cls) e.className = cls; |
| 38 | if (text != null) e.textContent = text; |
| 39 | return e; |
| 40 | } |
| 41 | |
| 42 | /// The gateway's view of this device, or an empty one on a stripped build. |
| 43 | function acct() { |
| 44 | try { |
| 45 | return (window.DaimondGateway && DaimondGateway.state()) || {}; |
| 46 | } catch (e) { return {}; } |
| 47 | } |
| 48 | |
| 49 | /// Is there a key here to sign a redemption with? |
| 50 | /// |
| 51 | /// Both halves. An identity that exists but is locked cannot sign, and an |
| 52 | /// app with none cannot either -- and the two need different sentences, so |
| 53 | /// they are asked separately wherever the difference shows. |
| 54 | function canSign() { |
| 55 | return !!(window.DaimondIdentity |
| 56 | && DaimondIdentity.exists() |
| 57 | && DaimondIdentity.isUnlocked()); |
| 58 | } |
| 59 | |
| 60 | /// The heading for a refusal, or for the dialog opened with none on record. |
| 61 | /// |
| 62 | /// THREE, not two. With no refusal on record -- the gateway was never asked, |
| 63 | /// or could not be reached at all -- naming a closed beta would be a claim |
| 64 | /// about a server this device has heard nothing from. That is the state a |
| 65 | /// device is in whenever the gateway is simply down, which is common, so the |
| 66 | /// wrong heading there would be the one most people saw. |
| 67 | function titleFor(reason) { |
| 68 | if (reason === 'beta_only') return t('beta.title'); |
| 69 | if (reason === 'unavailable') return t('beta.title_unavailable'); |
| 70 | return t('beta.title_plain'); |
| 71 | } |
| 72 | |
| 73 | /// The sentence explaining where this device stands. |
| 74 | /// |
| 75 | /// The app's own words, in the reader's language, keyed on the machine |
| 76 | /// `reason` -- which is exactly what the gateway documents that field for. |
| 77 | /// A reason this build has never heard of falls through to the gateway's own |
| 78 | /// English, kept verbatim in `state.refusal`, so a refusal added on the |
| 79 | /// server is still legible in an old tab rather than silently blank. |
| 80 | function leadFor(s) { |
| 81 | if (s.refused === 'beta_only') return t('beta.lead_beta_only'); |
| 82 | if (s.refused === 'unavailable') return t('beta.lead_unavailable'); |
| 83 | if (s.refusal) return s.refusal; |
| 84 | return t('beta.lead_no_reason'); |
| 85 | } |
| 86 | |
| 87 | // ── The dialog ───────────────────────────────────────────── |
| 88 | // |
| 89 | // The same shape pairing.js uses for the same kind of moment: a scrim, a |
| 90 | // card, Escape and a Tab that stays inside it, and the one cross every |
| 91 | // surface in this app wears. |
| 92 | |
| 93 | // SAY THE CARD AGAIN WHERE IT STANDS, when the language changes under it. |
| 94 | // |
| 95 | // This is a question somebody is part-way through answering, so it is relabelled |
| 96 | // and never rebuilt: a redraw would take away the code they had half typed and |
| 97 | // the focus with it. Each state of the card leaves a function here that puts its |
| 98 | // own words right in place, and closing the card clears the slot. |
| 99 | // |
| 100 | // ONE registration, against whatever card is up, rather than one per opening -- |
| 101 | // `DaimondI18n.surface` has no way to let go of a registration, and a card that |
| 102 | // registered on every open would leave one behind each time. |
| 103 | var relabelCard = null; |
| 104 | if (window.DaimondI18n) { |
| 105 | DaimondI18n.surface( |
| 106 | function () { return document.querySelector('.beta-scrim'); }, |
| 107 | function () { if (relabelCard) relabelCard(); }); |
| 108 | } |
| 109 | |
| 110 | function overlay(build) { |
| 111 | var scrim = el('div', 'beta-scrim'); |
| 112 | var box = el('div', 'beta-box'); |
| 113 | scrim.appendChild(box); |
| 114 | var prev = document.activeElement; // where the keyboard was. |
| 115 | function close() { |
| 116 | relabelCard = null; |
| 117 | document.removeEventListener('keydown', onKey, true); |
| 118 | try { document.body.removeChild(scrim); } catch (e) { /* already gone */ } |
| 119 | if (prev && prev.focus && prev.getClientRects && prev.getClientRects().length) { |
| 120 | try { prev.focus(); } catch (e) { /* gone with a redraw */ } |
| 121 | } |
| 122 | } |
| 123 | /// The controls in here that can take focus. |
| 124 | function stops() { |
| 125 | return [].filter.call( |
| 126 | box.querySelectorAll('button,input,a[href],[tabindex]:not([tabindex="-1"])'), |
| 127 | function (n) { return !n.disabled && n.getClientRects().length; }); |
| 128 | } |
| 129 | function onKey(e) { |
| 130 | if (e.key === 'Escape') { e.preventDefault(); close(); return; } |
| 131 | if (e.key !== 'Tab') return; |
| 132 | var f = stops(); |
| 133 | if (!f.length) return; |
| 134 | var first = f[0], last = f[f.length - 1]; |
| 135 | if (!box.contains(document.activeElement)) { e.preventDefault(); first.focus(); return; } |
| 136 | if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } |
| 137 | else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } |
| 138 | } |
| 139 | document.addEventListener('keydown', onKey, true); |
| 140 | scrim.addEventListener('click', function (e) { if (e.target === scrim) close(); }); |
| 141 | build(box, close); |
| 142 | if (window.DaimondCloser) { |
| 143 | var h3 = box.querySelector('h3'); |
| 144 | var row = h3 |
| 145 | ? DaimondCloser.head(h3.textContent || '', { titleEl: h3, onClose: close }) |
| 146 | : DaimondCloser.head('', { name: t('common.close'), onClose: close }); |
| 147 | box.insertBefore(row, box.firstChild); |
| 148 | } |
| 149 | document.body.appendChild(scrim); |
| 150 | // The first real control, not the closer: the way out should not be what |
| 151 | // the keyboard lands on in a card that exists to be answered. |
| 152 | var f0 = stops().filter(function (n) { return !n.classList.contains('ui-close'); })[0] |
| 153 | || stops()[0]; |
| 154 | if (f0) { try { f0.focus(); } catch (e) { /* not focusable */ } } |
| 155 | return close; |
| 156 | } |
| 157 | |
| 158 | /// Is a dialog of ours already up? Two of these over each other would be one |
| 159 | /// code field the user cannot see behind another. |
| 160 | function isOpen() { return !!document.querySelector('.beta-scrim'); } |
| 161 | |
| 162 | /// The screen: what the gateway said, and the box that answers it. |
| 163 | /// |
| 164 | /// `opts.reason` overrides what the gateway state carries, for the one caller |
| 165 | /// that has a refusal in hand before the state has caught up. Everything else |
| 166 | /// reads the state, so the dialog says the same thing however it was opened. |
| 167 | function show(opts) { |
| 168 | if (isOpen()) return; |
| 169 | opts = opts || {}; |
| 170 | var s = acct(); |
| 171 | if (opts.reason) { s = Object.assign({}, s, { refused: opts.reason }); } |
| 172 | |
| 173 | overlay(function (box, close) { |
| 174 | var h3 = el('h3', null, titleFor(s.refused)); |
| 175 | var lead = el('p', null, leadFor(s)); |
| 176 | var have = el('p', null, t('beta.have_code')); |
| 177 | box.appendChild(h3); |
| 178 | box.appendChild(lead); |
| 179 | box.appendChild(have); |
| 180 | |
| 181 | var lab = el('label', 'beta-label', t('beta.code')); |
| 182 | lab.setAttribute('for', 'beta-code-input'); |
| 183 | box.appendChild(lab); |
| 184 | var input = el('input', 'beta-input'); |
| 185 | input.id = 'beta-code-input'; |
| 186 | input.setAttribute('placeholder', t('beta.code_ph')); |
| 187 | input.setAttribute('autocapitalize', 'off'); |
| 188 | input.setAttribute('autocomplete', 'off'); |
| 189 | input.setAttribute('autocorrect', 'off'); |
| 190 | input.setAttribute('spellcheck', 'false'); |
| 191 | box.appendChild(input); |
| 192 | |
| 193 | var err = el('div', 'beta-err'); |
| 194 | box.appendChild(err); |
| 195 | |
| 196 | var row = el('div', 'beta-row'); |
| 197 | var not = el('button', 'beta-btn ghost', t('dlg.not_now')); |
| 198 | not.type = 'button'; |
| 199 | not.addEventListener('click', close); |
| 200 | row.appendChild(not); |
| 201 | |
| 202 | // Only for the refusal it answers. A gateway that could not read its |
| 203 | // own gate is asked again; one that refused on purpose would give the |
| 204 | // same answer to the same question, and a button that redoes a |
| 205 | // decision is a button that lies about what it does. |
| 206 | var again = null; |
| 207 | if (s.refused === 'unavailable') { |
| 208 | again = el('button', 'beta-btn ghost', t('beta.try_again')); |
| 209 | again.type = 'button'; |
| 210 | again.addEventListener('click', async function () { |
| 211 | err.textContent = ''; |
| 212 | again.disabled = true; |
| 213 | var got = false; |
| 214 | try { got = !!(await DaimondGateway.bootstrap()); } catch (e) { got = false; } |
| 215 | again.disabled = false; |
| 216 | refresh(); |
| 217 | if (got) { done(box, close, { created: false, pro: acct().pro === true, authed: true }); } |
| 218 | else { err.textContent = t('beta.still_closed'); } |
| 219 | }); |
| 220 | row.appendChild(again); |
| 221 | } |
| 222 | |
| 223 | var go = el('button', 'beta-btn', t('beta.redeem')); |
| 224 | go.type = 'button'; |
| 225 | row.appendChild(go); |
| 226 | box.appendChild(row); |
| 227 | |
| 228 | async function submit() { |
| 229 | if (go.disabled) return; |
| 230 | err.textContent = ''; |
| 231 | go.disabled = true; |
| 232 | var was = go.textContent; |
| 233 | go.textContent = t('beta.redeeming'); |
| 234 | try { |
| 235 | var r = await DaimondGateway.redeemPasscode(input.value); |
| 236 | refresh(); |
| 237 | done(box, close, r); |
| 238 | } catch (e) { |
| 239 | go.disabled = false; |
| 240 | go.textContent = was; |
| 241 | // The gateway's own distinction, said in the reader's |
| 242 | // language and not softened into one answer: see |
| 243 | // `redeemWords` in gateway.js. |
| 244 | err.textContent = (e && e.message) || t('beta.err_generic'); |
| 245 | try { input.focus(); input.select(); } catch (e2) { /* gone */ } |
| 246 | } |
| 247 | } |
| 248 | // ── And the way to ASK for one ───────────────────────── |
| 249 | // |
| 250 | // This card told people to have a passcode and gave them no way to get |
| 251 | // one, which was merely unhelpful while there was nowhere to send them |
| 252 | // and is a dead end now that `/apply.html` exists. A route in |
| 253 | // production that nothing reaches is the defect this codebase keeps |
| 254 | // finding, and it is found from the outside every time. |
| 255 | // |
| 256 | // `?for=test` is not decoration: the form reads the query to choose |
| 257 | // between applying to the test and joining the waitlist, and somebody |
| 258 | // arriving from a REFUSAL is asking for the first. A bare path would |
| 259 | // land them on whichever half the form defaults to. |
| 260 | // |
| 261 | // LAST IN THE CARD, deliberately. The dialog opens with the keyboard on |
| 262 | // the first control that is not the closer; a link above the field |
| 263 | // would take that focus, and the field is what somebody holding a code |
| 264 | // came here for. It also reads in the right order: put the code in, and |
| 265 | // if you have none, here is how to ask. |
| 266 | // |
| 267 | // A new tab rather than a navigation. Leaving the page would take the |
| 268 | // app down with it -- a refused device still has Diamonds, a provider |
| 269 | // key and possibly a turn running -- to show a form. |
| 270 | // `beta-ask` and not `beta-note`: the note class dims its whole subtree |
| 271 | // with `opacity`, and opacity cannot be undone by a child -- the link |
| 272 | // would be dimmed with the sentence around it. |
| 273 | var ask = el('p', 'beta-ask', t('beta.no_code') + ' '); |
| 274 | var a = el('a', 'beta-apply', t('beta.apply')); |
| 275 | // Root-absolute, as `/console/` is: the app is one document at the site |
| 276 | // root and this is its sibling, so a relative path would only differ |
| 277 | // from this by being wrong the first time anything is served deeper. |
| 278 | a.href = '/apply.html?for=test'; |
| 279 | a.target = '_blank'; |
| 280 | a.rel = 'noopener'; |
| 281 | ask.appendChild(a); |
| 282 | box.appendChild(ask); |
| 283 | |
| 284 | go.addEventListener('click', submit); |
| 285 | input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); }); |
| 286 | |
| 287 | // Every word the card is built from, gathered where they can be said again. |
| 288 | // `go` is left alone while it is disabled: that is a redemption in flight, |
| 289 | // carrying "Redeeming…", and putting "Redeem" back over it would report a |
| 290 | // request as finished that has not been answered. |
| 291 | relabelCard = function () { |
| 292 | h3.textContent = titleFor(s.refused); |
| 293 | lead.textContent = leadFor(s); |
| 294 | have.textContent = t('beta.have_code'); |
| 295 | lab.textContent = t('beta.code'); |
| 296 | input.setAttribute('placeholder', t('beta.code_ph')); |
| 297 | not.textContent = t('dlg.not_now'); |
| 298 | if (again) again.textContent = t('beta.try_again'); |
| 299 | if (!go.disabled) go.textContent = t('beta.redeem'); |
| 300 | if (ask.firstChild) ask.firstChild.nodeValue = t('beta.no_code') + ' '; |
| 301 | a.textContent = t('beta.apply'); |
| 302 | }; |
| 303 | }); |
| 304 | } |
| 305 | |
| 306 | /// What the card says once the code is spent. |
| 307 | /// |
| 308 | /// The account exists from here whatever else happened, so nothing in this |
| 309 | /// panel may read as a failure. The one thing that can still be untrue is |
| 310 | /// the session -- `redeemPasscode` reports it rather than throwing, because |
| 311 | /// the credential is gone and telling somebody it did not work would leave |
| 312 | /// them with no way back in -- so that is said plainly and separately. |
| 313 | function done(box, close, r) { |
| 314 | // Nothing here is typed into, so this state's words are said again by drawing |
| 315 | // it over -- which is also the only way to reach the consent block's own. |
| 316 | relabelCard = function () { done(box, close, r); }; |
| 317 | box.innerHTML = ''; |
| 318 | box.appendChild(el('h3', null, t('beta.done_title'))); |
| 319 | box.appendChild(el('p', null, r && r.pro ? t('beta.done_pro') : t('beta.done_plain'))); |
| 320 | if (r && r.handle) { |
| 321 | box.appendChild(el('p', 'beta-note', t('beta.done_handle', { handle: r.handle }))); |
| 322 | } |
| 323 | if (r && !r.authed) { |
| 324 | box.appendChild(el('p', 'beta-note', t('beta.done_not_signed_in'))); |
| 325 | } |
| 326 | |
| 327 | // ── And the one question we ask them ─────────────────── |
| 328 | // |
| 329 | // HERE, AND NOT A LINE EARLIER. The code is spent, the account exists and |
| 330 | // the Pro licence is granted before this is drawn, so nothing about |
| 331 | // saying no can cost them anything -- which is the difference between |
| 332 | // asking and extracting. A consent collected while somebody is still |
| 333 | // waiting to find out whether their passcode worked is not freely given. |
| 334 | var ask = canAsk(r); |
| 335 | if (ask) { |
| 336 | box.appendChild(consentBlock(ask, close)); |
| 337 | } else { |
| 338 | var row = el('div', 'beta-row'); |
| 339 | var ok = el('button', 'beta-btn', t('common.close')); |
| 340 | ok.type = 'button'; |
| 341 | ok.addEventListener('click', close); |
| 342 | row.appendChild(ok); |
| 343 | box.appendChild(row); |
| 344 | } |
| 345 | if (window.DaimondCloser) { |
| 346 | var h3 = box.querySelector('h3'); |
| 347 | box.insertBefore( |
| 348 | DaimondCloser.head(h3.textContent || '', { titleEl: h3, onClose: close }), |
| 349 | box.firstChild); |
| 350 | } |
| 351 | try { ok.focus(); } catch (e) { /* not focusable */ } |
| 352 | } |
| 353 | |
| 354 | // ── The one question, and the only place it is asked ─────── |
| 355 | // |
| 356 | // WHAT THIS IS. A beta tester may agree to send counts of how they use |
| 357 | // Daimond -- numbers, never words. `www/js/telemetry.js` is the whole of what |
| 358 | // would be sent and says so in prose a tester can read; the Privacy Policy |
| 359 | // says it again at `#beta-telemetry`, which the link below opens IN THE APP |
| 360 | // (a PWA tab draws itself, and a consent line pointing at another origin is a |
| 361 | // consent line with nowhere to point). |
| 362 | // |
| 363 | // FOUR THINGS THIS CARD HAS TO BE, and each is a line of code below rather |
| 364 | // than an intention: |
| 365 | // |
| 366 | // 1. A REAL CHOICE. Two buttons of the same weight, neither pre-pressed, |
| 367 | // neither dressed as the way out. Nothing is ticked, because there is no |
| 368 | // tick: consent here is a function call that only a press can make. |
| 369 | // 2. DECLINING AS EASY AS AGREEING, and the DEFAULT. Closing the card, |
| 370 | // pressing Escape, clicking the scrim, walking away -- every one of them |
| 371 | // leaves `consent()` uncalled, so silence is a no. There is no path |
| 372 | // through this file that agrees on somebody's behalf. |
| 373 | // 3. HONEST ABOUT THE COST. It says what is sent, what is never sent, that |
| 374 | // it is not anonymous, and that saying no costs nothing -- because it |
| 375 | // does not: the account, Pro and everything else are already granted by |
| 376 | // the time this is drawn. |
| 377 | // 4. WITHDRAWABLE, AND IT SAYS WHERE. The same question lives in the |
| 378 | // Credits drawer for as long as the account does, so "you can turn it |
| 379 | // off in Credits" names a control that is really there. See `render`. |
| 380 | |
| 381 | /// Can this reply be turned into a question worth asking? |
| 382 | /// |
| 383 | /// All four, or the card stays quiet: a client to record with, an intake to |
| 384 | /// record under, an account to scope the agreement to, and a session -- a |
| 385 | /// redemption that could not sign in has nothing to send under, and asking |
| 386 | /// then would be collecting an answer we could not honour. |
| 387 | function canAsk(r) { |
| 388 | if (!window.DaimondTelemetry) return null; |
| 389 | if (!r || !r.authed) return null; |
| 390 | var wave = r.wave, account = r.account; |
| 391 | if (typeof wave !== 'number' || wave < 1 || !account) return null; |
| 392 | return { wave: wave, account: account }; |
| 393 | } |
| 394 | |
| 395 | /// Say yes, for this account. The one call in this file that starts a |
| 396 | /// recorder, and it is reachable only from a button. |
| 397 | function grant(ask) { |
| 398 | try { DaimondTelemetry.consent({ wave: ask.wave, account: ask.account }); } |
| 399 | catch (e) { /* a build without the client; the question was not drawn */ } |
| 400 | } |
| 401 | |
| 402 | /// Say no, or take it back. Also the only call that stops one. |
| 403 | function revoke() { |
| 404 | try { DaimondTelemetry.withdraw(); } |
| 405 | catch (e) { /* nothing to withdraw from */ } |
| 406 | } |
| 407 | |
| 408 | /// The words, the link and the two buttons. |
| 409 | /// |
| 410 | /// `after` is what to do once either button is pressed -- close the card, or |
| 411 | /// redraw the drawer. It is called for BOTH answers and with no argument |
| 412 | /// saying which, so nothing downstream can behave differently for a person |
| 413 | /// who declined. |
| 414 | function consentBlock(ask, after) { |
| 415 | var wrap = el('div', 'beta-consent'); |
| 416 | wrap.appendChild(el('div', 'beta-head', t('beta.tel_title'))); |
| 417 | wrap.appendChild(el('p', null, t('beta.tel_lead'))); |
| 418 | wrap.appendChild(el('p', null, t('beta.tel_never'))); |
| 419 | wrap.appendChild(el('p', null, t('beta.tel_who'))); |
| 420 | wrap.appendChild(el('p', 'beta-note', t('beta.tel_free'))); |
| 421 | |
| 422 | // The policy, in the panel, at the section that describes this exactly. |
| 423 | // `DaimondLegal.link` was written for this caller and no other. |
| 424 | if (window.DaimondLegal && DaimondLegal.link) { |
| 425 | var p = el('p', 'beta-ask'); |
| 426 | p.appendChild(DaimondLegal.link('privacy', t('beta.tel_more'), 'beta-telemetry')); |
| 427 | wrap.appendChild(p); |
| 428 | } |
| 429 | |
| 430 | var row = el('div', 'beta-row'); |
| 431 | // NO FIRST. Not because the order decides anything on its own, but |
| 432 | // because the eye lands left and the button that costs the reader nothing |
| 433 | // should be the one it lands on. Both carry `beta-btn`: same size, same |
| 434 | // weight, same colour. |
| 435 | var no = el('button', 'beta-btn', t('beta.tel_no')); |
| 436 | no.type = 'button'; |
| 437 | no.addEventListener('click', function () { revoke(); if (after) after(); }); |
| 438 | row.appendChild(no); |
| 439 | |
| 440 | var yes = el('button', 'beta-btn', t('beta.tel_yes')); |
| 441 | yes.type = 'button'; |
| 442 | yes.addEventListener('click', function () { grant(ask); if (after) after(); }); |
| 443 | row.appendChild(yes); |
| 444 | wrap.appendChild(row); |
| 445 | return wrap; |
| 446 | } |
| 447 | |
| 448 | // ── The block in the Credits drawer ──────────────────────── |
| 449 | // |
| 450 | // WHY THERE. The Credits view is where this app already answers "what |
| 451 | // account have I got, and what does it cost me" -- the balance, the Pro |
| 452 | // licence, and the invitation to make an account are all in it. A closed |
| 453 | // registration is an answer to the same question, so it belongs beside them |
| 454 | // rather than on a surface of its own that nobody would think to look at. |
| 455 | // The status row above the drawer already reads "No credits account" for a |
| 456 | // refused device, and it opens this view, so the chain from the rail to the |
| 457 | // code field is one click and existed before this file did. |
| 458 | // |
| 459 | // It draws into `#credits-beta` and touches nothing else in the view. |
| 460 | |
| 461 | function host() { return document.getElementById('credits-beta'); } |
| 462 | |
| 463 | /// Usage counts, on or off, for an account that is in the test. |
| 464 | /// |
| 465 | /// TWO STATES AND ONE SET OF WORDS. Off, it draws the same question the |
| 466 | /// redemption card drew -- same sentences, same link, same two buttons -- so |
| 467 | /// somebody who said no at the door and has since changed their mind is asked |
| 468 | /// exactly what they were asked before, rather than being offered a shorter |
| 469 | /// version that leaves the cost out. On, it says so and offers the way out. |
| 470 | /// |
| 471 | /// The way out is the reason this exists. Consent that cannot be withdrawn is |
| 472 | /// not consent, and until this block existed the only honest thing to do with |
| 473 | /// the whole feature was to leave it unbuilt. |
| 474 | function telemetryBlock(s) { |
| 475 | var wrap = el('div', 'beta-tel'); |
| 476 | var on = false; |
| 477 | try { on = DaimondTelemetry.agreed(s.accountId); } catch (e) { on = false; } |
| 478 | if (!on) { |
| 479 | wrap.appendChild(consentBlock({ wave: s.wave, account: s.accountId }, refresh)); |
| 480 | return wrap; |
| 481 | } |
| 482 | wrap.appendChild(el('div', 'beta-head', t('beta.tel_title_on'))); |
| 483 | wrap.appendChild(el('p', 'beta-lead', t('beta.tel_on'))); |
| 484 | if (window.DaimondLegal && DaimondLegal.link) { |
| 485 | var p = el('p', 'beta-ask'); |
| 486 | p.appendChild(DaimondLegal.link('privacy', t('beta.tel_more'), 'beta-telemetry')); |
| 487 | wrap.appendChild(p); |
| 488 | } |
| 489 | var stop = el('button', 'beta-btn', t('beta.tel_stop')); |
| 490 | stop.type = 'button'; |
| 491 | stop.addEventListener('click', function () { |
| 492 | revoke(); |
| 493 | // Redrawn from the module's own answer rather than from what this |
| 494 | // button believes it just did, so what is on screen is what is true. |
| 495 | refresh(); |
| 496 | }); |
| 497 | wrap.appendChild(stop); |
| 498 | return wrap; |
| 499 | } |
| 500 | |
| 501 | /// Draw the block, or empty it where there is nothing honest to put in it. |
| 502 | /// |
| 503 | /// Called by `DaimondCredits.render` -- which daimond.js invokes every time |
| 504 | /// the Credits view is drawn, on opening it and on a language change -- and |
| 505 | /// again whenever the account's state moves under an open drawer. |
| 506 | function render() { |
| 507 | var h = host(); |
| 508 | if (!h) return; |
| 509 | h.innerHTML = ''; |
| 510 | if (!window.DaimondGateway) return; |
| 511 | var s = acct(); |
| 512 | // An account already exists: there is nothing here to redeem for, and a |
| 513 | // passcode field on a signed-in account would be a control looking for a |
| 514 | // problem. |
| 515 | // |
| 516 | // BUT A BETA ACCOUNT HAS ONE THING TO SAY HERE, and this is where the |
| 517 | // consent given at redemption is taken back. It is in the Credits view |
| 518 | // because that is where this app already answers "what account have I |
| 519 | // got"; it is in THIS file because this file asked the question, and one |
| 520 | // question with two surfaces must not become two sets of words. |
| 521 | // |
| 522 | // Drawn only for an account the gateway still names in the beta on this |
| 523 | // boot, which is the same standing `rearm()` reads: a revoked passcode |
| 524 | // leaves nothing here, because there is nothing left to withdraw. |
| 525 | if (s.authed) { |
| 526 | if (window.DaimondTelemetry && s.beta === true && s.wave && s.accountId) { |
| 527 | h.appendChild(telemetryBlock(s)); |
| 528 | } |
| 529 | return; |
| 530 | } |
| 531 | // Nothing to sign with. The view's own "Create an account" path is the |
| 532 | // step before this one, and it is already on screen below. |
| 533 | if (!canSign()) return; |
| 534 | |
| 535 | h.appendChild(el('div', 'beta-head', titleFor(s.refused))); |
| 536 | h.appendChild(el('p', 'beta-lead', leadFor(s))); |
| 537 | var b = el('button', 'beta-btn', t('beta.enter_code')); |
| 538 | b.type = 'button'; |
| 539 | b.id = 'beta-open'; |
| 540 | b.addEventListener('click', function () { show(); }); |
| 541 | h.appendChild(b); |
| 542 | } |
| 543 | |
| 544 | /// Redraw whatever of ours is on screen. Cheap, and safe from anywhere. |
| 545 | function refresh() { |
| 546 | try { render(); } catch (e) { /* the drawer is not built yet */ } |
| 547 | } |
| 548 | |
| 549 | // ── Being told ───────────────────────────────────────────── |
| 550 | |
| 551 | /// The refusals this browsing session has already put on screen. |
| 552 | /// |
| 553 | /// Once per reason per session. `bootstrap()` runs on every unlock and the |
| 554 | /// standing renewal retries on a timer, so a device that is refused is |
| 555 | /// refused repeatedly -- and a dialog that came back each time would be a |
| 556 | /// nag rather than an answer. It comes back in a new sitting, because the |
| 557 | /// state it describes is still true and a person who dismissed it a week ago |
| 558 | /// has forgotten. The drawer's block carries it in between. |
| 559 | var SAID = 'daimond-beta-said'; |
| 560 | |
| 561 | // ── Asked for at the front door ──────────────────────────── |
| 562 | // |
| 563 | // The identity screen offers three ways in to a browser that has never held |
| 564 | // an account (`syncDoors`, js/daimond.js). Two are links to /apply.html. The |
| 565 | // third is this dialog -- and it cannot open there, because a redemption is |
| 566 | // SIGNED by the device key and a stranger at that screen has none. The order |
| 567 | // is forced: passphrase first, code second. |
| 568 | // |
| 569 | // So the button records that a code is waiting, and `resume()` opens the |
| 570 | // dialog at the first moment it could work. The flag is sessionStorage and |
| 571 | // not a variable: creating an identity can end in a reload on some paths, |
| 572 | // and an intent that a reload forgets is a route that works when tested by |
| 573 | // hand and not when used. |
| 574 | var WANT = 'daimond-beta-wanted'; |
| 575 | |
| 576 | function wanted() { |
| 577 | try { return sessionStorage.getItem(WANT) === '1'; } |
| 578 | catch (e) { return false; } |
| 579 | } |
| 580 | function noteWanted(on) { |
| 581 | try { |
| 582 | if (on) sessionStorage.setItem(WANT, '1'); |
| 583 | else sessionStorage.removeItem(WANT); |
| 584 | } catch (e) { /* private mode: the refusal path still gets there */ } |
| 585 | } |
| 586 | |
| 587 | /// The front door's "I have a passcode". |
| 588 | /// |
| 589 | /// Returns true when the dialog is up and false when it could not be -- the |
| 590 | /// caller says what happens next, because what to say depends on the screen |
| 591 | /// it is said on. |
| 592 | function front() { |
| 593 | if (canSign()) { noteWanted(false); show(); return true; } |
| 594 | noteWanted(true); |
| 595 | return false; |
| 596 | } |
| 597 | |
| 598 | /// Open the dialog if one was asked for before it could be opened. |
| 599 | /// |
| 600 | /// Called once the gate is down and there is a key to sign with. Silent when |
| 601 | /// nothing was asked for, when the account already exists (there is nothing |
| 602 | /// left to redeem for), or when the dialog is already on screen. |
| 603 | function resume() { |
| 604 | if (!wanted()) return; |
| 605 | if (!canSign()) return; |
| 606 | if (acct().authed) { noteWanted(false); return; } |
| 607 | noteWanted(false); |
| 608 | // The same delay the refusal path uses, and for the same reason: the |
| 609 | // identity modal is still fading out of the frame this runs on. |
| 610 | setTimeout(function () { show(); }, 600); |
| 611 | } |
| 612 | |
| 613 | function alreadySaid(reason) { |
| 614 | try { return sessionStorage.getItem(SAID) === reason; } |
| 615 | catch (e) { return false; } // private mode: say it, rather than never. |
| 616 | } |
| 617 | function noteSaid(reason) { |
| 618 | try { sessionStorage.setItem(SAID, reason); } catch (e) { /* private mode */ } |
| 619 | } |
| 620 | |
| 621 | function onRefused(ev) { |
| 622 | var reason = (ev && ev.detail && ev.detail.reason) || acct().refused || ''; |
| 623 | refresh(); |
| 624 | if (!reason || !canSign()) return; |
| 625 | if (alreadySaid(reason)) return; |
| 626 | noteSaid(reason); |
| 627 | // After the frame the unlock is finishing on, so the card does not land |
| 628 | // on top of the identity modal's own fade. |
| 629 | setTimeout(function () { show({ reason: reason }); }, 600); |
| 630 | } |
| 631 | |
| 632 | // ── Recording again, for somebody who already said yes ───── |
| 633 | // |
| 634 | // The boot half of consent. A tester agrees once, at redemption; every |
| 635 | // session after that has to start recording again on its own, and this is |
| 636 | // the only thing that does it. It is `resume()` and never `consent()`: the |
| 637 | // difference is that this cannot create an agreement, only restore one that |
| 638 | // the person made and that the gateway still stands behind on THIS boot. |
| 639 | // |
| 640 | // Three facts must line up, and all three come off the gateway's own answer |
| 641 | // rather than off anything remembered here -- see `beta_standing` in |
| 642 | // gateway/src/handlers/account.rs. A revoked passcode takes the account's |
| 643 | // status with it, so the next boot names no wave and this quietly does |
| 644 | // nothing. |
| 645 | |
| 646 | /// Restore a recorder for an account that has already agreed. |
| 647 | function rearm() { |
| 648 | if (!window.DaimondTelemetry) return; // a build without the client. |
| 649 | var s = acct(); |
| 650 | if (!s.authed || s.beta !== true || !s.wave || !s.accountId) return; |
| 651 | try { DaimondTelemetry.resume({ wave: s.wave, account: s.accountId }); } |
| 652 | catch (e) { /* telemetry may never break the app it reports on */ } |
| 653 | } |
| 654 | |
| 655 | function start() { |
| 656 | window.addEventListener('daimond:refused', onRefused); |
| 657 | // The account moved -- signed in, balance read, logged out. Whatever of |
| 658 | // ours is on screen is about to be wrong. |
| 659 | window.addEventListener('daimond:authed', function () { rearm(); refresh(); }); |
| 660 | window.addEventListener('daimond:credits', function () { |
| 661 | // AFTER daimond.js's own listener, which redraws the Credits view |
| 662 | // from scratch when the drawer is open. Ours is registered first -- |
| 663 | // this file is a classic script and daimond.js is a module -- so a |
| 664 | // direct call here would paint into a block the redraw then walks |
| 665 | // past. A task boundary puts it back on the correct side. |
| 666 | setTimeout(refresh, 0); |
| 667 | }); |
| 668 | // A tab that was already signed in when this file loaded raises no |
| 669 | // `daimond:authed` for us to hear, and that is the ordinary case on a |
| 670 | // reload. Without this line consent survived a reload in name only. |
| 671 | rearm(); |
| 672 | refresh(); |
| 673 | } |
| 674 | |
| 675 | // ── Public surface ───────────────────────────────────────── |
| 676 | |
| 677 | window.DaimondPasscode = { |
| 678 | /// The dialog. `show()` reads the gateway state; `show({reason})` says |
| 679 | /// which refusal it is answering. |
| 680 | show: show, |
| 681 | /// Draw the Credits drawer's block from the state as it stands now. |
| 682 | render: render, |
| 683 | /// The identity screen's "I have a passcode". True when the dialog is up, |
| 684 | /// false when the intent was recorded for `resume()` instead. |
| 685 | front: front, |
| 686 | /// Open a dialog `front()` could not, now that there is a key to sign with. |
| 687 | resume: resume, |
| 688 | }; |
| 689 | |
| 690 | // THE MOUNT POINT. daimond.js's `drawCredits` calls `DaimondCredits.render()` |
| 691 | // every time the Credits view is drawn -- on opening it, and on a language |
| 692 | // change -- immediately after `renderCredits` has laid the view out. It is an |
| 693 | // extension point with no other implementor, and it is the only hook into |
| 694 | // that view that does not mean editing daimond.js. Anything else wanting to |
| 695 | // draw in the Credits view has to come through here rather than replace it. |
| 696 | window.DaimondCredits = { render: render }; |
| 697 | |
| 698 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); |
| 699 | else start(); |
| 700 | })(); |