Oregami
Repositories/oxedyne/daimond

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})();